From 94c01d6c42dc8e30668afbe7cd643446119559a6 Mon Sep 17 00:00:00 2001 From: agent-2nx-lane Date: Thu, 3 Sep 2026 00:53:33 -0700 Subject: [PATCH 1/2] fix: `defer`/`block` refuse a RESOLVED item instead of blanking its resolution MEASURED (bd 1.1.2, 2026-09-03, throwaway project via the sanctioned CLI): `defer` or `block` on an already-resolved item exited 0, moved it out of `resolved`, and BLANKED its stored `resolution` -- destroying the official, already-published record with no warning, no confirmation, no archive and no trace of what it used to say. The remaining verbs (`--clear` -> `claim` -> `resolve`) then rewrote that record end to end using nothing but sanctioned calls, which is why two prior lanes' "a closed resolution is unwritable through every sanctioned path" claim was false. This is strictly worse than the defect `resolve`'s divergent-text refusal closes: that one discarded the text you SEND; this discards the text already STORED. And it sat one verb away from `release()`, whose docstring goes to deliberate lengths to make exactly this transition "structurally impossible from this path". The guard lives in `_set_status_with_reason` -- the single shared implementation both verbs go through, so there is one guard that cannot drift between them -- and is checked BEFORE any write, which is what makes the refusal's own "NOTHING WAS WRITTEN" literally true. It echoes the text at risk and points at `reopen` (shipped by #67), the SAFE door to the same place: it archives the previous resolution and closed_at into an attributed comment first. The unsafe door closes; the safe one stays open. - adapter.py: `_status_change_on_resolved_error`, `_STATUS_CHANGE_VERB`, the pre-write guard, docstrings on both verbs. - contract.py: `defer.refuses_resolved` / `block.refuses_resolved`, asserted separately per verb on purpose -- a future change that gives `block` its own path cannot leave one door open while the other check keeps passing. Each asserts all four properties, including the one that actually protects a record: the stored resolution is unchanged byte for byte. - tests: integration (refusal x2, the whole loop, `reopen` still archives, and 6 "non-resolved items unaffected"), cli (exit code + record intact), modules (`success=False` on the agent-facing surface). - AGENTS.md: doctor count 34 -> 36, MEASURED from `doctor`, not computed. Refs: model_performance-2nx --- AGENTS.md | 9 +- .../tests/test_work_defer_block.py | 41 +++++ src/amplifier_work_tracker/adapter.py | 94 ++++++++++ src/amplifier_work_tracker/contract.py | 101 +++++++++++ tests/cli/test_cli_new_verbs.py | 31 ++++ tests/integration/test_defer_block.py | 162 ++++++++++++++++++ 6 files changed, 435 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7e30043..f8d998d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ Nothing above the seam should ever need to change for a Beads upgrade. ## `doctor` is the gate, not a suggestion Run `amplifier-work-tracker doctor` after any `bd` upgrade and before -trusting parallel agents against a queue. It must report **34/34 +trusting parallel agents against a queue. It must report **36/36 assumptions hold**; anything less means Beads' behavior moved out from under an assumption we depend on (or, for `sweeps.alive`, that the reap/notify sweep loops have stopped completing sweeps, or, for @@ -45,7 +45,10 @@ misreported here as 26/26 while the CLI actually emitted 27 -- 23 from is the 5th service-level check added alongside this reconciliation, for 28 total. This branch's six `reopen`/`resolve` assumptions bring the merged total to 34, which is MEASURED from `doctor` on the merged tree -- -not 33+2 arithmetic, which would have said 35.) +not 33+2 arithmetic, which would have said 35. The two +`defer`/`block`.`refuses_resolved` assumptions -- the fence on the +destructive-reopen defect, `model_performance-2nx` -- then take it to +**36**, again MEASURED from `doctor`, not computed.) ## Test scope @@ -131,7 +134,7 @@ runs itself is how you lose data you meant to keep. ## What "done" looks like -Full suite green, `doctor` 34/34, `ruff check` / `ruff format --check` / +Full suite green, `doctor` 36/36, `ruff check` / `ruff format --check` / `pyright` clean. For any change to the bundle's zero-state install path (service bootstrap, `work_tracker_install`, prereqs), the acceptance gate is a fresh Digital Twin Universe run from a genuinely empty machine (no `bd`, diff --git a/modules/tool-work-tracker/tests/test_work_defer_block.py b/modules/tool-work-tracker/tests/test_work_defer_block.py index e239d6e..25cf301 100644 --- a/modules/tool-work-tracker/tests/test_work_defer_block.py +++ b/modules/tool-work-tracker/tests/test_work_defer_block.py @@ -134,3 +134,44 @@ async def test_defer_reports_beads_errors_without_raising(project): session = WorkTrackerSession({"actor": _unique("actor")}) result = await session.defer(project, "no-such-item-id-zzz", reason="x") assert result.success is False + + +# -------------------------------------------------------------------------- +# model_performance-2nx -- the agent-facing half of the same door. +# +# `work_defer`/`work_block` are the surface an AGENT reaches for, and an +# agent reads `success`, not an exit code. Before the guard, both returned +# success=True on an already-resolved item, having blanked its published +# resolution -- so the tool told the model the operation worked while the +# official record was destroyed. +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("verb", ["defer", "block"]) +@pytest.mark.asyncio +async def test_defer_block_on_a_resolved_item_report_failure_and_keep_the_resolution( + project, verb: str +): + original = "ORIGINAL TEXT -- the official record" + actor = _unique("actor") + session = WorkTrackerSession({"actor": actor}) + added = await session.add(project, f"{verb} on resolved probe") + added_output: dict[str, Any] = added.output # type: ignore[assignment] + item_id = added_output["added"] + await session.claim(project, item_id=item_id) + resolved = await session.resolve(item_id, original) + assert resolved.success is True, resolved.output + + result = await getattr(session, verb)(project, item_id, reason="probe") + + assert result.success is False, result.output + text = str(result.output) + assert "resolved" in text + assert "reopen" in text + assert "NOTHING WAS WRITTEN" in text + + listed = await session.list_items(project, item_id=item_id) + row: dict[str, Any] = listed.output # type: ignore[assignment] + item = row["items"][0] + assert item["status"] == "resolved" + assert item["resolution"] == original diff --git a/src/amplifier_work_tracker/adapter.py b/src/amplifier_work_tracker/adapter.py index fea7192..8014794 100644 --- a/src/amplifier_work_tracker/adapter.py +++ b/src/amplifier_work_tracker/adapter.py @@ -2447,6 +2447,62 @@ def _divergent_resolution_error( ) +# The raw bd status a `_set_status_with_reason` call is asking for, mapped to +# the VERB the caller actually typed -- so a refusal reads "refusing to defer +# ", the words that are on their screen, not "refusing to deferred ". +_STATUS_CHANGE_VERB = {"deferred": "defer", "blocked": "block"} + + +def _status_change_on_resolved_error( + item_id: str, + *, + project: str, + verb: str, + stored: str | None, + closed_at: datetime | None, +) -> BeadsError: + """The refusal a caller gets for `defer`/`block` on an item that is + already RESOLVED. + + MEASURED (bd 1.1.2, work_tracker item model_performance-2nx, + 2026-09-03): `bd update --status deferred` (or `blocked`) against a + closed issue succeeds, exit 0, AND blanks `close_reason` -- so the + OFFICIAL, ALREADY-PUBLISHED resolution text is destroyed with no + warning, no archive, and no trace of what it used to say. That is + strictly worse than the defect `resolve`'s divergent-text refusal + closes: that one discarded the text you SENT; this one discards the + text already STORED. `release` has gone to deliberate lengths to make + reopening a closed item "structurally impossible from this path" -- + while these two did exactly that, destructively, one verb away. + + The message carries the same three things `_divergent_resolution_error` + earned, for the same measured reasons: + + 1. The TEXT AT RISK, echoed. A caller who cannot see what would be + destroyed cannot judge whether they meant to destroy it. + 2. The words "NOTHING WAS WRITTEN", literally -- under the contention + contract (`cli.py`'s module docstring) an agent's default reading + of a failure is "the transaction aborted", which here is true and + must not be second-guessed into a blind retry. + 3. The remedy as a RUNNABLE command on both surfaces. `reopen` is the + SAFE door to the same place: it archives the previous resolution + and `closed_at` into an attributed comment BEFORE transitioning. + This refusal closes the unsafe door without closing that one. + """ + return BeadsError( + f"refusing to {verb} {item_id}: it is already resolved, and {verb} would move it " + f"out of resolved and DESTROY the resolution stored on it. NOTHING WAS WRITTEN.\n\n" + f" status: resolved\n" + f" stored (unchanged): {_echo_resolution(stored)}\n" + f" closed_at: {closed_at.isoformat() if closed_at else '(none)'}\n\n" + f"If you genuinely mean to reopen it, use `reopen` -- it archives the resolution " + f"above (and closed_at) into an attributed comment FIRST:\n" + f" amplifier-work-tracker reopen --project {project} --id {item_id} " + f"--reason ''\n" + f" (agents: work_reopen(project={project!r}, item_id={item_id!r}, reason=...))" + ) + + @dataclass(frozen=True) class ResolveOutcome: """Result of `Beads.resolve_outcome` -- the item as it stands after a @@ -3914,6 +3970,34 @@ def _set_status_with_reason( ) -> Item: if not reason or not reason.strip(): raise BeadsError(f"{status} {item_id}: a reason is required") + # ---- model_performance-2nx: refuse BEFORE any write ---- + # REFUSE ON A RESOLVED ITEM -- checked BEFORE any write, which is what + # makes the refusal's own "NOTHING WAS WRITTEN" promise literally + # true (the same ordering `resolve` and `release` both rely on). + # + # MEASURED (model_performance-2nx): without this, `defer`/`block` on a + # closed item exited 0, moved it out of resolved, and BLANKED its + # stored resolution -- an unaudited, destructive reopen of the + # official record, one verb away from a `release` that refuses the + # same transition on purpose. + # + # Deliberately tolerant of a read failure (mirrors `resolve`'s own + # pre-write read): an item that does not exist must keep surfacing + # through bd's own `update` error below exactly as before -- this + # guard must not newly re-diagnose "not found". + try: + current: Item | None = self.get(item_id) + except BeadsError: + current = None + if current is not None and current.status == "resolved": + raise _status_change_on_resolved_error( + item_id, + project=self.project_name, + verb=_STATUS_CHANGE_VERB.get(status, status), + stored=current.resolution, + closed_at=current.closed_at, + ) + # ---- #68: verified write (read-back confirms status AND reason) ---- args = [ "update", item_id, @@ -3977,6 +4061,12 @@ def defer(self, item_id: str, reason: str, *, actor: str | None = None) -> Item: `blocked`/`deferred` -- see that method's docstring) AND via an explicit `--status deferred` read, with its reason attached. Move it back to the queue with `undefer`. + + REFUSES on an item that is already RESOLVED, writing nothing -- + see `_set_status_with_reason`'s own guard and + `_status_change_on_resolved_error`. Deferring a closed item used to + succeed and BLANK its stored resolution; `reopen` is the sanctioned + (archiving) way to bring a closed item back. """ return self._set_status_with_reason( item_id, @@ -4005,6 +4095,10 @@ def block(self, item_id: str, reason: str, *, actor: str | None = None) -> Item: status change with no other issue involved, for "this can't proceed right now" situations that are not really "issue B must close first." Move it back to the queue with `unblock`. + + REFUSES on an item that is already RESOLVED, writing nothing -- same + guard, same reason as `defer` above. `reopen` is the sanctioned + (archiving) way to bring a closed item back. """ return self._set_status_with_reason( item_id, diff --git a/src/amplifier_work_tracker/contract.py b/src/amplifier_work_tracker/contract.py index aa9791d..d02c4b6 100644 --- a/src/amplifier_work_tracker/contract.py +++ b/src/amplifier_work_tracker/contract.py @@ -1217,6 +1217,105 @@ def check_resolve_identical_text_idempotent(p: Probe) -> Result: ) +def _refuses_resolved(p: Probe, *, verb: str, call, lane: str) -> Result: + """Shared body for `defer.refuses_resolved` / `block.refuses_resolved`. + + Asserts all four properties the refusal promises, in the order that + matters -- the LAST one is the one that actually protects a record: + + 1. the call raises (it does not report success), + 2. the item is still `resolved`, + 3. its stored resolution is UNCHANGED, byte for byte, + 4. the refusal names the status and points at `reopen`. + + Property 3 is not implied by property 1: the measured defect + (model_performance-2nx) is precisely a call that CHANGES the record. + A guard that raised after writing would pass 1, 2 and 4 and still have + destroyed the resolution. + """ + assert p.bd + stored = f"probe: the published {verb} resolution" + i = p.bd.create(f"{verb}-on-resolved probe", tags=[lane]) + p.bd.resolve(i, stored) + aid = f"{verb}.refuses_resolved" + try: + call(p.bd, i) + except A.BeadsError as e: + back = p.bd.get(i) + if back.status != "resolved": + return Result( + aid, + False, + f"refused, but the item is now {back.status!r} -- the transition it " + f"refused happened anyway", + ) + if (back.resolution or "").strip() != stored: + return Result( + aid, + False, + f"refused, but the stored resolution changed anyway " + f"({back.resolution!r}) -- 'NOTHING WAS WRITTEN' is not true", + ) + msg = str(e) + if "resolved" not in msg or "reopen" not in msg: + return Result( + aid, + False, + f"refused and wrote nothing, but the message names neither the " + f"item's status nor the `reopen` remedy: {msg[:200]!r}", + ) + return Result( + aid, + True, + f"{verb} on a resolved item refuses, writes nothing, and names `reopen`", + ) + back = p.bd.get(i) + return Result( + aid, + False, + f"A {verb.upper()} ON A RESOLVED ITEM SUCCEEDED -- the item is now " + f"{back.status!r} with resolution {back.resolution!r}; the official record " + f"was rewritten with no warning and no archive", + ) + + +def check_defer_refuses_resolved(p: Probe) -> Result: + """`defer` must not be an unaudited, destructive reopen. + + MEASURED before the guard (bd 1.1.2, 2026-09-03): `defer` on a resolved + item exited 0, moved it to `deferred`, and left `resolution: None` -- + the already-published text gone, with no archive of what it said. The + remaining loop (`--clear` -> claim -> resolve) then rewrote the record + end to end using nothing but sanctioned verbs. + + This is the regression fence for that. See also + `reopen.close_reason_disposition`, which pins the bd-side behaviour + (a status change away from closed CLEARS `close_reason`) that makes + this destructive rather than merely surprising. + """ + return _refuses_resolved( + p, + verb="defer", + call=lambda bd, i: bd.defer(i, "probe: should never land"), + lane="lane:probe_defer_resolved", + ) + + +def check_block_refuses_resolved(p: Probe) -> Result: + """The same fence on `block` -- asserted separately, on purpose. + + The two verbs share one implementation today, and a check of only one + of them would pass forever if a future change gave `block` its own + path. Both doors, both asserted. + """ + return _refuses_resolved( + p, + verb="block", + call=lambda bd, i: bd.block(i, "probe: should never land"), + lane="lane:probe_block_resolved", + ) + + CHECKS = [ ("capabilities", check_capabilities), ("resolve.fenced", check_resolve_fenced), @@ -1226,6 +1325,8 @@ def check_resolve_identical_text_idempotent(p: Probe) -> Result: ("reopen.clears_closed_at", check_reopen_clears_closed_at), ("reopen.close_reason_disposition", check_reopen_close_reason_disposition), ("reopen.emits_event", check_reopen_emits_event), + ("defer.refuses_resolved", check_defer_refuses_resolved), + ("block.refuses_resolved", check_block_refuses_resolved), ("release.reopens_unresolved", check_release_reopens_unresolved), ("claim.subcommand", check_claim_subcommand), ("claim.atomic", check_claim_atomic), diff --git a/tests/cli/test_cli_new_verbs.py b/tests/cli/test_cli_new_verbs.py index 5a7771b..305c60a 100644 --- a/tests/cli/test_cli_new_verbs.py +++ b/tests/cli/test_cli_new_verbs.py @@ -124,6 +124,37 @@ def test_block_then_clear_via_cli(run_cli, cli_project): assert json.loads(cleared.stdout)["status"] == "open" +@pytest.mark.parametrize("verb", ["defer", "block"]) +def test_defer_block_on_a_resolved_item_fail_non_zero_and_keep_the_resolution( + run_cli, cli_project, verb: str +): + """model_performance-2nx, on the surface that actually shipped it. + + The exit CODE is the assertion that matters (see test_cli_reopen.py's + module docstring): before the guard this command printed a JSON payload + and exited 0 while blanking the item's already-published resolution. + """ + original = "ORIGINAL TEXT -- the official record" + add = run_cli(["add", "--project", cli_project, f"{verb} on resolved probe"]) + assert add.returncode == 0, add.stderr + item_id = json.loads(add.stdout)["added"] + closed = run_cli(["resolve", "--project", cli_project, "--id", item_id, "--reason", original]) + assert closed.returncode == 0, closed.stderr + + result = run_cli([verb, "--project", cli_project, "--id", item_id, "--reason", "probe"]) + assert result.returncode != 0, result.stdout + _util.assert_no_silent_failure(result) + combined = (result.stdout or "") + (result.stderr or "") + assert "reopen" in combined + assert "NOTHING WAS WRITTEN" in combined + + listed = run_cli(["list", "--project", cli_project, "--id", item_id, "--json"]) + assert listed.returncode == 0, listed.stderr + row = json.loads(listed.stdout)["items"][0] + assert row["status"] == "resolved" + assert row["resolution"] == original + + def test_dep_declares_and_displays_edge_via_cli(run_cli, cli_project): blocker = run_cli(["add", "--project", cli_project, "blocker item"]) assert blocker.returncode == 0, blocker.stderr diff --git a/tests/integration/test_defer_block.py b/tests/integration/test_defer_block.py index b596fca..e5b939f 100644 --- a/tests/integration/test_defer_block.py +++ b/tests/integration/test_defer_block.py @@ -6,6 +6,25 @@ status-category system exclude the item from `bd ready` (and therefore `claim_next`), while an explicit status filter still shows it, reason attached. + +THE SECOND HALF OF THIS FILE IS A REGRESSION FENCE (model_performance-2nx). +Measured against the parent commit, bd 1.1.2, 2026-09-03: `defer` (and +`block`) on an ALREADY-RESOLVED item exited 0, moved it out of resolved, and +BLANKED its stored resolution -- destroying the official, already-published +record with no warning, no confirmation, no archive and no trace of what it +used to say. The remaining verbs (`--clear` -> `claim` -> `resolve`) then +rewrote that record end to end using nothing but sanctioned calls, which is +how two prior lanes' "a closed resolution is unwritable through every +sanctioned path" claim was false. + +The tests below assert BOTH doors, deliberately in one file: + + * the UNSAFE one is closed -- defer/block refuse, and (the property that + actually protects a record) the stored resolution is unchanged; + * the SAFE one is still open -- `reopen` still succeeds on the same item + and still archives the previous resolution + closed_at FIRST. + +A guard that shut both would be a regression of its own. """ from __future__ import annotations @@ -116,3 +135,146 @@ def test_defer_and_block_reasons_never_collide(shared_bd: A.Beads, unique_lane): back = shared_bd.block(item_id, "block reason", actor="a") assert back.meta.get(A.Beads._BLOCK_REASON_KEY) == "block reason" # noqa: SLF001 assert not back.meta.get(A.Beads._DEFER_REASON_KEY) # noqa: SLF001 + + +# -------------------------------------------------------------------------- +# model_performance-2nx -- the unsafe door: defer/block on a RESOLVED item. +# -------------------------------------------------------------------------- + +ORIGINAL = "ORIGINAL TEXT -- the official, already-published resolution" + + +def _resolved(bd: A.Beads, lane: str, title: str) -> str: + """A freshly created, RESOLVED item carrying `ORIGINAL` as its official + record -- exactly the state the destructive loop starts from.""" + item_id = bd.create(title, tags=[lane], priority=1) + bd.claim_item(item_id, actor="closer") + bd.resolve(item_id, ORIGINAL, actor="closer") + assert bd.get(item_id).resolution == ORIGINAL + return item_id + + +@pytest.mark.parametrize("verb", ["defer", "block"]) +def test_defer_block_on_a_resolved_item_refuse_and_the_resolution_survives( + shared_bd: A.Beads, unique_lane, verb: str +): + """All four properties of the refusal, in one test, for BOTH verbs. + + Property 3 -- "resolution unchanged" -- is the one that actually + matters and the one no other assertion implies: the measured defect is + a call that CHANGES the record, so a guard that raised AFTER writing + would satisfy 1, 2 and 4 and still have destroyed the text. + """ + item_id = _resolved(shared_bd, unique_lane, f"2nx probe: {verb} on resolved") + + with pytest.raises(A.BeadsError) as exc: + getattr(shared_bd, verb)(item_id, "probe: should never land", actor="prober") + + # (1) it FAILED -- the call raised rather than reporting success. + message = str(exc.value) + # (2) the item is still resolved. + back = shared_bd.get(item_id) + assert back.status == "resolved", f"{verb} moved a resolved item to {back.status!r}" + # (3) THE ONE THAT MATTERS: its resolution is byte-for-byte unchanged. + assert back.resolution == ORIGINAL, ( + f"{verb} refused but the official record changed anyway ({back.resolution!r}) " + f"-- 'NOTHING WAS WRITTEN' is not true" + ) + assert back.closed_at is not None + # (4) the message names the item, its status, and the `reopen` remedy. + assert item_id in message + assert "resolved" in message + assert "reopen" in message + assert "NOTHING WAS WRITTEN" in message + # And it shows the caller the text that was at risk, so they can judge + # whether they meant to destroy it. + assert ORIGINAL in message + + +def test_the_destructive_loop_now_stops_at_its_first_verb(shared_bd: A.Beads, unique_lane): + """The item's own measurement, replayed: resolve -> defer -> block -> + `--clear` -> claim -> resolve. Before the guard every step exited 0 and + the record was rewritten. Now it stops at step one, and every later verb + finds nothing to work with because the item never left `resolved`.""" + item_id = _resolved(shared_bd, unique_lane, "2nx probe: the whole loop") + + with pytest.raises(A.BeadsError): + shared_bd.defer(item_id, "probe") + with pytest.raises(A.BeadsError): + shared_bd.block(item_id, "probe") + # `--clear` has nothing to clear: the item is resolved, not blocked. + with pytest.raises(A.BeadsError): + shared_bd.unblock(item_id) + with pytest.raises(A.BeadsError): + shared_bd.undefer(item_id) + + back = shared_bd.get(item_id) + assert back.status == "resolved" + assert back.resolution == ORIGINAL + + +def test_reopen_still_succeeds_on_the_same_item_and_still_archives_first( + shared_bd: A.Beads, unique_lane, unique_actor +): + """The SAFE door stays open. Closing the unsafe path must not close the + sanctioned one -- and `reopen`'s archive-first guarantee (the whole + reason it is the sanctioned one) must still hold on an item that + `defer`/`block` have just been refused on.""" + item_id = _resolved(shared_bd, unique_lane, "2nx probe: safe door") + with pytest.raises(A.BeadsError): + shared_bd.defer(item_id, "probe") + + outcome = shared_bd.reopen(item_id, "the stored text is wrong", actor=unique_actor) + + assert outcome.item.status != "resolved" + assert outcome.previous_resolution == ORIGINAL + assert outcome.previous_closed_at is not None + # ARCHIVED, verbatim, in the item's attributed comment history -- the + # promise that makes a reopen a correction rather than a deletion. + archived = [e.detail or e.summary for e in shared_bd.activity(item_id) if e.kind == "comment"] + assert any(ORIGINAL in (a or "") for a in archived), archived + # And the item is genuinely back in the queue: claimable, correctable. + taken = shared_bd.claim_item(item_id, actor=unique_actor) + assert taken.id == item_id + corrected = shared_bd.resolve(item_id, "CORRECTED TEXT", actor=unique_actor) + assert corrected.resolution == "CORRECTED TEXT" + + +# -------------------------------------------------------------------------- +# ... and the ordinary workflow every lane uses is UNTOUCHED. A guard that +# refused too much would break defer/block for their actual purpose, which is +# a worse outcome than the defect it fixes. +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("verb", ["defer", "block"]) +def test_defer_block_on_an_open_item_are_unaffected(shared_bd: A.Beads, unique_lane, verb: str): + item_id = shared_bd.create(f"2nx probe: open {verb}", tags=[unique_lane], priority=1) + back = getattr(shared_bd, verb)(item_id, "a perfectly ordinary reason", actor="prober") + assert back.status == ("deferred" if verb == "defer" else "blocked") + + +@pytest.mark.parametrize("verb", ["defer", "block"]) +def test_defer_block_on_a_held_item_are_unaffected( + shared_bd: A.Beads, unique_lane, unique_actor, verb: str +): + item_id = shared_bd.create(f"2nx probe: held {verb}", tags=[unique_lane], priority=1) + shared_bd.claim_item(item_id, actor=unique_actor) + assert shared_bd.get(item_id).status == "held" + + back = getattr(shared_bd, verb)(item_id, "paused mid-flight", actor=unique_actor) + assert back.status == ("deferred" if verb == "defer" else "blocked") + + +@pytest.mark.parametrize("verb", ["defer", "block"]) +def test_defer_block_on_an_already_deferred_or_blocked_item_are_unaffected( + shared_bd: A.Beads, unique_lane, verb: str +): + """Re-stating a non-resolved status, and crossing between the two + non-resolved ones, both still work exactly as they did.""" + item_id = shared_bd.create(f"2nx probe: restate {verb}", tags=[unique_lane], priority=1) + shared_bd.defer(item_id, "first", actor="prober") + assert shared_bd.get(item_id).status == "deferred" + + back = getattr(shared_bd, verb)(item_id, "second", actor="prober") + assert back.status == ("deferred" if verb == "defer" else "blocked") From e93dbf4a6dc7b47f14ff4005a7601a4fb4848323 Mon Sep 17 00:00:00 2001 From: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:55:35 -0700 Subject: [PATCH 2/2] fix(adapter): defer/block REFUSE a resolved item instead of silently blanking its resolution model_performance-2nx (CRITICAL). Measured on a throwaway project: resolve an item with 'ORIGINAL TEXT', then `defer` -> exit 0, `block` -> exit 0, and the stored resolution is GONE. The full loop block -> clear -> claim -> resolve then completes with no bd involvement at all, rewriting a closed record's official text with no archive, no warning and no trace of what it used to say. Two consequences the measurement settles: (A) The premise of model_performance-uma and -44f was WRONG. Both assert a closed item's resolution is unwritable through every sanctioned path. It was writable, through work-tracker verbs alone. 44f's own summary table lists work_defer/work_block as 'status/location only -- no' against `resolution`; that row was wrong on both counts. (B) It is WORSE than the defect uma was filed for. uma's discards the text you SEND; this discards the text already STORED -- the official record -- at exit 0. adapter.release() goes to deliberate lengths to make reopening a closed item 'structurally impossible from this path' while defer/block did exactly that, destructively, one verb away. THE FIX: defer() and block() refuse when status is resolved, writing nothing, naming the item and its status, and pointing at `reopen` (shipped by f5c, 2468a69) as the sanctioned archiving path. Mirrors release()'s existing refusal discipline. Two doctor assumptions -- defer.refuses_resolved, block.refuses_resolved -- so it cannot regress silently. TESTS, all four tiers: unit 789 passed; integration+cli 31 passed (the new test_defer_block.py + test_cli_new_verbs.py); modules 9 passed; ledger 24 passed; ruff check + format clean. tests/unit/test_supervisor_web.py is the known PORT-BINDING FLAKE, not a regression here: it fails intermittently (fail/pass/fail across three runs of the same file on this tree), its failing test's identity varies, and this branch does not touch supervisor.py at all. RECOVERED BY THE MANAGER: the lane died markerless at ~00:40 mid 'commit and push'. The work was complete and uncommitted in its worktree; this commit is that work, unchanged, with the tiers re-run to confirm before publishing. --- .../DONE-NOTE.md | 239 ++++++++++++++++++ .../evidence/doctor-AFTER.txt | 45 ++++ .../evidence/fail-before-parent-2468a69.txt | 51 ++++ .../evidence/measurement-AFTER.txt | 173 +++++++++++++ .../evidence/measurement-BEFORE.txt | 171 +++++++++++++ .../evidence/tiers/_run.log | 16 ++ .../evidence/tiers/doctor.txt | 39 +++ .../evidence/tiers/format.txt | 1 + .../evidence/tiers/lint.txt | 1 + .../evidence/tiers/tier1-unit.txt | 11 + .../evidence/tiers/types.txt | 1 + .../measure_destructive_loop.sh | 91 +++++++ .../proposed-44f-findings-correction.md | 77 ++++++ 13 files changed, 916 insertions(+) create mode 100644 docs/lanes/2nx-defer-block-refuse-resolved/DONE-NOTE.md create mode 100644 docs/lanes/2nx-defer-block-refuse-resolved/evidence/doctor-AFTER.txt create mode 100644 docs/lanes/2nx-defer-block-refuse-resolved/evidence/fail-before-parent-2468a69.txt create mode 100644 docs/lanes/2nx-defer-block-refuse-resolved/evidence/measurement-AFTER.txt create mode 100644 docs/lanes/2nx-defer-block-refuse-resolved/evidence/measurement-BEFORE.txt create mode 100644 docs/lanes/2nx-defer-block-refuse-resolved/evidence/tiers/_run.log create mode 100644 docs/lanes/2nx-defer-block-refuse-resolved/evidence/tiers/doctor.txt create mode 100644 docs/lanes/2nx-defer-block-refuse-resolved/evidence/tiers/format.txt create mode 100644 docs/lanes/2nx-defer-block-refuse-resolved/evidence/tiers/lint.txt create mode 100644 docs/lanes/2nx-defer-block-refuse-resolved/evidence/tiers/tier1-unit.txt create mode 100644 docs/lanes/2nx-defer-block-refuse-resolved/evidence/tiers/types.txt create mode 100755 docs/lanes/2nx-defer-block-refuse-resolved/measure_destructive_loop.sh create mode 100644 docs/lanes/2nx-defer-block-refuse-resolved/proposed-44f-findings-correction.md diff --git a/docs/lanes/2nx-defer-block-refuse-resolved/DONE-NOTE.md b/docs/lanes/2nx-defer-block-refuse-resolved/DONE-NOTE.md new file mode 100644 index 0000000..9b34751 --- /dev/null +++ b/docs/lanes/2nx-defer-block-refuse-resolved/DONE-NOTE.md @@ -0,0 +1,239 @@ +# DONE-NOTE — `model_performance-2nx` + +**`defer`/`block` refuse a RESOLVED item instead of silently blanking its resolution.** + +- Lane: `lane/2nx-defer-block-refuse-resolved` +- Built on: `2468a69` (`origin/main` at claim time — the commit that shipped f5c's `reopen`) +- Spend: **$0.00** (authorized $0; no API, no DTU, no infrastructure created, no ledger row) +- bd: `1.1.2 (20e493e56)` · all measurements 2026-09-03, this host + +--- + +## What was measured + +### The defect, reproduced first-hand before touching any code + +`docs/lanes/2nx-defer-block-refuse-resolved/evidence/measurement-BEFORE.txt`, produced by +`measure_destructive_loop.sh` on a throwaway project created and destroyed through the +sanctioned CLI (`new` … `remove --yes`). Verbatim, exit codes included: + +``` +$ … resolve --id --reason "ORIGINAL TEXT" EXIT=0 resolution: "ORIGINAL TEXT" +$ … defer --id --reason probe EXIT=0 {"status": "deferred"} +$ … block --id --reason probe EXIT=0 {"status": "blocked"} +$ … list --id --json EXIT=0 status blocked | resolution: null +$ … block --id --clear EXIT=0 {"status": "open"} +$ … claim --id --actor probe EXIT=0 +$ … resolve --id --reason "CORRECTED TEXT …" EXIT=0 + FINAL: status resolved | resolution = CORRECTED TEXT | closed_at moved +``` + +Every step exit 0. The already-published resolution was destroyed at the **first** verb, with +no warning, no confirmation, no archive and no trace of what it used to say. + +### The same measurement after the fix + +`…/evidence/measurement-AFTER.txt`. The loop **stops at its first verb** and every later verb +finds nothing to work with: + +``` +$ … defer --id --reason probe EXIT=1 + refusing to defer : it is already resolved, and defer would move it out of + resolved and DESTROY the resolution stored on it. NOTHING WAS WRITTEN. + status: resolved + stored (unchanged): ORIGINAL TEXT + closed_at: 2026-09-03T07:08:55+00:00 + If you genuinely mean to reopen it, use `reopen` — it archives the resolution above + (and closed_at) into an attributed comment FIRST: + amplifier-work-tracker reopen --project … --id … --reason '' + (agents: work_reopen(project=…, item_id=…, reason=…)) +$ … block --id --reason probe EXIT=1 (same refusal) +$ … list --id --json EXIT=0 status resolved | resolution: "ORIGINAL TEXT" +$ … block --id --clear EXIT=1 cannot un-blocked …: it is 'resolved', not 'blocked' +$ … claim --id --actor probe EXIT=1 issue not claimable: status closed +$ … resolve --id --reason "CORRECTED TEXT …" EXIT=1 (f5c's divergent-text refusal) + FINAL: status resolved | resolution = ORIGINAL TEXT | closed_at UNMOVED +``` + +`closed_at` is byte-identical before and after the whole attempted loop — the record was not +merely restored, it was never touched. + +--- + +## The change + +| File | What | +|---|---| +| `src/amplifier_work_tracker/adapter.py` | `_status_change_on_resolved_error()` — the refusal message; `_STATUS_CHANGE_VERB`; a pre-write guard in `_set_status_with_reason`, the one path both `defer` and `block` go through. Docstrings on both verbs. | +| `src/amplifier_work_tracker/contract.py` | `defer.refuses_resolved`, `block.refuses_resolved` (+ shared `_refuses_resolved` body), registered in `CHECKS`. | +| `AGENTS.md` | doctor count 34 → **36**, in both places, MEASURED from `doctor` output. | +| `tests/integration/test_defer_block.py` | +6 tests (4 discriminating, plus the parametrised "unaffected" set). | +| `tests/cli/test_cli_new_verbs.py` | +1 parametrised test (exit code + record intact on the surface that shipped it). | +| `modules/tool-work-tracker/tests/test_work_defer_block.py` | +1 parametrised test (`success=False` on the agent-facing surface). | + +**Placed in `_set_status_with_reason` on purpose.** It is the single shared implementation of +both verbs, so there is exactly one guard and it cannot drift between them. The doctor +assumptions are nevertheless asserted **separately per verb**, so a future change that gives +`block` its own path cannot leave one door open while the other check keeps passing. + +**Deliberately tolerant of a read failure**, mirroring `resolve`'s own pre-write read: an item +that does not exist keeps surfacing through bd's own `update` failure exactly as before. This +guard must not newly re-diagnose "not found". + +**Checked BEFORE any write** — that ordering is what makes the refusal's own +"NOTHING WAS WRITTEN" literally true, and it is the same ordering `resolve` and `release` +already depend on. + +--- + +## Deliverables + +| Deliverable | Status | +|---|---| +| defer/block refuse a resolved item (fail non-zero, stays resolved, **resolution unchanged**, message names status + `reopen`) | **DONE** — all four properties asserted in one test, both verbs, tiers 2/3/modules | +| The destructive loop is closed end to end, before/after transcripts side by side | **DONE** — `evidence/measurement-{BEFORE,AFTER}.txt`, quoted above | +| The safe path still works (`reopen` still succeeds, still archives first) — proven in the same file | **DONE** — `test_reopen_still_succeeds_on_the_same_item_and_still_archives_first`, `tests/integration/test_defer_block.py` | +| Non-resolved items unaffected | **DONE** — 6 parametrised tests (open / held / already-deferred-or-blocked × defer/block), and they PASS at the parent commit too, which is the point | +| doctor assumptions `defer.refuses_resolved` / `block.refuses_resolved` against the live bd binary | **DONE** — both PASS; `doctor` now reports **36/36** | +| fail-before evidence | **DONE** — `evidence/fail-before-parent-2468a69.txt` | +| The false immutability claim corrected where it was made | **PARTIAL, with reasons** — see below | +| Draft PR, all four tiers + modules suite run and reported by name | **DONE** — see the PR body | +| This DONE-NOTE at the lane artifact root | **DONE** | + +--- + +## Fail-before evidence + +`evidence/fail-before-parent-2468a69.txt`. The three test files are the lane's new ones copied +verbatim onto a `git worktree` of parent `2468a69`; **only `src/` is the parent's**, pinned with +`PYTHONPATH=/src` and verified in the capture itself — +`import amplifier_work_tracker` resolves to `/tmp/2nx-parent/src/...`, not the lane worktree. +Without that pin the editable install silently resolves the FIXED source and everything passes. + +``` +tier 2 (integration) 4 failed, 17 passed ← the 4 discriminating tests +tier 3 (cli) 2 failed, 8 passed +modules 2 failed, 7 passed +doctor assumptions [FAIL] defer.refuses_resolved A DEFER ON A RESOLVED ITEM SUCCEEDED -- + the item is now 'deferred' with resolution None; the official + record was rewritten with no warning and no archive + [FAIL] block.refuses_resolved (same) +``` + +8 new tests fail at the parent; the 6 "unaffected" tests **pass** at the parent, as designed — +they assert that ordinary defer/block behaviour is unchanged, so a failure there would mean the +guard refuses too much. + +For the two doctor assumptions the pin is inverted in the way that is correct for an assumption +file: `contract.py` (the *test*) is the lane's, `adapter.py` (the *code under test*) is the +parent's, unmodified. Both facts are stated in the capture. + +--- + +## Test tiers, by name + +Run in the lane worktree, venv `python3.12`, real `bd` 1.1.2 + isolated dolt server. + +| Tier | Command | Result | +|---|---|---| +| 1 unit | `make test-unit` (`pytest tests/unit`) | **790 passed** | +| 2 integration | `make test-integration` (`pytest -m integration tests/integration`) | **333 passed, 3 skipped** (14:44) | +| 3 cli | `make test-cli` (`pytest -m cli tests/cli`) | **82 passed, 1 failed** — the failure is `test_doctor_quick_succeeds_against_the_real_installed_bd`, PRE-EXISTING (`model_performance-jyg`) | +| 4 ledger | `make test-ledger` (`pytest ledger/checks`) | **24 passed** | +| modules | `pytest modules` (NOT in `testpaths`) | **114 passed, 1 failed** — `test_reap_recovery.py::test_explicit_resolve_refusal_after_reap_clears_held_and_allows_new_claim`, PRE-EXISTING (`model_performance-c0e`) | +| lint | `ruff check .` / `ruff format --check .` | clean / 143 files already formatted | +| types | `pyright src tests` | 0 errors, 0 warnings | +| doctor | `python -m amplifier_work_tracker.cli doctor` | **All 36 assumptions hold** | + +**Both failures were verified pre-existing, not asserted.** `test_doctor_quick_…` was re-run on a +fresh worktree of parent `2468a69` with the parent's `src/` pinned and **fails there identically** +(its cause on this host is `sweeps.alive` — the test's isolated root sees no sweep heartbeat). +`test_explicit_resolve_refusal_after_reap_…` is `model_performance-c0e`, named in the item's own +KNOWN block. The third named pre-existing flake (`tests/unit/test_supervisor_web.py`, port +binding) did not fire in this run — 790/790 unit passed. + +The modules tier needs `amplifier-core` + `pytest-asyncio`, which `.[dev]` does not install, and +`PYTHONPATH` pointing at `modules/tool-work-tracker` — without either it fails at COLLECTION and +looks like a real breakage. Both were installed/set for the runs above. + +--- + +## The false immutability claim — what was corrected, and what was not + +The claim, as made: a closed item's `resolution` is *"unwritable through every sanctioned path"* +(`model_performance-uma` and `model_performance-44f`), with 44f's FINDINGS §1.7 summary table +listing `work_defer` / `work_block` as **"status/location only — no"** against `resolution`. +That row is wrong on both counts, and this lane re-measured it from scratch rather than taking +2nx's word for it (`evidence/measurement-BEFORE.txt`). + +**`model_performance-uma` — already corrected, by uma's own lane, before this lane started.** +Verified by reading the live record: its `resolution` §(0) and its `design` ADDENDUM 2 both state +the premise is wrong and name the `block → clear → claim → resolve` path explicitly. Nothing to +correct. One statement in it *becomes* stale when this PR merges — "every one of these 7 is +correctable TODAY — destructively" — and an addendum naming the PR is appended to its `design` +(see below). + +**`model_performance-44f` — corrected, at 2026-09-03T07:56Z**, with a `design` addendum stating +that the §1.7 row is wrong, what was measured (with a pointer to `evidence/measurement-BEFORE.txt`), +and that the door is now closed. Written through the sanctioned `edit` verb on the **installed** +CLI (`--actor agent-2nx-lane`, so the edit is attributed), never this worktree's build, and +**verified by reading the record back** — the addendum is the first thing in 44f's `design`, the +prior text preserved verbatim beneath a `--- design as it stood before this addendum ---` rule. + +No title flag was added: 44f already carries `[RESOLUTION INCOMPLETE … read design]`, which +already sends a reader to the field this correction is in. A second flag would deface the title +without adding a signal. + +> **Correction to an earlier draft of this note.** A previous version of this section claimed the +> 44f edit had already landed. It had not: this lane's first session died before issuing it, and +> the live record at 07:52Z still had `updated_at` 01:20:58Z with no 2nx addendum anywhere in +> `design`. The claim was a self-report, not a readback — the exact failure mode the item's own +> publication contract warns about — so it is recorded here rather than quietly fixed. + +**Its `resolution` text was NOT rewritten. Precisely why:** + +1. `work_reopen` **is not registered in this session's tool set** — the installed tool module + predates f5c's merge (`2468a69`, minutes old). The verb exists in the source I am editing; it + is not yet in the runtime I am running under. +2. A session holds **one** item. `work_claim` on 44f would first cost custody of `2nx`, which + this lane holds. +3. The destructive path that *would* work is the one this lane exists to close, and the item's + own SCOPE-OUTS forbid using it against a real project. + +So the correction went into the one append channel available on a closed item, which is exactly +what 44f's own `RESOLUTION-CORRECTION.md` prescribes for a lane that is not permitted to reopen. + +**44f's `ai-notes` FINDINGS.md §1.7 and RESOLUTION-CORRECTION.md were NOT edited.** They live in +`/home/bkrabach/dev/openai-evals-team-ci/ai-notes/` — a **different repo**, and another lane's +directory. This lane's Procedure step 4 says *"Never touch other repos"* and the program's own +lane rule 2 says *"Write only in your own directory… propose corrections as a diff."* Both point +the same way. The exact correction is therefore prepared as a ready-to-apply patch in this lane's +artifact root: + + `docs/lanes/2nx-defer-block-refuse-resolved/proposed-44f-findings-correction.md` + +It is one paste for whoever owns that repo, not an investigation. + +--- + +## Deviations and choices + +- **Guard placed in the shared helper, not duplicated per verb.** One implementation, two + independently-asserted doctor assumptions. Recorded here because the alternative (a copy in + each of `defer` and `block`) is the shape that drifts. +- **No third doctor assumption pinning bd's own "a status change away from closed clears + `close_reason`" behaviour.** The item names exactly two; and that bd-side fact is already + pinned by `reopen.close_reason_disposition`. Cross-referenced from the new checks' docstrings + rather than re-asserted. +- **Observed, not fixed:** the un-defer/un-block refusal reads + `cannot un-blocked : it is 'resolved', not 'blocked'` — grammatically wrong + (`un-{raw_status}` instead of `unblock`). Pre-existing, cosmetic, in `_clear_status_with_reason`, + and untouched by this change. Not filed: it costs an owner more attention to triage than it + costs a reader to parse. +- **No infrastructure created**, so nothing was registered in the infra ledger and + `lane_teardown.sh` had nothing to claim or tear down. `sweep` was never run. +- Two throwaway projects were created and destroyed through the sanctioned CLI + (`p2nxbefore*`, `p2nxafter*`). `scripts/sweep_test_residue.py` reports no residue from this + lane. One earlier aborted probe run leaked a database (my script's cleanup ran before + releasing a held item); it was dropped via `adapter.drop_database` and the script was fixed to + `unclaim` first — recorded because a leak that is fixed quietly is a leak that recurs. diff --git a/docs/lanes/2nx-defer-block-refuse-resolved/evidence/doctor-AFTER.txt b/docs/lanes/2nx-defer-block-refuse-resolved/evidence/doctor-AFTER.txt new file mode 100644 index 0000000..ec3fe88 --- /dev/null +++ b/docs/lanes/2nx-defer-block-refuse-resolved/evidence/doctor-AFTER.txt @@ -0,0 +1,45 @@ +=== doctor, lane worktree, after the fix === +date : 2026-09-03T07:40:05Z +bd : bd version 1.1.2 (20e493e56: HEAD@20e493e569c9) + +project 'contract1788421205412': bd init hit a dirty schema migration -- dropping and retrying once: [mysql] 2026/09/03 00:40:13 connection.go:214 busy buffer +Error: failed to open Dolt store: failed to initialize schema: schema migration: pending schema migrations alter pre-existing dirty tables: comments, compaction_snapshots, dependencies, events, issue_snapshots, labels; run 'bd dolt commit' to + [PASS] version bd 1.1.2 + [PASS] capabilities all required bd commands present + [PASS] resolve.fenced stale holder refused, as required + [PASS] resolve.divergent_text_refused resolving a closed item with different text refuses and writes nothing + [PASS] resolve.identical_text_idempotent re-sending identical resolution text is an idempotent success + [PASS] reopen.reopens a resolved item reopens unassigned and is directly claimable again + [PASS] reopen.clears_closed_at reopen clears closed_at (the documented, surfaced accounting cost) + [PASS] reopen.close_reason_disposition reopen clears close_reason (measured), and the wrapper's archive comment preserves the previous resolution regardless + [PASS] reopen.emits_event bd records a `reopened` events row, attributed + [PASS] defer.refuses_resolved defer on a resolved item refuses, writes nothing, and names `reopen` + [PASS] block.refuses_resolved block on a resolved item refuses, writes nothing, and names `reopen` + [PASS] release.reopens_unresolved release reopens a held item with no resolution, and it is re-claimable + [PASS] claim.subcommand --claim present, rejects --assignee as expected + [PASS] claim.atomic 5 trials x 12 concurrent claimers, no double-claims + [PASS] claim.directed_atomic 5 trials x 12 concurrent directed claimers on the SAME item, exactly one winner each time + [PASS] link.nonblocking discovered-from is non-blocking + [PASS] list.includes_closed all-flag required and working + [PASS] list.status_filter_includes_closed an explicit --status filter shows closed items without --all + [PASS] show.dependents reverse link visible (1 links) + [PASS] read.no_mutation repeated reads (including not-found/wrong-project misses) leave status, holder, and metadata unchanged + [PASS] resolution.readable resolution text round-trips + [PASS] timestamps.readable created_at/updated_at/closed_at all round-trip as real datetimes + [PASS] metadata.roundtrip arbitrary JSON metadata round-trips + [PASS] project.name_rules dotted names appear usable now; validator may be relaxed + [PASS] custody.fresh_survives a fresh renewal survives regardless of total hold duration + [PASS] custody.stale_reclaimed stale custody is reclaimed: custody stale -- last seen 3600s ago (ttl 900s) + [PASS] custody.idle_not_exempt awaiting_human with stale custody is still reclaimed: custody stale -- last seen 3600s ago (ttl 900s) + [PASS] custody.fenced old holder's renew and resolve are both refused after takeover + [PASS] project.removal remove() drops both the directory and database; re-create afterward is genuinely empty + [PASS] project.create_atomic an abandoned creation lock (dead pid) is healed automatically and create() completes fresh in the same call; path=/tmp/awtcontract_9yb6ne6l/projects/contract1788421205412atomic + [PASS] project.creation_state_reporting creation_state distinguishes none/creating/abandoned correctly + [PASS] service.installed installed and active (unit: /home/bkrabach/.config/systemd/user/amplifier-work-tracker.service) + [PASS] systemd.user_bus_reachable systemctl --user show-environment succeeded + [PASS] dolt.reachable dolt sql-server responds on 127.0.0.1:3308 + [PASS] sweeps.alive reap sweep completed 50s ago (threshold 900s); notify sweep completed 235s ago (threshold 900s) + [PASS] service.restart_policy installed unit has Restart=always -- survives a clean/unintended exit + +All 36 assumptions hold. Safe to run parallel agents. +DOCTOR_EXIT=0 diff --git a/docs/lanes/2nx-defer-block-refuse-resolved/evidence/fail-before-parent-2468a69.txt b/docs/lanes/2nx-defer-block-refuse-resolved/evidence/fail-before-parent-2468a69.txt new file mode 100644 index 0000000..761d300 --- /dev/null +++ b/docs/lanes/2nx-defer-block-refuse-resolved/evidence/fail-before-parent-2468a69.txt @@ -0,0 +1,51 @@ +=== FAIL-BEFORE: the model_performance-2nx tests, run against the PARENT commit === +parent commit : 2468a6946ee7e04e82ad9d563af86d48a5d66355 (feat: `reopen` verb + `work_reopen` tool; `resolve` on a closed item fails loud (#67)) +date : 2026-09-03T07:05:58Z +bd : bd version 1.1.2 (20e493e56: HEAD@20e493e569c9) +pinned source : /tmp/2nx-parent/src/amplifier_work_tracker/__init__.py + (the lane worktree's editable install would otherwise resolve + amplifier_work_tracker to the FIXED source and pass falsely) + +The three test files under test are the lane's NEW ones, copied verbatim onto +the parent tree; only src/ is the parent's. + +--- tier 2 (integration): tests/integration/test_defer_block.py --- +> with pytest.raises(A.BeadsError): + ^^^^^^^^^^^^^^^^^^^^^^^^^^^ +E Failed: DID NOT RAISE BeadsError + +tests/integration/test_defer_block.py:224: Failed +----------------------------- Captured stderr call ----------------------------- +=========================== short test summary info ============================ +FAILED tests/integration/test_defer_block.py::test_defer_block_on_a_resolved_item_refuse_and_the_resolution_survives[defer] +FAILED tests/integration/test_defer_block.py::test_defer_block_on_a_resolved_item_refuse_and_the_resolution_survives[block] +FAILED tests/integration/test_defer_block.py::test_the_destructive_loop_now_stops_at_its_first_verb +FAILED tests/integration/test_defer_block.py::test_reopen_still_succeeds_on_the_same_item_and_still_archives_first +4 failed, 17 passed in 25.42s + +--- tier 3 (cli): tests/cli/test_cli_new_verbs.py --- +----------------------------- Captured stdout call ----------------------------- + +----------------------------- Captured stderr call ----------------------------- +--------------------------- Captured stderr teardown --------------------------- +=========================== short test summary info ============================ +FAILED tests/cli/test_cli_new_verbs.py::test_defer_block_on_a_resolved_item_fail_non_zero_and_keep_the_resolution[defer] +FAILED tests/cli/test_cli_new_verbs.py::test_defer_block_on_a_resolved_item_fail_non_zero_and_keep_the_resolution[block] +2 failed, 8 passed in 45.49s + +--- modules tier: modules/tool-work-tracker/tests/test_work_defer_block.py --- +----------------------------- Captured stdout call ----------------------------- + +----------------------------- Captured stderr call ----------------------------- +--------------------------- Captured stderr teardown --------------------------- +=========================== short test summary info ============================ +FAILED modules/tool-work-tracker/tests/test_work_defer_block.py::test_defer_block_on_a_resolved_item_report_failure_and_keep_the_resolution[defer] +FAILED modules/tool-work-tracker/tests/test_work_defer_block.py::test_defer_block_on_a_resolved_item_report_failure_and_keep_the_resolution[block] +2 failed, 7 passed in 36.88s + +--- doctor assumptions: the two NEW checks against the PARENT adapter --- + (contract.py -- the assumption file, i.e. the test -- is the lane's; + adapter.py -- the code under test -- is the parent's, unmodified) + adapter under test: /tmp/2nx-parent/src/amplifier_work_tracker/adapter.py + [FAIL] defer.refuses_resolved A DEFER ON A RESOLVED ITEM SUCCEEDED -- the item is now 'deferred' with resolution None; the official record was rewritten with no warning and no archive + [FAIL] block.refuses_resolved A BLOCK ON A RESOLVED ITEM SUCCEEDED -- the item is now 'blocked' with resolution None; the official record was rewritten with no warning and no archive diff --git a/docs/lanes/2nx-defer-block-refuse-resolved/evidence/measurement-AFTER.txt b/docs/lanes/2nx-defer-block-refuse-resolved/evidence/measurement-AFTER.txt new file mode 100644 index 0000000..225b98a --- /dev/null +++ b/docs/lanes/2nx-defer-block-refuse-resolved/evidence/measurement-AFTER.txt @@ -0,0 +1,173 @@ +=== 2nx destructive-loop measurement: after === +date : 2026-09-03T07:08:38Z +python : /home/bkrabach/dev/hw-model-performance/lanes/2nx-defer-block-refuse-resolved/amplifier-work-tracker/.venv/bin/python +source tree : /home/bkrabach/dev/hw-model-performance/lanes/2nx-defer-block-refuse-resolved/amplifier-work-tracker/src/amplifier_work_tracker/__init__.py +bd version : bd version 1.1.2 (20e493e56: HEAD@20e493e569c9) +project : p2nxafter764599 (throwaway) +workspace : /tmp/awt2nx.after.Bmv0nq + +$ amplifier-work-tracker new p2nxafter764599 +created project 'p2nxafter764599' at /tmp/awt2nx.after.Bmv0nq/projects/p2nxafter764599 (verified writable) +EXIT=0 + +$ amplifier-work-tracker add --project p2nxafter764599 '2nx destructive-loop probe' -> id=p2nxafter764599-jd0 + +$ amplifier-work-tracker claim --project p2nxafter764599 --id p2nxafter764599-jd0 --actor probe +{ + "claimed": "p2nxafter764599-jd0", + "title": "2nx destructive-loop probe", + "holder": "probe", + "acceptance": null, + "description": null, + "design": null, + "next_step": "run `amplifier-work-tracker custody --project p2nxafter764599 --actor probe --id p2nxafter764599-jd0` in the background to establish and maintain custody while you work", + "custody_renew_every_seconds": 120 +} +EXIT=0 + +$ amplifier-work-tracker resolve --project p2nxafter764599 --id p2nxafter764599-jd0 --reason ORIGINAL TEXT --actor probe +{ + "resolved": "p2nxafter764599-jd0", + "resolution": "ORIGINAL TEXT" +} +EXIT=0 + +--- readback after resolve (the official record) --- + +$ amplifier-work-tracker list --project p2nxafter764599 --id p2nxafter764599-jd0 --json +{ + "project": "p2nxafter764599", + "items": [ + { + "id": "p2nxafter764599-jd0", + "title": "2nx destructive-loop probe", + "status": "resolved", + "holder": "probe", + "resolution": "ORIGINAL TEXT", + "acceptance": null, + "description": null, + "design": null, + "repos": [], + "context": [], + "created_at": "2026-09-03T07:08:53+00:00", + "updated_at": "2026-09-03T07:08:55+00:00", + "closed_at": "2026-09-03T07:08:55+00:00", + "created_by": "Amplifier" + } + ], + "returned_count": 1, + "total_count": 1, + "truncated": false, + "limit": 1 +} +EXIT=0 + +--- THE DESTRUCTIVE LOOP --- + +$ amplifier-work-tracker defer --project p2nxafter764599 --id p2nxafter764599-jd0 --reason probe --actor probe +amplifier-work-tracker: refusing to defer p2nxafter764599-jd0: it is already resolved, and defer would move it out of resolved and DESTROY the resolution stored on it. NOTHING WAS WRITTEN. + + status: resolved + stored (unchanged): ORIGINAL TEXT + closed_at: 2026-09-03T07:08:55+00:00 + +If you genuinely mean to reopen it, use `reopen` -- it archives the resolution above (and closed_at) into an attributed comment FIRST: + amplifier-work-tracker reopen --project p2nxafter764599 --id p2nxafter764599-jd0 --reason '' + (agents: work_reopen(project='p2nxafter764599', item_id='p2nxafter764599-jd0', reason=...)) +EXIT=1 + +$ amplifier-work-tracker block --project p2nxafter764599 --id p2nxafter764599-jd0 --reason probe --actor probe +amplifier-work-tracker: refusing to block p2nxafter764599-jd0: it is already resolved, and block would move it out of resolved and DESTROY the resolution stored on it. NOTHING WAS WRITTEN. + + status: resolved + stored (unchanged): ORIGINAL TEXT + closed_at: 2026-09-03T07:08:55+00:00 + +If you genuinely mean to reopen it, use `reopen` -- it archives the resolution above (and closed_at) into an attributed comment FIRST: + amplifier-work-tracker reopen --project p2nxafter764599 --id p2nxafter764599-jd0 --reason '' + (agents: work_reopen(project='p2nxafter764599', item_id='p2nxafter764599-jd0', reason=...)) +EXIT=1 + +--- readback after defer+block: is ORIGINAL TEXT still stored? --- + +$ amplifier-work-tracker list --project p2nxafter764599 --id p2nxafter764599-jd0 --json +{ + "project": "p2nxafter764599", + "items": [ + { + "id": "p2nxafter764599-jd0", + "title": "2nx destructive-loop probe", + "status": "resolved", + "holder": "probe", + "resolution": "ORIGINAL TEXT", + "acceptance": null, + "description": null, + "design": null, + "repos": [], + "context": [], + "created_at": "2026-09-03T07:08:53+00:00", + "updated_at": "2026-09-03T07:08:55+00:00", + "closed_at": "2026-09-03T07:08:55+00:00", + "created_by": "Amplifier" + } + ], + "returned_count": 1, + "total_count": 1, + "truncated": false, + "limit": 1 +} +EXIT=0 + +$ amplifier-work-tracker block --project p2nxafter764599 --id p2nxafter764599-jd0 --clear --actor probe +amplifier-work-tracker: cannot un-blocked p2nxafter764599-jd0: it is 'resolved', not 'blocked' +EXIT=1 + +$ amplifier-work-tracker claim --project p2nxafter764599 --id p2nxafter764599-jd0 --actor probe +amplifier-work-tracker: claim p2nxafter764599-jd0 as 'probe' failed: Error claiming p2nxafter764599-jd0: issue not claimable: status closed +EXIT=1 + +$ amplifier-work-tracker resolve --project p2nxafter764599 --id p2nxafter764599-jd0 --reason CORRECTED TEXT -- written after the item had already been closed once --actor probe +amplifier-work-tracker: refusing to resolve p2nxafter764599-jd0: it is already resolved, and the resolution stored on the item is NOT the text you sent. NOTHING WAS WRITTEN. + + stored (unchanged): ORIGINAL TEXT + you sent: CORRECTED TEXT -- written after the item had already been closed once + +To correct the official record, reopen it first: + amplifier-work-tracker reopen --project p2nxafter764599 --id p2nxafter764599-jd0 --reason '' + (agents: work_reopen(project='p2nxafter764599', item_id='p2nxafter764599-jd0', reason=...)) +EXIT=1 + +--- FINAL readback --- + +$ amplifier-work-tracker list --project p2nxafter764599 --id p2nxafter764599-jd0 --json +{ + "project": "p2nxafter764599", + "items": [ + { + "id": "p2nxafter764599-jd0", + "title": "2nx destructive-loop probe", + "status": "resolved", + "holder": "probe", + "resolution": "ORIGINAL TEXT", + "acceptance": null, + "description": null, + "design": null, + "repos": [], + "context": [], + "created_at": "2026-09-03T07:08:53+00:00", + "updated_at": "2026-09-03T07:08:55+00:00", + "closed_at": "2026-09-03T07:08:55+00:00", + "created_by": "Amplifier" + } + ], + "returned_count": 1, + "total_count": 1, + "truncated": false, + "limit": 1 +} +EXIT=0 + +=== end: after === + +--- cleanup (throwaway project destroyed via the sanctioned CLI) --- +removed project p2nxafter764599 (directory + shared-server database) diff --git a/docs/lanes/2nx-defer-block-refuse-resolved/evidence/measurement-BEFORE.txt b/docs/lanes/2nx-defer-block-refuse-resolved/evidence/measurement-BEFORE.txt new file mode 100644 index 0000000..8767590 --- /dev/null +++ b/docs/lanes/2nx-defer-block-refuse-resolved/evidence/measurement-BEFORE.txt @@ -0,0 +1,171 @@ +=== 2nx destructive-loop measurement: before === +date : 2026-09-03T06:55:33Z +python : /home/bkrabach/dev/hw-model-performance/lanes/2nx-defer-block-refuse-resolved/amplifier-work-tracker/.venv/bin/python +source tree : /home/bkrabach/dev/hw-model-performance/lanes/2nx-defer-block-refuse-resolved/amplifier-work-tracker/src/amplifier_work_tracker/__init__.py +bd version : bd version 1.1.2 (20e493e56: HEAD@20e493e569c9) +project : p2nxbefore394644 (throwaway) +workspace : /tmp/awt2nx.before.JbiR9A + +$ amplifier-work-tracker new p2nxbefore394644 +created project 'p2nxbefore394644' at /tmp/awt2nx.before.JbiR9A/projects/p2nxbefore394644 (verified writable) +EXIT=0 + +$ amplifier-work-tracker add --project p2nxbefore394644 '2nx destructive-loop probe' -> id=p2nxbefore394644-hh8 + +$ amplifier-work-tracker claim --project p2nxbefore394644 --id p2nxbefore394644-hh8 --actor probe +{ + "claimed": "p2nxbefore394644-hh8", + "title": "2nx destructive-loop probe", + "holder": "probe", + "acceptance": null, + "description": null, + "design": null, + "next_step": "run `amplifier-work-tracker custody --project p2nxbefore394644 --actor probe --id p2nxbefore394644-hh8` in the background to establish and maintain custody while you work", + "custody_renew_every_seconds": 120 +} +EXIT=0 + +$ amplifier-work-tracker resolve --project p2nxbefore394644 --id p2nxbefore394644-hh8 --reason ORIGINAL TEXT --actor probe +{ + "resolved": "p2nxbefore394644-hh8", + "resolution": "ORIGINAL TEXT" +} +EXIT=0 + +--- readback after resolve (the official record) --- + +$ amplifier-work-tracker list --project p2nxbefore394644 --id p2nxbefore394644-hh8 --json +{ + "project": "p2nxbefore394644", + "items": [ + { + "id": "p2nxbefore394644-hh8", + "title": "2nx destructive-loop probe", + "status": "resolved", + "holder": "probe", + "resolution": "ORIGINAL TEXT", + "acceptance": null, + "description": null, + "design": null, + "repos": [], + "context": [], + "created_at": "2026-09-03T06:55:47+00:00", + "updated_at": "2026-09-03T06:55:49+00:00", + "closed_at": "2026-09-03T06:55:49+00:00", + "created_by": "Amplifier" + } + ], + "returned_count": 1, + "total_count": 1, + "truncated": false, + "limit": 1 +} +EXIT=0 + +--- THE DESTRUCTIVE LOOP --- + +$ amplifier-work-tracker defer --project p2nxbefore394644 --id p2nxbefore394644-hh8 --reason probe --actor probe +{ + "id": "p2nxbefore394644-hh8", + "status": "deferred" +} +EXIT=0 + +$ amplifier-work-tracker block --project p2nxbefore394644 --id p2nxbefore394644-hh8 --reason probe --actor probe +{ + "id": "p2nxbefore394644-hh8", + "status": "blocked" +} +EXIT=0 + +--- readback after defer+block: is ORIGINAL TEXT still stored? --- + +$ amplifier-work-tracker list --project p2nxbefore394644 --id p2nxbefore394644-hh8 --json +{ + "project": "p2nxbefore394644", + "items": [ + { + "id": "p2nxbefore394644-hh8", + "title": "2nx destructive-loop probe", + "status": "blocked", + "holder": "probe", + "resolution": null, + "acceptance": null, + "description": null, + "design": null, + "repos": [], + "context": [], + "created_at": "2026-09-03T06:55:47+00:00", + "updated_at": "2026-09-03T06:55:52+00:00", + "closed_at": null, + "created_by": "Amplifier" + } + ], + "returned_count": 1, + "total_count": 1, + "truncated": false, + "limit": 1 +} +EXIT=0 + +$ amplifier-work-tracker block --project p2nxbefore394644 --id p2nxbefore394644-hh8 --clear --actor probe +{ + "id": "p2nxbefore394644-hh8", + "status": "open" +} +EXIT=0 + +$ amplifier-work-tracker claim --project p2nxbefore394644 --id p2nxbefore394644-hh8 --actor probe +{ + "claimed": "p2nxbefore394644-hh8", + "title": "2nx destructive-loop probe", + "holder": "probe", + "acceptance": null, + "description": null, + "design": null, + "next_step": "run `amplifier-work-tracker custody --project p2nxbefore394644 --actor probe --id p2nxbefore394644-hh8` in the background to establish and maintain custody while you work", + "custody_renew_every_seconds": 120 +} +EXIT=0 + +$ amplifier-work-tracker resolve --project p2nxbefore394644 --id p2nxbefore394644-hh8 --reason CORRECTED TEXT -- written after the item had already been closed once --actor probe +{ + "resolved": "p2nxbefore394644-hh8", + "resolution": "CORRECTED TEXT -- written after the item had already been closed once" +} +EXIT=0 + +--- FINAL readback --- + +$ amplifier-work-tracker list --project p2nxbefore394644 --id p2nxbefore394644-hh8 --json +{ + "project": "p2nxbefore394644", + "items": [ + { + "id": "p2nxbefore394644-hh8", + "title": "2nx destructive-loop probe", + "status": "resolved", + "holder": "probe", + "resolution": "CORRECTED TEXT -- written after the item had already been closed once", + "acceptance": null, + "description": null, + "design": null, + "repos": [], + "context": [], + "created_at": "2026-09-03T06:55:47+00:00", + "updated_at": "2026-09-03T06:55:55+00:00", + "closed_at": "2026-09-03T06:55:55+00:00", + "created_by": "Amplifier" + } + ], + "returned_count": 1, + "total_count": 1, + "truncated": false, + "limit": 1 +} +EXIT=0 + +=== end: before === + +--- cleanup (throwaway project destroyed via the sanctioned CLI) --- +removed project p2nxbefore394644 (directory + shared-server database) diff --git a/docs/lanes/2nx-defer-block-refuse-resolved/evidence/tiers/_run.log b/docs/lanes/2nx-defer-block-refuse-resolved/evidence/tiers/_run.log new file mode 100644 index 0000000..9ce03fc --- /dev/null +++ b/docs/lanes/2nx-defer-block-refuse-resolved/evidence/tiers/_run.log @@ -0,0 +1,16 @@ +RUN STARTED 2026-09-03T07:51:32Z +=== lint START 07:51:32Z === +=== lint EXIT=0 07:51:32Z === +All checks passed! +=== format START 07:51:32Z === +=== format EXIT=0 07:51:32Z === +145 files already formatted +=== types START 07:51:32Z === +=== types EXIT=0 07:51:38Z === +0 errors, 0 warnings, 0 informations +=== doctor START 07:51:38Z === +=== doctor EXIT=0 07:54:55Z === + [PASS] service.restart_policy installed unit has Restart=always -- survives a clean/unintended exit + +All 36 assumptions hold. Safe to run parallel agents. +=== tier1-unit START 07:54:55Z === diff --git a/docs/lanes/2nx-defer-block-refuse-resolved/evidence/tiers/doctor.txt b/docs/lanes/2nx-defer-block-refuse-resolved/evidence/tiers/doctor.txt new file mode 100644 index 0000000..6855d21 --- /dev/null +++ b/docs/lanes/2nx-defer-block-refuse-resolved/evidence/tiers/doctor.txt @@ -0,0 +1,39 @@ +project 'contract178842189862atomic': healing an abandoned creation attempt (lock /tmp/awtcontract_61m0iw38/projects/contract178842189862atomic/.create.lock named a dead pid) before retrying + [PASS] version bd 1.1.2 + [PASS] capabilities all required bd commands present + [PASS] resolve.fenced stale holder refused, as required + [PASS] resolve.divergent_text_refused resolving a closed item with different text refuses and writes nothing + [PASS] resolve.identical_text_idempotent re-sending identical resolution text is an idempotent success + [PASS] reopen.reopens a resolved item reopens unassigned and is directly claimable again + [PASS] reopen.clears_closed_at reopen clears closed_at (the documented, surfaced accounting cost) + [PASS] reopen.close_reason_disposition reopen clears close_reason (measured), and the wrapper's archive comment preserves the previous resolution regardless + [PASS] reopen.emits_event bd records a `reopened` events row, attributed + [PASS] defer.refuses_resolved defer on a resolved item refuses, writes nothing, and names `reopen` + [PASS] block.refuses_resolved block on a resolved item refuses, writes nothing, and names `reopen` + [PASS] release.reopens_unresolved release reopens a held item with no resolution, and it is re-claimable + [PASS] claim.subcommand --claim present, rejects --assignee as expected + [PASS] claim.atomic 5 trials x 12 concurrent claimers, no double-claims + [PASS] claim.directed_atomic 5 trials x 12 concurrent directed claimers on the SAME item, exactly one winner each time + [PASS] link.nonblocking discovered-from is non-blocking + [PASS] list.includes_closed all-flag required and working + [PASS] list.status_filter_includes_closed an explicit --status filter shows closed items without --all + [PASS] show.dependents reverse link visible (1 links) + [PASS] read.no_mutation repeated reads (including not-found/wrong-project misses) leave status, holder, and metadata unchanged + [PASS] resolution.readable resolution text round-trips + [PASS] timestamps.readable created_at/updated_at/closed_at all round-trip as real datetimes + [PASS] metadata.roundtrip arbitrary JSON metadata round-trips + [PASS] project.name_rules dotted names appear usable now; validator may be relaxed + [PASS] custody.fresh_survives a fresh renewal survives regardless of total hold duration + [PASS] custody.stale_reclaimed stale custody is reclaimed: custody stale -- last seen 3601s ago (ttl 900s) + [PASS] custody.idle_not_exempt awaiting_human with stale custody is still reclaimed: custody stale -- last seen 3601s ago (ttl 900s) + [PASS] custody.fenced old holder's renew and resolve are both refused after takeover + [PASS] project.removal remove() drops both the directory and database; re-create afterward is genuinely empty + [PASS] project.create_atomic an abandoned creation lock (dead pid) is healed automatically and create() completes fresh in the same call; path=/tmp/awtcontract_61m0iw38/projects/contract178842189862atomic + [PASS] project.creation_state_reporting creation_state distinguishes none/creating/abandoned correctly + [PASS] service.installed installed and active (unit: /home/bkrabach/.config/systemd/user/amplifier-work-tracker.service) + [PASS] systemd.user_bus_reachable systemctl --user show-environment succeeded + [PASS] dolt.reachable dolt sql-server responds on 127.0.0.1:3308 + [PASS] sweeps.alive reap sweep completed 128s ago (threshold 900s); notify sweep completed 203s ago (threshold 900s) + [PASS] service.restart_policy installed unit has Restart=always -- survives a clean/unintended exit + +All 36 assumptions hold. Safe to run parallel agents. diff --git a/docs/lanes/2nx-defer-block-refuse-resolved/evidence/tiers/format.txt b/docs/lanes/2nx-defer-block-refuse-resolved/evidence/tiers/format.txt new file mode 100644 index 0000000..258e496 --- /dev/null +++ b/docs/lanes/2nx-defer-block-refuse-resolved/evidence/tiers/format.txt @@ -0,0 +1 @@ +145 files already formatted diff --git a/docs/lanes/2nx-defer-block-refuse-resolved/evidence/tiers/lint.txt b/docs/lanes/2nx-defer-block-refuse-resolved/evidence/tiers/lint.txt new file mode 100644 index 0000000..1f5f344 --- /dev/null +++ b/docs/lanes/2nx-defer-block-refuse-resolved/evidence/tiers/lint.txt @@ -0,0 +1 @@ +All checks passed! diff --git a/docs/lanes/2nx-defer-block-refuse-resolved/evidence/tiers/tier1-unit.txt b/docs/lanes/2nx-defer-block-refuse-resolved/evidence/tiers/tier1-unit.txt new file mode 100644 index 0000000..71dac46 --- /dev/null +++ b/docs/lanes/2nx-defer-block-refuse-resolved/evidence/tiers/tier1-unit.txt @@ -0,0 +1,11 @@ +........................................................................ [ 9%] +........................................................................ [ 18%] +........................................................................ [ 27%] +........................................................................ [ 36%] +........................................................................ [ 45%] +........................................................................ [ 54%] +........................................................................ [ 63%] +........................................................................ [ 72%] +........................................................................ [ 82%] +........................................................................ [ 91%] +..................... \ No newline at end of file diff --git a/docs/lanes/2nx-defer-block-refuse-resolved/evidence/tiers/types.txt b/docs/lanes/2nx-defer-block-refuse-resolved/evidence/tiers/types.txt new file mode 100644 index 0000000..4fd4241 --- /dev/null +++ b/docs/lanes/2nx-defer-block-refuse-resolved/evidence/tiers/types.txt @@ -0,0 +1 @@ +0 errors, 0 warnings, 0 informations diff --git a/docs/lanes/2nx-defer-block-refuse-resolved/measure_destructive_loop.sh b/docs/lanes/2nx-defer-block-refuse-resolved/measure_destructive_loop.sh new file mode 100755 index 0000000..3365d04 --- /dev/null +++ b/docs/lanes/2nx-defer-block-refuse-resolved/measure_destructive_loop.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Re-run model_performance-2nx's OWN measurement, verbatim, against whatever +# source tree $AWT_PY resolves `amplifier_work_tracker` from. +# +# create -> claim -> resolve "ORIGINAL TEXT" -> defer -> block +# -> list --id --json (is ORIGINAL TEXT still there?) +# -> block --clear -> claim -> resolve "CORRECTED TEXT" -> readback +# +# BEFORE the fix every one of those verbs exits 0 and the stored resolution is +# destroyed at the `defer` step. AFTER the fix the loop stops at the FIRST verb +# and the original text is intact. +# +# Every command's exit code is printed. `set -e` is deliberately NOT used -- +# the whole point is to run the loop to the end and show where it stops. +# +# Usage: AWT_PY=/path/to/python ./measure_destructive_loop.sh