fix: refuse plain "mark it done" writes while a task's PR is still open#675
Open
bborn wants to merge 2 commits into
Open
fix: refuse plain "mark it done" writes while a task's PR is still open#675bborn wants to merge 2 commits into
bborn wants to merge 2 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 toblockedand the daemon promotes it todoneonly once the PR merges or closes (reconcileReviewTasks,internal/executor/executor.go:1291).The bypass is that
ty close,ty status <id> doneandty bulk closeare plain status writes — they skip every completion rule and write no log at all. Agents reach forty closeconstantly, and not by accident:pipeline.GateStepParkedLogadvertises it verbatim — "Approve it withty close <id>to release the next phase";skills/taskyou/SKILL.mddocumented 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_jsonand writes a"PR #N merged — task auto-completed"log; neither is present on these tasks, and their recorded state isDRAFT, whichshouldPromoteReviewTaskrejects), and the TUI (ui.loghas zero lines across the window in which four of them completed).The fix
internal/completion/guard.go—CheckDoneWriterefuses 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.ty close <gate-step>is unaffected. Pinned by a test.gh— a network call on everyty closewould add latency to the common path and fail open exactly when GitHub is slow.ty close(with a--forceescape),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 acrossevent_log,task_logsandui.logtimestamps.internal/ai/command.go— removed thecmd.Status = db.StatusDonefallback. The most destructive of the six statuses was the guess for the most ambiguous input.skills/taskyou/SKILL.md— no longer teachesty closeas 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 toblocked:ty status 4547 donerefuses identically.go test ./...is green.Notes
blockedon the live board.buildUniversalGuidance("calltaskyou_complete, REQUIRED, nothing else watches for completion") vscomposeInstruction("don't look for a tool to signal completion"). Left alone here; worth a separate issue.🤖 Generated with Claude Code