Repository navigation
v1.8.0 — QA-loop fixes: two wrong committee readings, three new hard rejections
A QA pass driven by actually running the CLI rather than reading it — seven rounds over the documented
user paths (dry-run, generate, stats, refine, the discussion pipeline, leak-check, config handling),
stopping after two consecutive rounds found nothing above P3, plus what three rounds of independent
pre-ship review turned up. Eleven defects, each reproduced before it was fixed and each locked by a
regression test. Two of them produced wrong committee readings rather than crashes, which is the
failure mode this project exists to prevent.
Three previously-accepted invocations now fail fast. All three used to "work" only in the sense
of exiting 0 while producing something wrong or meaningless; none of them has a legitimate use.
Following the v1.7.0 precedent for ISSUE-008, no opt-out flag ships — a flag would preserve the
silent-failure mode being removed. To go back, pin the previous release.
statswhose--modedisagrees with the artifacts in--collect-dir. The error names the
mode it detected and prints the corrected command. Action: pass the--modeyou generated with.--round 0or negative onrefine/discuss-turn/discuss-prompt(and negative on
stats;stats --round 0stays valid and means the generate round). Action: number rounds
from 1. Forrefinethat is because round 0 is the generate round; for thediscusscommands
it is simply the first speaking turn, and a non-positive round used to be written into
discussion.jsonland shown to later speakers as "第 0 轮".- A member whose
seat:is present but empty and which sets no explicitrole:. An empty
seat resolved to the role key"", whose escaped regex matched an inline##occurrence in
roles-review.md's own prose — so that seat silently ran with the file's documentation preamble
as its role prompt. A member that setsrole:is unaffected ingenerate/refine:role
wins overseatin role resolution, so its seat carries no role meaning there. Thediscuss
commands require a non-empty seat from every member regardless, because thereseatis also the
anonymized speaker identity and theblindvote_<seat>.jsonfilename.
Action: writeA/B/C/D, or drop the key entirely to fall back to the default role.
Fixed
- A finished straggler no longer has its result thrown away at the grace-window boundary.
dispatch_with_quorumharvests completed futures, then registers grace deadlines, then expires
them — so a seat that finished during the harvest callbacks was still inpendingand got recorded
asskipped_graceeven though its result was already in hand. Both directions were wrong: a real
HTTP 401 was laundered into "voluntarily skipped, not a fault", which zeroes out the
members_failed - members_skippeddifferenceSKILL.mdteaches the arbiter to read, and discarded
both the actionable hint and the already-billedusage; a successful straggler lost a paid-for
committee opinion outright — in the reproduction, the only seat votingfailwith a blocker, leaving
statsreporting a unanimouspasswith zero blockers. The expiry check now asksfut.done()first
and takes the real result when there is one. Genuinely-running seats are still skipped exactly as before. statsno longer emits an all-zero tally when--modedisagrees with the artifacts.--mode
defaults toreviewand artifacts do not record their mode, sogenerate --mode brainstormfollowed
by a barestatssilently producedverdict_tally={'?':N},mean_confidence=0.0and zero issues —
andsynthesis.mdrequires the arbiter to copy stats numbers into the report verbatim, so that empty
reading went straight into the deliverable.statsnow infers the mode from the schema keys the
successful seats actually carry and refuses to aggregate under the wrong one. The detector is
deliberately conservative: every successful seat must point at exactly one other mode, and an
ambiguous or unrecognizable shape stays silent. Failed seats are skipped rather than vetoing the
check, so degraded runs — the common case here — are still covered.dry-runno longer crashes on a config written the way the example config's comment tells you
to.dict.get(k, default)only substitutes when the key is absent; YAMLmodel:(explicit
null) returnsNone, which a:<28width format rejects withTypeError. A primary codex
seat is writtenmodel: null— that is whatconfig.example.yaml's own comment instructs
("codex 兜底: model 必须置空") — and the very first step of the documented workflow, showing the
user the roster and cost before spending anything, died on it. (The shipped file itself only uses
model: nullinsidefallbackentries, whichdry_runnever renders, so a stock config was not
affected.) An explicit null in any roster column now renders as that column's empty placeholder
rather than raising or printing the literalNone.- One malformed field no longer destroys the whole refine-round aggregation. ISSUE-002 hardened
compute_statsandcompute_discuss_statsagainst ill-typed member fields but skipped
compute_refine_stats, whereverdictandrevised_claimed_optionare used as dict keys and set
elements. A model writing either as a list or an object raisedTypeErrorat three sites and threw
away every other seat's already-billed output. Non-string values are now excluded, and
early_stop_suggestedadditionally requires every successful seat to have a readable stance —
a stance nobody can read is not agreement. compute_discuss_statstolerates non-string seats.seatdoubles as a dict key and a sort key
there, so mixingseat: 1andseat: Aacross members crashedsorted()on int-vs-str. Seats are
normalized to strings for aggregation; string seats are unaffected.- The
[budget]banner stops promising a fallback that does not exist. It said
"已让位给下一条 fallback" unconditionally, including when the exhausted link was the last (or only)
one on the seat — sending the reader to debug a degradation chain that was never configured. - A straggler whose worker raises is recorded as a failure, not as a voluntary skip. 1.7.1
reached such a seat only through the expiry branch, which overwrote it withskipped_grace— the
same laundering of a fault into "not a fault" as the headline fix above, and the same corruption
of themembers_failed - members_skippeddifference. It is now recorded as a real failure
carrying the exception text. Reachable because the worker runsresolve_channel(member)and
opts["timeout_seconds"]outside_dispatch_channels' ownexcept Exception. - Non-string
seat:androle:values no longer crash role resolution._seat_rolehands the
raw value toload_role_prompt, which feeds it tore.escape— soseat: 1,seat: falseor
role: 123died with a rawTypeErrornaming theremodule, andseat: ["A"]died even
earlier on the unhashableDEFAULT_SEAT_ROLElookup. Both sites now coerce tostr, which is an
identity map for every string seat and role — i.e. for everything the docs, the example config and
decide-mode role injection produce — and lets an odd value fall through to the generic role
sentence that already exists for unknown roles. (One exotic exception: acustom_roleskey that is
not a string, such as YAML's1:oron:, is no longer matched by an equally non-string
role:/seat:; such a pair now lands on the generic sentence.)
Changed
- Named errors replace raw tracebacks when reading a user-supplied path. ISSUE-005 established
this for--input/--inject; the same doors elsewhere still raised library exceptions:- hand-written CH1 artifacts (
member_*.json,blindvote_*.json— the--collect-dirseam where
the arbiter, not the script, writes the file) gaveJSONDecodeErrorwithout naming the file, or
KeyError: 'name'; - a
config.yamlwith misaligned indentation or a tab gave the YAML scanner's error, and a
--configpointing at a directory gaveIsADirectoryError— while the same function already
named the missing-config case; - an unwritable
--collect-dirgavePermissionErrorfrommkdir(the creating commands —
generate,discuss-turn,discuss-blindvote; a write that fails later, e.g.statswriting
into a directory that is read-only but already exists, still raises); - a non-UTF-8 file at any of those four doors gave
UnicodeDecodeError. GBK-saved Chinese briefs and
configs are a routine Windows artifact, andleak_checkhad handled this for its own reads all
along. The message now names the encoding and gives theiconvinvocation.
- hand-written CH1 artifacts (
seatis validated at startup. An emptyseatused to surface either asTypeErrorfrom
re.escape(None)deep inside role resolution — an error with no textual connection to the config —
or, forseat: "", as the silent first-section match described above. Only empty values are
rejected, and only when the member sets norole:;seat: 1and a missingseatkey still work
(see the coercion fix above), because tightening further would be a breaking config change for no
defect.
Known, not fixed here. Two raw-traceback paths remain, both confirmed to behave identically on
v1.7.1 and both deliberately left out to keep the repair round narrow:
options: {}passesvalidate_configbut then raisesKeyError: 'timeout_seconds'at dispatch,
because_dispatch_channelsindexesoptsdirectly while its siblings
(min_successful_members,grace_seconds) use.get()with a default. Write theoptionsblock
asassets/config.example.yamldoes.--modelscombined with a config that parses toNone(a zero-byteconfig.yaml, or
--config /dev/null) raisesTypeError: 'NoneType' object is not iterable, because
apply_custom_committeeruns beforevalidate_config. Without--modelsthe same input gives the
named[config] 顶层必须是 YAML 映射(dict)error.
tests 231 → 305.