Skip to content

feat: epic closeouts#3

Merged
php-workx merged 20 commits into
mainfrom
feat/epic-closeouts
Apr 22, 2026
Merged

feat: epic closeouts#3
php-workx merged 20 commits into
mainfrom
feat/epic-closeouts

Conversation

@php-workx
Copy link
Copy Markdown
Owner

@php-workx php-workx commented Apr 21, 2026

Summary by CodeRabbit

  • New Features

    • Added ticket outcome tracking (closed, blocked, failed/retryable, needs-decision).
    • Advisory validation checks now trigger best-effort repair cycles without blocking closure.
  • Bug Fixes

    • More graceful streaming process termination to avoid hangs.
    • Hardened claim handling to prevent path traversal and symlink escape.
    • Claim renewal now prefers live-claim data and schedules more reliably.
    • CLI run/persistence fixes to avoid incorrect current-run pointer updates.
  • Documentation

    • Published repair-oriented run-gates and ticket state-machine plans.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 21, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds TicketOutcome types and persistence, tightens claim path validation and containment, refactors CLI run persistence and claim-renewal logic, centralizes runtime parsing/normalization, adjusts adapter process-kill behavior, and expands tests and documentation across engine, CLI, adapters, and ticketstore.

Changes

Cohort / File(s) Summary
Ticket outcome & engine changes
internal/state/types.go, internal/engine/ticket_run.go, internal/engine/ticket_run_test.go, internal/engine/epic_run.go, internal/engine/epic_run_test.go, internal/engine/resume.go, internal/engine/resume_test.go, internal/engine/closeout_test.go
Added exported TicketOutcome and persisted Outcome in ticket snapshots; verification now can record advisory-driven repair cycles; claim-renewal uses live-claim TTL; default blocked-ticket phase changed to intake unless snapshot overrides; tests updated/added for outcome, waiver, closeout, and blocked-ticket behavior.
Claim path safety & ticketstore hardening
internal/adapters/ticketstore/tkmd/claims.go, internal/adapters/ticketstore/tkmd/claims_test.go, internal/adapters/ticketstore/tkmd/store.go, internal/adapters/ticketstore/tkmd/store_test.go
Introduced ErrInvalidIdentifier, strengthened identifier validation, ensured live/durable base dirs are created, added containment/symlink-escape checks and helpers; updated tests to assert invalid identifiers, containment, symlink-escape failures, and adjusted concurrent claim tests.
CLI run flow, persistence & pointer behavior
internal/cli/run.go, internal/cli/run_current_pointer_test.go, internal/cli/run_persistence_test.go, internal/cli/run_sync_test.go, internal/cli/root.go
Made runTicket injectable; doRunTicket uses named returns and defers run-id emission until final persistence; added conditional .verk/current update helper; removed premature current writes; added tests for pointer/persistence failures and goroutine sync; ExecuteArgs uses ExecuteC().
Runtime parsing & adapter behavior
internal/adapters/runtime/normalize.go, internal/adapters/runtime/prompt.go, internal/adapters/runtime/prompt_test.go, internal/adapters/runtime/claude/adapter.go, internal/adapters/runtime/claude/adapter_test.go
Moved worker-status normalization to map lookup; funneled parse paths through finalize* helpers enforcing normalized statuses; scanner error path now prefers cmd.Cancel() when available; expanded tests for parsing normalization and subprocess/process-group termination.
Claims/TTL and renewal scheduling
internal/engine/ticket_run.go, internal/e2e/helpers_test.go, internal/e2e/resume_claim_recovery_test.go
Replaced remainingTTL() with currentClaimRemainingTTL() to prefer live claim expiry; centralized renewal logic via a renew() closure; tests adjusted to use time.Now().UTC() for claim seeding consistency.
Ticket-run behaviors & extensive tests
internal/engine/ticket_run_test.go, internal/engine/ticket_run.go
Added many tests covering verification-derived repairs, reopen flows, claim renewal/renew-from-live behavior, snapshot outcome mapping, and preserved createdAt; added helper test utilities; changed verification flow to record advisory failing checks and trigger repair when applicable.
Ticketstore load/ready behaviors
internal/adapters/ticketstore/tkmd/store.go, internal/adapters/ticketstore/tkmd/store_test.go
loadEpicChildren now returns empty children on load errors; claimAllowsReady validates ticketID early; tests added to assert rejection of traversal ticketIDs and to ensure malformed epic errors propagate.
CLI tests & small CLI fixes
cmd/verk/main_test.go, internal/cli/init_test.go, internal/cli/shared_test.go
Refactored large-output CLI test to end-to-end repo fixture; added errReader to simulate stdin read failures and test init abort behavior; adjusted LatestRunID test fixtures.
Adapter env allowlist & runner tests
internal/adapters/verify/command/runner.go, internal/adapters/verify/command/runner_test.go, tools.mod
Removed LOGNAME, TERM, USER from default allowlist; tests updated to assert expected env inclusions/exclusions; added replace verk => . to tools.mod.
Parsing/unit test additions
internal/adapters/runtime/prompt_test.go, internal/adapters/runtime/prompt.go
Added table-driven tests asserting normalization/validation across all parse paths; introduced finalizeResultBlock/finalizeReviewBlock to centralize status normalization and validation.
Docs & plans
docs/plans/2026-04-19-verk-run-repair-oriented-gates.md, docs/plans/INDEX.md, docs/plans/validation-coverage.md, docs/plans/2026-04-22-ticket-state-machine.md
Marked repair-oriented run gates implemented, added new ticket-state-machine plan, clarified advisory vs required check semantics in validation coverage, and updated INDEX priorities and legend entries.
Misc tests & tooling tweaks
internal/e2e/*, internal/cli/*, internal/adapters/*
Multiple tests updated to use wall-clock UTC for claim timestamps, many new/expanded unit and integration tests across adapters, engine, and CLI; small helpers and test scaffolding added or adjusted.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.36% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: epic closeouts' is concise and directly describes the main feature added in this PR - implementation of epic closeout functionality.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/cli/run.go (1)

377-403: ⚠️ Potential issue | 🟠 Major

Blocked-epic runs not resumable when not retried: pointer missing for non-interactive failures.

At line 395, when handleBlockedEpicRun returns false (non-TTY environment or user selects no tickets), doRunEpic returns the original runErr without writing .verk/current. However, engine.RunEpic already persisted run.json before returning the BlockedRunError (lines 85–87, 159–162 in epic_run.go), so a valid run artifact exists on disk but is unreachable by doAutoResume due to the missing pointer.

Write .verk/current unconditionally after engine.RunEpic returns, even on blocked error, to ensure all persisted runs remain resumable. The pointer-write can move before the blocked-error handling (line 377) or be duplicated in the non-retried path (after line 394).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cli/run.go` around lines 377 - 403, When engine.RunEpic has
persisted run.json but returns a BlockedRunError, doRunEpic currently returns
early without writing the .verk/current pointer; update doRunEpic so
writeCurrentRunID(repoRoot, runID) is invoked unconditionally after
engine.RunEpic returns (i.e., before handling asBlockedRunError), or add a
writeCurrentRunID call in the non-retried path after handleBlockedEpicRun
returns false, ensuring runID is written to disk even when runErr is a blocked
error; reference symbols: doRunEpic, engine.RunEpic, runErr, asBlockedRunError,
handleBlockedEpicRun, writeCurrentRunID, runID, repoRoot.
🧹 Nitpick comments (4)
internal/cli/shared_test.go (1)

70-71: Fix the lexicographic explanation in the test comment.

The statement is reversed: run-ticket-a-1000 sorts before run-ticket-z-2000, not the other way around.

Suggested comment correction
-	// run-ticket-z-2000 sorts lex BEFORE run-ticket-a-1000,
+	// run-ticket-a-1000 sorts lex BEFORE run-ticket-z-2000,
 	// but 2000 > 1000, so run-ticket-z-2000 must win.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cli/shared_test.go` around lines 70 - 71, The test comment
incorrectly states sort order; update the comment referencing the example
branches `run-ticket-a-1000` and `run-ticket-z-2000` to say that
`run-ticket-a-1000` sorts lexicographically before `run-ticket-z-2000` (so even
though 2000 > 1000, lexicographic ordering places the "a" branch first), and
adjust the sentence around that example in the block containing those examples
to reflect the correct ordering.
internal/adapters/verify/command/runner_test.go (1)

199-215: Consider removing or softening this host-dependent test.

Line 203 (go version) makes this test depend on runtime toolchain availability and overlaps with the PATH assertion already covered by TestRunCommands_DefaultEnvIncludesPath (Line 218-248). Prefer one deterministic PATH test to avoid CI flakiness.

♻️ Optional simplification
-func TestRunCommands_PathDependentCommandWorksByDefault(t *testing.T) {
-	repoRoot := t.TempDir()
-
-	results, err := RunCommands(context.Background(), repoRoot, []string{
-		"go version",
-	}, policy.VerificationConfig{
-		DefaultTimeoutMinutes: 1,
-	})
-	if err != nil {
-		t.Fatalf("RunCommands returned error: %v", err)
-	}
-	if len(results) != 1 {
-		t.Fatalf("expected 1 command result, got %d", len(results))
-	}
-	if results[0].ExitCode != 0 {
-		t.Fatalf("expected PATH-dependent command to run, got exit code %d", results[0].ExitCode)
-	}
-}
+// Covered by TestRunCommands_DefaultEnvIncludesPath.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/adapters/verify/command/runner_test.go` around lines 199 - 215, The
test TestRunCommands_PathDependentCommandWorksByDefault is host-dependent
because it runs "go version" and should be removed or softened; either delete
this test function entirely or change it to skip on CI (call t.Skip) or replace
the command with a deterministic, platform-neutral check already covered by
TestRunCommands_DefaultEnvIncludesPath; update/remove any assertions in
TestRunCommands_PathDependentCommandWorksByDefault and keep
TestRunCommands_DefaultEnvIncludesPath as the single PATH verification for
RunCommands.
internal/adapters/ticketstore/tkmd/claims.go (1)

562-576: Redundant condition at lines 572-574.

The check at lines 572-574 is unreachable. If rel starts with ".." but isn't equal to ".." and doesn't start with ".."+separator, the only remaining possibility would be something like "..foo" which filepath.Rel would never produce for a valid relative path.

The condition at line 569 already covers rel == ".." and strings.HasPrefix(rel, ".."+separator), making lines 572-574 dead code.

🔧 Suggested simplification
 func assertPathUnderBase(target, base string) error {
 	cleanTarget := filepath.Clean(target)
 	cleanBase := filepath.Clean(base)
 	rel, err := filepath.Rel(cleanBase, cleanTarget)
 	if err != nil {
 		return fmt.Errorf("claim path resolution failed: %w", err)
 	}
 	if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
 		return fmt.Errorf("claim path escapes base directory")
 	}
-	if strings.HasPrefix(rel, "..") {
-		return fmt.Errorf("claim path escapes base directory")
-	}
 	return nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/adapters/ticketstore/tkmd/claims.go` around lines 562 - 576, The
function assertPathUnderBase contains a redundant check: after computing rel via
filepath.Rel and already checking rel == ".." and strings.HasPrefix(rel,
".."+string(filepath.Separator)), the subsequent if strings.HasPrefix(rel, "..")
branch is unreachable and should be removed; update the function (referencing
assertPathUnderBase, rel, filepath.Rel, filepath.Separator) to delete the
redundant condition and return nil as before, leaving only the initial two
escape checks.
internal/engine/resume_test.go (1)

350-355: Remove the redundant nil check in the second assertion.

After the explicit nil guard, snapshot.Closeout == nil || ... can be simplified for readability.

♻️ Suggested cleanup
 if snapshot.Closeout == nil {
 	t.Fatalf("expected repaired closeout, got %#v", snapshot.Closeout)
 }
-if snapshot.Closeout == nil || !snapshot.Closeout.Closable {
+if !snapshot.Closeout.Closable {
 	t.Fatalf("expected closable closeout, got %#v", snapshot.Closeout)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/engine/resume_test.go` around lines 350 - 355, The second assertion
redundantly checks nil after the prior guard; update the test so the first check
remains (if snapshot.Closeout == nil { t.Fatalf(...) }) and the subsequent
assertion only verifies the Closable field (if !snapshot.Closeout.Closable {
t.Fatalf("expected closable closeout, got %#v", snapshot.Closeout) }),
referencing snapshot.Closeout to locate the code in the test.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cmd/verk/main_test.go`:
- Around line 187-199: The test creates a run-level fixture but not the
per-ticket snapshots that status expects, causing failures; before calling
runCLIFromDir(t, repoRoot, "status", largeRunID) iterate ticketIDs and write a
ticket-level fixture (e.g., using writeJSONFixture) for each ticket with the
expected structure (a ticket run artifact / snapshot containing
RunID/RunArtifact fields such as SchemaVersion, RunID, TicketID/RootTicketID,
Status/CurrentPhase matching state.* types) and the correct path/name (the
ticket-run.json file the status command loads) so that status can load each
ticket artifact during the test.

In `@internal/adapters/runtime/prompt.go`:
- Around line 248-249: The parse logic accepts statuses only for the sentinel
path but not for the direct or last-JSON paths, causing inconsistent acceptance;
after any successful parse (e.g., parseSentinelLine[VerkResultBlock],
parseDirectJSON, parseLastJSON) run the same canonicalization/validation step:
convert the parsed status into WorkerStatus and call
ValidateWorkerStatus(WorkerStatus(...)) (and replace the status with the
canonical/normalized value if needed) before returning the VerkResultBlock so
all parse branches use identical status validation/normalization.

In `@internal/adapters/runtime/types.go`:
- Around line 341-343: The expiry boundary is inconsistent: in
internal/adapters/runtime/types.go the isExpiredWaiver check treats
WaiverExpiresAt == now as expired (!After(time.Now())), while
internal/engine/closeout.go uses Before(time.Now()) (< now). Change the
isExpiredWaiver logic in internal/adapters/runtime/types.go (the isExpiredWaiver
variable computation) to use finding.WaiverExpiresAt.Before(time.Now()) with the
nil-check (i.e., finding.WaiverExpiresAt != nil &&
finding.WaiverExpiresAt.Before(time.Now())) so both code paths use the same
strict-before semantics as internal/engine/closeout.go.

In `@internal/engine/closeout_test.go`:
- Around line 396-398: Replace the weak check that only rejects "open" with an
explicit equality assertion: instead of else if got.Disposition == "open" {
t.Fatalf(...) }, compare got.Disposition to tc.finding.Disposition and fail with
a clear message using tc.name when they differ; i.e., assert got.Disposition ==
tc.finding.Disposition and call t.Fatalf("%s: expected disposition %q, got %q",
tc.name, tc.finding.Disposition, got.Disposition) on mismatch.

---

Outside diff comments:
In `@internal/cli/run.go`:
- Around line 377-403: When engine.RunEpic has persisted run.json but returns a
BlockedRunError, doRunEpic currently returns early without writing the
.verk/current pointer; update doRunEpic so writeCurrentRunID(repoRoot, runID) is
invoked unconditionally after engine.RunEpic returns (i.e., before handling
asBlockedRunError), or add a writeCurrentRunID call in the non-retried path
after handleBlockedEpicRun returns false, ensuring runID is written to disk even
when runErr is a blocked error; reference symbols: doRunEpic, engine.RunEpic,
runErr, asBlockedRunError, handleBlockedEpicRun, writeCurrentRunID, runID,
repoRoot.

---

Nitpick comments:
In `@internal/adapters/ticketstore/tkmd/claims.go`:
- Around line 562-576: The function assertPathUnderBase contains a redundant
check: after computing rel via filepath.Rel and already checking rel == ".." and
strings.HasPrefix(rel, ".."+string(filepath.Separator)), the subsequent if
strings.HasPrefix(rel, "..") branch is unreachable and should be removed; update
the function (referencing assertPathUnderBase, rel, filepath.Rel,
filepath.Separator) to delete the redundant condition and return nil as before,
leaving only the initial two escape checks.

In `@internal/adapters/verify/command/runner_test.go`:
- Around line 199-215: The test
TestRunCommands_PathDependentCommandWorksByDefault is host-dependent because it
runs "go version" and should be removed or softened; either delete this test
function entirely or change it to skip on CI (call t.Skip) or replace the
command with a deterministic, platform-neutral check already covered by
TestRunCommands_DefaultEnvIncludesPath; update/remove any assertions in
TestRunCommands_PathDependentCommandWorksByDefault and keep
TestRunCommands_DefaultEnvIncludesPath as the single PATH verification for
RunCommands.

In `@internal/cli/shared_test.go`:
- Around line 70-71: The test comment incorrectly states sort order; update the
comment referencing the example branches `run-ticket-a-1000` and
`run-ticket-z-2000` to say that `run-ticket-a-1000` sorts lexicographically
before `run-ticket-z-2000` (so even though 2000 > 1000, lexicographic ordering
places the "a" branch first), and adjust the sentence around that example in the
block containing those examples to reflect the correct ordering.

In `@internal/engine/resume_test.go`:
- Around line 350-355: The second assertion redundantly checks nil after the
prior guard; update the test so the first check remains (if snapshot.Closeout ==
nil { t.Fatalf(...) }) and the subsequent assertion only verifies the Closable
field (if !snapshot.Closeout.Closable { t.Fatalf("expected closable closeout,
got %#v", snapshot.Closeout) }), referencing snapshot.Closeout to locate the
code in the test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 08b2b656-4b62-467f-a03a-b91a42f22851

📥 Commits

Reviewing files that changed from the base of the PR and between 28b3091 and 5f42d30.

📒 Files selected for processing (26)
  • cmd/verk/main_test.go
  • internal/adapters/runtime/claude/adapter.go
  • internal/adapters/runtime/claude/adapter_test.go
  • internal/adapters/runtime/normalize.go
  • internal/adapters/runtime/prompt.go
  • internal/adapters/runtime/types.go
  • internal/adapters/ticketstore/tkmd/claims.go
  • internal/adapters/ticketstore/tkmd/claims_test.go
  • internal/adapters/ticketstore/tkmd/store.go
  • internal/adapters/ticketstore/tkmd/store_test.go
  • internal/adapters/verify/command/runner.go
  • internal/adapters/verify/command/runner_test.go
  • internal/cli/init_test.go
  • internal/cli/root.go
  • internal/cli/run.go
  • internal/cli/run_current_pointer_test.go
  • internal/cli/run_persistence_test.go
  • internal/cli/run_sync_test.go
  • internal/cli/shared_test.go
  • internal/engine/closeout_test.go
  • internal/engine/epic_run.go
  • internal/engine/epic_run_test.go
  • internal/engine/resume.go
  • internal/engine/resume_test.go
  • internal/engine/ticket_run_test.go
  • tools.mod
💤 Files with no reviewable changes (1)
  • internal/adapters/verify/command/runner.go

Comment thread cmd/verk/main_test.go
Comment thread internal/adapters/runtime/prompt.go Outdated
Comment thread internal/adapters/runtime/types.go Outdated
Comment thread internal/engine/closeout_test.go Outdated
@php-workx
Copy link
Copy Markdown
Owner Author

Follow-up for CodeRabbit review #3 (review):

  • Inline threads in cmd/verk/main_test.go, internal/adapters/runtime/prompt.go, internal/adapters/runtime/types.go, and internal/engine/closeout_test.go are fixed and resolved.
  • The review-body/outside-diff finding in internal/cli/run.go is fixed: doRunEpic now writes .verk/current after engine.RunEpic returns with a persisted run artifact, including blocked runs that are not retried immediately.
  • The review-body nitpicks are fixed: the lexicographic test comment is corrected, the host-dependent go version test is removed in favor of the deterministic PATH test, the redundant assertPathUnderBase condition is removed, and the redundant snapshot.Closeout == nil assertion is removed.

Validation: just pre-commit passes.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/cli/run.go (1)

256-264: ⚠️ Potential issue | 🟠 Major

Publish .verk/current only after the run is durably resumable.

writeCurrentRunID() happens before tkmd.SaveTicket() and before the final run.json update. If either later write fails, doAutoResume() will still follow .verk/current to the stale startup snapshot and may rerun work that never started cleanly or already finished. Move the pointer write after the remaining required persistence succeeds, or clear it on those error paths.

🔧 Minimal direction
 	if err := saveJSONAtomic(filepath.Join(repoRoot, ".verk", "runs", runID, "run.json"), run); err != nil {
 		return runID, err
 	}
-	// Write the current-run pointer only after run.json is on disk.  An early
-	// return above (lock, claim, adapter, git, or this save) leaves the pointer
-	// untouched so subsequent commands never resolve to a run without an artifact.
-	if wErr := writeCurrentRunID(repoRoot, runID); wErr != nil {
-		_, _ = fmt.Fprintf(errw, "warning: could not write current run: %v\n", wErr)
-	}

 	ticket.Status = tkmd.StatusInProgress
 	if err := tkmd.SaveTicket(filepath.Join(repoRoot, ".tickets", ticketID+".md"), ticket); err != nil {
 		return runID, err
 	}
+	if wErr := writeCurrentRunID(repoRoot, runID); wErr != nil {
+		_, _ = fmt.Fprintf(errw, "warning: could not write current run: %v\n", wErr)
+	}
@@
 	); err != nil {
+		_ = clearCurrentRunID(repoRoot)
 		emitRunID = false
 		return runID, err
 	}

Also applies to: 267-268, 319-327

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cli/run.go` around lines 256 - 264, The current-run pointer is
written too early (writeCurrentRunID called immediately after saveJSONAtomic) so
doAutoResume may follow a pointer to a run whose ticket or final run.json wasn't
persisted; move the call to writeCurrentRunID so it executes only after
tkmd.SaveTicket() and the final run.json update complete successfully (or,
alternatively, clear the pointer on those error paths), making sure to update
code paths around saveJSONAtomic, tkmd.SaveTicket, and the final run.json write
(the run persistence and ticket-save logic referenced in doAutoResume) so
.verk/current is only published when the run is durably resumable.
🧹 Nitpick comments (4)
internal/adapters/runtime/prompt_test.go (1)

195-199: “normalizes spaces” case does not currently include spaced input.

The label says normalization is being tested, but the payload is already canonical ("passed"). Consider using a spaced/cased value to actually exercise normalization on the last-JSON path.

Suggested test tweak
 		{
 			name:       "last JSON normalizes spaces",
-			input:      "Review follows:\n{\"review_status\":\"passed\",\"summary\":\"ok\",\"findings\":[]}",
+			input:      "Review follows:\n{\"review_status\":\"  PASSED  \",\"summary\":\"ok\",\"findings\":[]}",
 			wantFound:  true,
 			wantStatus: "passed",
 		},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/adapters/runtime/prompt_test.go` around lines 195 - 199, The "last
JSON normalizes spaces" test case currently uses a canonical "passed" value so
it doesn't exercise normalization; update the test input string in the table
entry (the case with name "last JSON normalizes spaces") to include extra spaces
and/or different casing inside the JSON payload (e.g., " review_status": "
Passed " or similar) and keep wantFound true and wantStatus "passed" so the test
asserts that the parsing/normalization logic (the code path exercised by this
test) trims/spaces and lowercases the status correctly.
internal/engine/resume_test.go (1)

260-265: Strengthen this assertion to avoid a false-positive empty/empty match.

Great direction asserting BlockReason == FailedGate. To make it stricter, also assert FailedGate is non-empty before the equality check.

Proposed test hardening
 	if snapshot.Closeout == nil {
 		t.Fatalf("expected closeout to be repaired, got %#v", snapshot.Closeout)
 	}
+	if snapshot.Closeout.FailedGate == "" {
+		t.Fatalf("expected FailedGate to be set for non-closable closeout, got empty")
+	}
 	if snapshot.BlockReason != snapshot.Closeout.FailedGate {
 		t.Fatalf("expected BlockReason %q, got %q", snapshot.Closeout.FailedGate, snapshot.BlockReason)
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/engine/resume_test.go` around lines 260 - 265, The test currently
only checks snapshot.BlockReason == snapshot.Closeout.FailedGate which can pass
if both are empty; strengthen by first asserting snapshot.Closeout.FailedGate is
non-empty (e.g., t.Fatalf if ""), then assert snapshot.BlockReason equals
snapshot.Closeout.FailedGate; update the checks around snapshot.Closeout,
snapshot.Closeout.FailedGate and snapshot.BlockReason in resume_test.go to
perform the non-empty assertion before the equality assertion.
internal/cli/run_current_pointer_test.go (1)

97-100: Rename the test to match what it now asserts.

The body no longer checks “cleared on engine failure”; it only checks that .verk/current is not advanced to the new runID. Keeping the old name will make future failures harder to interpret.

Also applies to: 123-124

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cli/run_current_pointer_test.go` around lines 97 - 100, Rename the
test identifiers and descriptions to reflect the current assertion that
.verk/current is not advanced to the new runID when engine.RunEpic errors;
specifically change the doRunEpic-related test name(s) (e.g., any
TestDoRunEpic_ClearedOnEngineFailure or similar) to something like
TestDoRunEpic_DoesNotAdvanceCurrentOnEngineFailure (and likewise update the
other affected test name at the second location) and update their test
description strings so they assert "does not advance .verk/current to new runID
when engine.RunEpic returns an error" while leaving the test body and assertions
unchanged (references: doRunEpic, writeCurrentRunID, engine.RunEpic).
internal/engine/ticket_run.go (1)

1443-1452: Consider documenting the intentional outcome mapping subset.

The function only maps closed and blocked phases to outcomes, while state.TicketOutcome defines additional constants (failed_retryable, needs_decision, cancelled) that remain unused. The test names this legacy_blocked, suggesting future refinement.

Consider adding a brief comment noting that granular outcomes (e.g., distinguishing retryable vs. needs-decision blockers) are deferred to a follow-up.

📝 Suggested documentation
 func ticketOutcomeForPhase(phase state.TicketPhase) state.TicketOutcome {
+	// Maps terminal phases to outcomes. Non-terminal phases return empty.
+	// More granular outcomes (failed_retryable, needs_decision, cancelled)
+	// are reserved for future escalation classification work.
 	switch phase {
 	case state.TicketPhaseClosed:
 		return state.TicketOutcomeClosed
 	case state.TicketPhaseBlocked:
 		return state.TicketOutcomeBlocked
 	default:
 		return ""
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/engine/ticket_run.go` around lines 1443 - 1452, Add a short comment
above ticketOutcomeForPhase explaining that it intentionally maps only the two
legacy phases to outcomes — state.TicketPhaseClosed -> state.TicketOutcomeClosed
and state.TicketPhaseBlocked -> state.TicketOutcomeBlocked — and that other
outcome constants like state.TicketOutcomeFailedRetryable,
state.TicketOutcomeNeedsDecision, and state.TicketOutcomeCancelled are
intentionally deferred for a follow-up (the test refers to legacy_blocked). This
documents the deliberate subset mapping and signals future refinement without
changing behavior in ticketOutcomeForPhase.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/adapters/ticketstore/tkmd/claims.go`:
- Around line 562-571: assertPathUnderBase currently only does lexical checks
and allows symlink escapes; update it to resolve symlinks before comparing paths
by calling filepath.EvalSymlinks (or equivalent) on both the base and the target
and using those resolved paths for the relative check and separator-prefix
check. Ensure EvalSymlinks errors are returned (wrap with context like "claim
path resolution failed") and keep the same escape detection (rel == ".." or
strings.HasPrefix(rel, ".."+string(filepath.Separator))) but performed on the
resolved paths so symlinked parents cannot escape the base.

---

Outside diff comments:
In `@internal/cli/run.go`:
- Around line 256-264: The current-run pointer is written too early
(writeCurrentRunID called immediately after saveJSONAtomic) so doAutoResume may
follow a pointer to a run whose ticket or final run.json wasn't persisted; move
the call to writeCurrentRunID so it executes only after tkmd.SaveTicket() and
the final run.json update complete successfully (or, alternatively, clear the
pointer on those error paths), making sure to update code paths around
saveJSONAtomic, tkmd.SaveTicket, and the final run.json write (the run
persistence and ticket-save logic referenced in doAutoResume) so .verk/current
is only published when the run is durably resumable.

---

Nitpick comments:
In `@internal/adapters/runtime/prompt_test.go`:
- Around line 195-199: The "last JSON normalizes spaces" test case currently
uses a canonical "passed" value so it doesn't exercise normalization; update the
test input string in the table entry (the case with name "last JSON normalizes
spaces") to include extra spaces and/or different casing inside the JSON payload
(e.g., " review_status": " Passed " or similar) and keep wantFound true and
wantStatus "passed" so the test asserts that the parsing/normalization logic
(the code path exercised by this test) trims/spaces and lowercases the status
correctly.

In `@internal/cli/run_current_pointer_test.go`:
- Around line 97-100: Rename the test identifiers and descriptions to reflect
the current assertion that .verk/current is not advanced to the new runID when
engine.RunEpic errors; specifically change the doRunEpic-related test name(s)
(e.g., any TestDoRunEpic_ClearedOnEngineFailure or similar) to something like
TestDoRunEpic_DoesNotAdvanceCurrentOnEngineFailure (and likewise update the
other affected test name at the second location) and update their test
description strings so they assert "does not advance .verk/current to new runID
when engine.RunEpic returns an error" while leaving the test body and assertions
unchanged (references: doRunEpic, writeCurrentRunID, engine.RunEpic).

In `@internal/engine/resume_test.go`:
- Around line 260-265: The test currently only checks snapshot.BlockReason ==
snapshot.Closeout.FailedGate which can pass if both are empty; strengthen by
first asserting snapshot.Closeout.FailedGate is non-empty (e.g., t.Fatalf if
""), then assert snapshot.BlockReason equals snapshot.Closeout.FailedGate;
update the checks around snapshot.Closeout, snapshot.Closeout.FailedGate and
snapshot.BlockReason in resume_test.go to perform the non-empty assertion before
the equality assertion.

In `@internal/engine/ticket_run.go`:
- Around line 1443-1452: Add a short comment above ticketOutcomeForPhase
explaining that it intentionally maps only the two legacy phases to outcomes —
state.TicketPhaseClosed -> state.TicketOutcomeClosed and
state.TicketPhaseBlocked -> state.TicketOutcomeBlocked — and that other outcome
constants like state.TicketOutcomeFailedRetryable,
state.TicketOutcomeNeedsDecision, and state.TicketOutcomeCancelled are
intentionally deferred for a follow-up (the test refers to legacy_blocked). This
documents the deliberate subset mapping and signals future refinement without
changing behavior in ticketOutcomeForPhase.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0448f41d-d7ba-49df-9752-90a6dd679258

📥 Commits

Reviewing files that changed from the base of the PR and between c4b237e and 288ccf7.

📒 Files selected for processing (19)
  • cmd/verk/main_test.go
  • docs/plans/2026-04-22-ticket-state-machine.md
  • docs/plans/INDEX.md
  • internal/adapters/runtime/prompt.go
  • internal/adapters/runtime/prompt_test.go
  • internal/adapters/ticketstore/tkmd/claims.go
  • internal/adapters/verify/command/runner_test.go
  • internal/cli/run.go
  • internal/cli/run_current_pointer_test.go
  • internal/cli/shared_test.go
  • internal/e2e/helpers_test.go
  • internal/e2e/resume_claim_recovery_test.go
  • internal/engine/closeout_test.go
  • internal/engine/epic_run.go
  • internal/engine/epic_run_test.go
  • internal/engine/resume_test.go
  • internal/engine/ticket_run.go
  • internal/engine/ticket_run_test.go
  • internal/state/types.go
✅ Files skipped from review due to trivial changes (1)
  • internal/e2e/resume_claim_recovery_test.go
🚧 Files skipped from review as they are similar to previous changes (6)
  • internal/adapters/verify/command/runner_test.go
  • internal/engine/epic_run.go
  • internal/adapters/runtime/prompt.go
  • docs/plans/INDEX.md
  • internal/engine/closeout_test.go
  • cmd/verk/main_test.go

Comment thread internal/adapters/ticketstore/tkmd/claims.go
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
internal/engine/ticket_run.go (1)

703-730: Consider: declared/derived check ID collision behavior.

When the same ID exists in both DeclaredChecks and DerivedChecks, the derived check overwrites the declared one in the checks map (lines 705-710). If a declared check is non-advisory but a derived check with the same ID is advisory, the advisory flag from the derived check would be used.

If this is intentional (derived checks take precedence), a brief comment would clarify the design. If IDs are guaranteed unique across both slices, this is a non-issue.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/engine/ticket_run.go` around lines 703 - 730, The code in
advisoryFailingCheckIDs currently inserts DeclaredChecks then DerivedChecks into
the checks map so DerivedChecks overwrite DeclaredChecks on ID collisions
(variables: checks, coverage.DeclaredChecks, coverage.DerivedChecks); decide
intended precedence and make it explicit: either add a short comment above
advisoryFailingCheckIDs stating "Derived checks intentionally override declared
checks on ID collisions" if that behavior is desired, or change the insertion
order so declared checks take precedence (populate coverage.DerivedChecks first,
then coverage.DeclaredChecks) so the declared entry wins; update the function’s
comment accordingly to document the choice.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/adapters/ticketstore/tkmd/claims_test.go`:
- Around line 276-305: Update the tests around the maliciousIDs table so they
assert a validation-specific failure from RenewClaim/ReleaseClaim rather than
any error; locate the test cases using maliciousIDs and the calls to RenewClaim
and ReleaseClaim and change the assertion to check for the identifier-validation
error (e.g. compare against the exported validation error like
ErrInvalidIdentifier if present, or assert the error string contains a clear
validation marker such as "invalid" or "invalid identifier"), and explicitly
fail the test if the error is a generic "not found" or unrelated error so the
test guarantees traversal-hardening rejection up front.

---

Nitpick comments:
In `@internal/engine/ticket_run.go`:
- Around line 703-730: The code in advisoryFailingCheckIDs currently inserts
DeclaredChecks then DerivedChecks into the checks map so DerivedChecks overwrite
DeclaredChecks on ID collisions (variables: checks, coverage.DeclaredChecks,
coverage.DerivedChecks); decide intended precedence and make it explicit: either
add a short comment above advisoryFailingCheckIDs stating "Derived checks
intentionally override declared checks on ID collisions" if that behavior is
desired, or change the insertion order so declared checks take precedence
(populate coverage.DerivedChecks first, then coverage.DeclaredChecks) so the
declared entry wins; update the function’s comment accordingly to document the
choice.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 600a81e4-fc9c-4eda-bcc7-53586bc37f13

📥 Commits

Reviewing files that changed from the base of the PR and between 288ccf7 and ff24729.

📒 Files selected for processing (8)
  • internal/adapters/runtime/prompt_test.go
  • internal/adapters/ticketstore/tkmd/claims.go
  • internal/adapters/ticketstore/tkmd/claims_test.go
  • internal/cli/run.go
  • internal/cli/run_current_pointer_test.go
  • internal/cli/run_persistence_test.go
  • internal/engine/resume_test.go
  • internal/engine/ticket_run.go
✅ Files skipped from review due to trivial changes (1)
  • internal/adapters/runtime/prompt_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/adapters/ticketstore/tkmd/claims.go

Comment thread internal/adapters/ticketstore/tkmd/claims_test.go
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
internal/engine/ticket_run.go (2)

1221-1223: Magic number coupling between minimum interval and immediate renewal threshold.

The immediate renewal threshold 3*25*time.Millisecond is implicitly coupled to the minimum interval 25*time.Millisecond. If the minimum interval changes, this threshold should also be updated to maintain the safety margin.

Consider extracting these to named constants or computing the threshold from the interval:

♻️ Optional: extract constants
+const (
+	claimRenewalMinInterval = 25 * time.Millisecond
+)
+
 func (st *ticketRunState) startClaimRenewal(ctx context.Context) (context.Context, func() error) {
 	// ...
 	interval := remaining / 3
-	if interval < 25*time.Millisecond {
-		interval = 25 * time.Millisecond
+	if interval < claimRenewalMinInterval {
+		interval = claimRenewalMinInterval
 	}
 	// ...
-		if knownRemaining && remaining <= 3*25*time.Millisecond {
+		if knownRemaining && remaining <= 3*claimRenewalMinInterval {

Also applies to: 1242-1246

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/engine/ticket_run.go` around lines 1221 - 1223, Extract the magic
25*time.Millisecond into a named constant (e.g., MinInterval) and replace the
hard-coded 25*time.Millisecond usage in the interval clamping (currently in
ticket_run.go around the interval check) with MinInterval; then compute any
dependent thresholds (the immediate renewal threshold currently written as
3*25*time.Millisecond around the later check) from that constant (e.g.,
ImmediateRenewalThreshold := 3 * MinInterval) and use that computed value in the
renewal logic so the safety margin stays consistent when MinInterval changes;
update all occurrences (the clamp and the immediate-renewal check locations) to
reference these constants/derived values instead of literal numbers.

719-719: Consider preallocating slice capacity.

The slice could be initialized with a capacity hint to reduce allocations when there are multiple advisory failing checks.

♻️ Optional: preallocate capacity
-	out := make([]string, 0)
+	out := make([]string, 0, len(latest))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/engine/ticket_run.go` at line 719, The slice out is created without
capacity which causes extra allocations; change allocation to preallocate
capacity by first counting the number of advisory failing checks and then
creating out with that capacity (e.g. count failing entries in the loop that
inspects advisory checks, then replace out := make([]string, 0) with out :=
make([]string, 0, count)). Update any code that appends to out (the variable
named out) to use the preallocated slice so append operations avoid unnecessary
reallocations.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/adapters/ticketstore/tkmd/claims_test.go`:
- Around line 385-439: The test TestClaimPaths_RejectsSymlinkEscapingBase
currently only covers a symlink on the final "claims" directory; add
table-driven cases that create ancestor symlinks for the base paths (e.g.
symlink the ".tickets" directory itself to an outside path and symlink ".verk"
or ".verk/runs" to outside) so claimPaths run against those repo roots also
rejects escapes; update the tests' setup functions to create ancestor symlinks
(use t.TempDir(), os.MkdirAll, os.Symlink and t.Skipf on unsupported platforms)
and assert that claimPaths(dir, "run-a", "ticket-1") returns an error containing
"claim path escapes base directory" for these new cases, referencing the
existing TestClaimPaths_RejectsSymlinkEscapingBase and the claimPaths function
for locating where to add them.

In `@internal/adapters/ticketstore/tkmd/claims.go`:
- Around line 552-567: The current checks validate livePath/durablePath against
liveDir/durableDir but not that those base dirs themselves are anchored under
repo-owned roots; change the logic to first validate liveDir and durableDir are
under trusted repo roots (e.g., assertPathUnderBase(liveDir, repoRoot) or more
specifically the intended repo subroots like filepath.Join(repoRoot, ".tickets")
and filepath.Join(repoRoot, ".verk")) before calling os.MkdirAll, then keep the
existing assertPathUnderBase(livePath, liveDir) and
assertPathUnderBase(durablePath, durableDir) checks afterward; update the code
around the liveDir/durableDir creation (and references to runID/ticketsDir) so
the directory containment checks run prior to any MkdirAll calls.

---

Nitpick comments:
In `@internal/engine/ticket_run.go`:
- Around line 1221-1223: Extract the magic 25*time.Millisecond into a named
constant (e.g., MinInterval) and replace the hard-coded 25*time.Millisecond
usage in the interval clamping (currently in ticket_run.go around the interval
check) with MinInterval; then compute any dependent thresholds (the immediate
renewal threshold currently written as 3*25*time.Millisecond around the later
check) from that constant (e.g., ImmediateRenewalThreshold := 3 * MinInterval)
and use that computed value in the renewal logic so the safety margin stays
consistent when MinInterval changes; update all occurrences (the clamp and the
immediate-renewal check locations) to reference these constants/derived values
instead of literal numbers.
- Line 719: The slice out is created without capacity which causes extra
allocations; change allocation to preallocate capacity by first counting the
number of advisory failing checks and then creating out with that capacity (e.g.
count failing entries in the loop that inspects advisory checks, then replace
out := make([]string, 0) with out := make([]string, 0, count)). Update any code
that appends to out (the variable named out) to use the preallocated slice so
append operations avoid unnecessary reallocations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a76940ff-eccb-47cf-960c-859dfeb709fa

📥 Commits

Reviewing files that changed from the base of the PR and between ff24729 and c1835c7.

📒 Files selected for processing (4)
  • internal/adapters/ticketstore/tkmd/claims.go
  • internal/adapters/ticketstore/tkmd/claims_test.go
  • internal/engine/ticket_run.go
  • internal/engine/ticket_run_test.go
✅ Files skipped from review due to trivial changes (1)
  • internal/engine/ticket_run_test.go

Comment thread internal/adapters/ticketstore/tkmd/claims_test.go
Comment thread internal/adapters/ticketstore/tkmd/claims.go
@php-workx php-workx merged commit 4e9d0f9 into main Apr 22, 2026
11 checks passed
@php-workx php-workx deleted the feat/epic-closeouts branch April 27, 2026 13:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant