TASK-141 — a stored blocked no longer masks an empty blocked_by - #26
Merged
Conversation
Both pre-flights refuse and both releases were given by the user on 2026-08-20. Recorded in evidence rather than left in chat: TASK-126's hit reads as a false positive (the fragment matched a filename, not the execute stage) and TASK-140's does not (the row really does rewrite a locked design document). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ted ratio Writing the spec found the row's title understates it. The numbers are not absent, they are wrong in both directions: P-O1.1 reads 0-of-1 with all four linked tasks closed, and P-O2.2 reads met while TASK-094 measured 13 splits and 87 resolutions still live. Six of eight phase KRs have target 0.0 and an unset current defaults to 0.0, so every drive-to-zero KR reads as met on the day it is written. The pre-flight refused on state-schema.json (claim surface). Scoped out rather than escalated: the row adds payload fields, not declared shape, and the agent is told to stop and report if that turns out to be wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…live reproduction Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ullet already has Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TASK-140 caught two errors in the PMO's own sketch: 'four derived slots' asserted twice while three were listed, and Question clock assigned to no group at all. Its Calendar finding is stronger than either — inquiry and pipeline carry the same stage clock in the same column and disagree on whether the calendar binds, so a clock is not what makes a date binding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ocked_by`
`startable` read the row's own `status` before the dependency graph it had
already computed one loop above:
task["startable"] = bool(task["open"]
and task["status"] not in {"blocked", "review"}
and not task["blocked_by"])
so the third term was unreachable for exactly the rows it existed to catch. On
this project's own board TASK-037 (on the closed TASK-092) and TASK-045 (on the
closed TASK-044 → TASK-047 chain) both reported
status=blocked blocked_by=[] startable=False
in one object — two of the four blocked rows. `perry-task done` never looks at
its dependents, so the ordinary close path CREATES that state and this line
then hid it.
Option 2 of the spec: the read path stops deferring to a status the graph
contradicts, and the payload names the disagreement rather than silently
recomputing it. `tasks[].blocked_stale` is true when a row is open, stored
`blocked`, declares at least one dependency, and every one of them has closed.
`startable` no longer lets the stored value win on those rows.
The stored status is deliberately left alone. `list` reports; it does not
rewrite a cell nobody asked it to rewrite. So a stale row still READS `blocked`
until somebody acts — `blocked_stale` is what makes that visible meanwhile.
Not "delete the check". Three cases are unchanged and tested in both
directions: a row with an open dependency keeps a non-empty `blocked_by` and
stays unstartable (TASK-050 and TASK-067 are the live instances); a `blocked`
row that declares no dependency at all stays put, because its blocker is prose
Perry cannot read and "I cannot see it" is not "it closed"; and `review` is
untouched, since a row waiting on a human can never be contradicted by an edge.
Contract moves to `perry-task/list/1.12` with a `semantics` entry, because a
changed MEANING is not covered by "1.x only adds keys". The key-parity metric
is unchanged at 0 documented-not-emitted / 17 emitted-not-documented — the new
key was documented in the same edit.
No project state changes: `git diff -- perry/` is empty.
Co-Authored-By: Claude Opus 5 <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 bug
startableread the row's ownstatusbefore the dependency graph it had already computed one loop above:so the third term was unreachable for exactly the rows it existed to catch. On this project's own board TASK-037 (waiting on the closed TASK-092) and TASK-045 (on the closed TASK-044 → TASK-047 chain) both reported
in one object — two of the four blocked rows, one still carrying a
Next actionnaming a chain that had fully closed. The other half:perry-task donenever looks at its dependents, so the ordinary close path creates that state and this line then hid it.The option taken, and what the other would have cost
Option 2. The read path stops deferring to a status the graph contradicts, and the payload names the disagreement instead of silently recomputing it. New
tasks[].blocked_staleistruewhen a row is open, storedblocked, declares at least one dependency, and every one of them has closed.The stored status is deliberately left alone.
listreports; it does not rewrite a cell nobody asked it to rewrite. So a stale row still readsblockedon the Board and instatusuntil somebody acts —blocked_staleis what makes that visible meanwhile. That is the cost, stated rather than hidden.What option 1 (the close path clears its dependents) would have cost. It writes state the user did not ask for, on rows the user did not name, as a side effect of closing a different row — and to be honest about it every such write needs its own event in the log (V3 item 4; TASK-139 is open for exactly that shape), so it is a write-path change with a log-format obligation attached, not one computation. It also fixes nothing already on disk: every board that is currently stale stays stale until each row is closed again, and any store written by hand, imported, or migrated re-creates the condition. Option 2 corrects every such row on the next read, on boards that already exist. The two are complementary rather than alternatives — if option 1 ever lands,
blocked_staleis what would let you verify it worked.Not "delete the check"
Three cases unchanged, each tested in both directions:
blocked_byand stays unstartable — TASK-050 (on TASK-094) and TASK-067 (on TASK-094 + TASK-095) are the live instances, and both still reportstartable=False blocked_stale=False;blockedrow that declares no dependency at all is untouched — its blocker is prose Perry cannot read (conformance.blocked_without_dependency), and "I cannot see it" is not "it closed";reviewis untouched — a row waiting on a human can never be contradicted by a dependency edge.Verification
Every fixture is built through the store's own writer (
add,status --status blocked --on,done) against a temp root — never by hand-editing a board, which would be drift and would not exercise the close path that produces the bug.tests/test_stale_blocked.py, 11 tests. Proved by mutation in both directions:startableto the pre-TASK-141 formula reddens exactly 2 tests, both the new case (test_the_measurement_itself_no_longer_reproduces,test_closing_the_second_blocker_frees_its_dependent_too) and leaves the genuinely-blocked and boundary tests green;test_a_blocked_row_that_declares_no_dependency_stays_blocked,test_a_row_in_review_is_untouched).Contract
Moves to
perry-task/list/1.12with asemanticsentry, because a changed meaning is not covered by "1.x only adds keys". The key-parity metric is unchanged at 0 documented-not-emitted / 17 emitted-not-documented — the new key was documented in the same edit; the baseline diff is the version string plus 87→88 documented / 99→100 emitted.blocked_staleis derived on every read and never reachestasks.jsonl—perry_store.record()uses a fixedSTOREDallowlist, and a test pins that.Out of scope, respected
conformance.blocked_by_closed_rowsis TASK-142 and is not added here.git diff -- perry/is empty; TASK-050 and TASK-067 were not touched.🤖 Generated with Claude Code