Skip to content

fix: refuse plain "mark it done" writes while a task's PR is still open#675

Open
bborn wants to merge 2 commits into
mainfrom
fix/guard-done-writes
Open

fix: refuse plain "mark it done" writes while a task's PR is still open#675
bborn wants to merge 2 commits into
mainfrom
fix/guard-done-writes

Conversation

@bborn

@bborn bborn commented Jul 20, 2026

Copy link
Copy Markdown
Owner

The problem

Tasks with live, unreviewed PRs were being buried in Done, so the human who was supposed to review them never saw them again.

The design was never broken. completion.Complete() already routes a PR-bearing task to blocked and the daemon promotes it to done only once the PR merges or closes (reconcileReviewTasks, internal/executor/executor.go:1291).

The bypass is that ty close, ty status <id> done and ty bulk close are plain status writes — they skip every completion rule and write no log at all. Agents reach for ty close constantly, and not by accident:

  • it is on PATH and needs no MCP session, so it is the natural fallback when MCP is flaky;
  • pipeline.GateStepParkedLog advertises it verbatim — "Approve it with ty close <id> to release the next phase";
  • skills/taskyou/SKILL.md documented it as "Close/complete — Mark as done".

The executor prompt tells agents not to use it. Two other surfaces teach them to.

Evidence

Ruled out as the cause: the PR reconciler (it stamps pr_info_json and writes a "PR #N merged — task auto-completed" log; neither is present on these tasks, and their recorded state is DRAFT, which shouldPromoteReviewTask rejects), and the TUI (ui.log has zero lines across the window in which four of them completed).

The fix

  • internal/completion/guard.goCheckDoneWrite refuses a plain done-write when a PR exists and is still OPEN or DRAFT. MERGED/CLOSED pass (the human already decided) and no-PR passes.
    • This is what keeps workflow gate-step releases working: non-terminal steps never open a PR, only the sink step does, so ty close <gate-step> is unaffected. Pinned by a test.
    • It reads the PR state already persisted on the task rather than shelling out to gh — a network call on every ty close would add latency to the common path and fail open exactly when GitHub is slow.
  • Guarded: ty close (with a --force escape), ty status <id> done, ty bulk close.
  • RecordStatusWrite — every plain done-write now leaves an audit line naming its surface. These writes previously left no trace whatsoever, which is what turned diagnosing this into an archaeology dig across event_log, task_logs and ui.log timestamps.
  • Web/desktop board stays unguarded — it is a human surface and dragging a card to Done is a deliberate human decision — but it records the same audit line.
  • internal/ai/command.go — removed the cmd.Status = db.StatusDone fallback. The most destructive of the six statuses was the guess for the most ambiguous input.
  • skills/taskyou/SKILL.md — no longer teaches ty close as the way to complete work.

QA

CLI surface, so the evidence is the real terminal transcript. Run against a copy of the live DB (WORKTREE_DB_PATH), with 4525 (PR #1187 draft/open) and 4880 (PR #3303 merged) reset to blocked:

=== close 4525 (PR #1187 DRAFT/open) — expect REFUSE ===
Refusing to close task #4525: PR #1187 is still draft — this task is waiting on a human review, not finished
  https://github.com/bborn/influencekit/pull/1187
  Merge or close the PR to complete it automatically, or re-run with --force.
exit=1

=== close 4880 (PR #3303 MERGED) — expect ALLOW ===
Closed task #4880: My-logo uses brand logo (not avatar)
exit=0

=== close 4525 --force — expect ALLOW ===
Closed task #4525: Compare vs. Elfsite
exit=0

=== audit trail ===
4525|Status set to "done" via `ty close --force` (plain status write — completion rules not evaluated).
4880|Status set to "done" via `ty close` (plain status write — completion rules not evaluated).

ty status 4547 done refuses identically. go test ./... is green.

Notes

  • The five tasks above still need restoring to blocked on the live board.
  • Two prompt sources still give workflow steps contradictory completion contracts — buildUniversalGuidance ("call taskyou_complete, REQUIRED, nothing else watches for completion") vs composeInstruction ("don't look for a tool to signal completion"). Left alone here; worth a separate issue.

🤖 Generated with Claude Code

bborn and others added 2 commits July 20, 2026 11:01
Tasks with live, unreviewed PRs were being buried in Done. Complete() already
routes a PR-bearing task to 'blocked' so a human decides when it ships, but
`ty close`, `ty status <id> done` and `ty bulk close` are plain status writes
that skip that decision entirely — and agents reach for them constantly, since
`ty close` is on PATH, needs no MCP session, and the gate-step log line
advertises it ("Approve it with `ty close <id>` to release the next phase").

Five tasks were found done this morning whose PRs are still open today
(influencekit #760, #1084, #1187, #1195, #1283).

- Add completion.CheckDoneWrite: refuses the write when a PR exists and is
  still open or draft. Merged/closed PRs pass (the human already decided) and
  no-PR tasks pass, which keeps workflow gate-step releases working since
  non-terminal steps never open a PR.
- Guard `ty close` (with --force), `ty status <id> done`, and `ty bulk close`.
- Add completion.RecordStatusWrite so every plain write to done leaves an audit
  line naming its surface. These writes previously left no trace at all, which
  is what made diagnosing this an archaeology dig.
- Web/desktop board keeps working unguarded — it is a human surface — but now
  records the same audit line.
- internal/ai: stop defaulting an unparsed status to done. The most destructive
  of the six statuses was the guess for the most ambiguous input.
- skills/taskyou: stop documenting `ty close` as the way to mark work complete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Rename OpenPRGuard.Error() → Reason(). The type is a verdict, not an error
  value, and is never returned through the error interface; the Error() method
  name tripped golangci-lint's errname (types with Error() must be named
  XxxError). Renaming the method is truer than renaming the type.
- Close a gap in this PR's own coverage: `ty bulk status done` was a plain write
  to done that skipped the guard. It now runs CheckDoneWrite + RecordStatusWrite
  like the other done-writing paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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