Skip to content

TASK-141 — a stored blocked no longer masks an empty blocked_by - #26

Merged
ranjiao merged 6 commits into
feat/work-modesfrom
coding/task-141-stale-blocked
Aug 20, 2026
Merged

TASK-141 — a stored blocked no longer masks an empty blocked_by#26
ranjiao merged 6 commits into
feat/work-modesfrom
coding/task-141-stale-blocked

Conversation

@ranjiao

@ranjiao ranjiao commented Aug 20, 2026

Copy link
Copy Markdown
Owner

The bug

startable read the row's own status before the dependency graph it had already computed one loop above:

bin/perry-task:4728
task["startable"] = bool(task["open"]
                         and task["status"] not in {"blocked", "review"}   # read first
                         and not task["blocked_by"])                        # never reached

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

status=blocked   blocked_by=[]   startable=False

in one object — two of the four blocked rows, one still carrying a Next action naming a chain that had fully closed. The other half: perry-task done never 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_stale is true when a row is open, stored blocked, declares at least one dependency, and every one of them has closed.

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 on the Board and in status until somebody acts — blocked_stale is 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_stale is what would let you verify it worked.

Not "delete the check"

Three cases unchanged, each tested in both directions:

  • a row with at least one open dependency keeps a non-empty blocked_by and stays unstartable — TASK-050 (on TASK-094) and TASK-067 (on TASK-094 + TASK-095) are the live instances, and both still report startable=False blocked_stale=False;
  • a blocked row 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";
  • review is 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:

  • reverting startable to 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;
  • deleting the status check outright — the wrong fix — reddens exactly the two boundary tests (test_a_blocked_row_that_declares_no_dependency_stays_blocked, test_a_row_in_review_is_untouched).

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; the baseline diff is the version string plus 87→88 documented / 99→100 emitted.

blocked_stale is derived on every read and never reaches tasks.jsonlperry_store.record() uses a fixed STORED allowlist, and a test pins that.

Out of scope, respected

conformance.blocked_by_closed_rows is TASK-142 and is not added here. git diff -- perry/ is empty; TASK-050 and TASK-067 were not touched.

🤖 Generated with Claude Code

ranjiao and others added 6 commits August 20, 2026 23:17


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>
@ranjiao
ranjiao merged commit 0f1da53 into feat/work-modes Aug 20, 2026
1 check failed
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