Skip to content

agenttalk 0.97.0

Choose a tag to compare

@zoolok17 zoolok17 released this 05 Oct 09:50
· 711 commits to master since this release
3adc32a

In short: this release is about seats that run into limits, and about a safe way
for other programs to read the gateway's money figures. A Claude seat that runs out of
its 5-hour or weekly allowance now stops and waits, instead of retrying for hours and
then losing the message, and it keeps its working context while it waits. A seat's
health now names the real cause of a failure, for example a usage limit and its
window; it no longer says "rate limited" just because an error contains the letters
"rate", and it never guesses when a seat will recover. For a program that keeps its
own accounts, the gateway gains a read-only gateway report, each receipt now names
its month, gateway receipts works from any folder, and the Qwen gateway can tie a
paid child turn to an outside quota reference and keep a receipt of what it spent. An
optional turn journal can record what each turn did. A refused work order no longer
prints anything that looks like proof it was sent, and the new console no longer hides
a stalled seat the server had already flagged. Tests no longer reach a running gateway
by default, CI logs list every skipped test with its reason, and two rare test
failures are gone or explained. Upgrading a running gateway is a separate step: the
ledger change for quota references is opt-in (agenttalk gateway binding-install),
and older agenttalk versions refuse a ledger once it is migrated, so read the gateway
entries below before you upgrade one.

Changed

  • A Claude seat that runs out of its allowance now stops and waits, instead of retrying for
    hours and then losing the message.
    Until now, when a wrapped Claude seat used up its 5-hour or
    weekly allowance, the wrapper started the model again every few seconds for four hours (844 and
    809 tries in two real cases) and then dead-lettered the message. This is on by default.

    What changes: the first time the seat proves it is out of allowance (Claude reports a rejected
    usage event and ends the turn in error), the wrapper parks the message. It tries once more
    each time the wrapper is started again, and once 30 seconds after the reset time Claude states
    (only when that time is in the future and within 8 days). Everything else behaves as before.

    What you will notice: a seat that stops and waits. agenttalk status, attention, doctor and
    both web consoles show it as parked on a usage limit ("until " or "until restarted"), and
    the liaison gets one notice. The seat stays alive and the supervisor leaves it alone. The
    message is kept, but messages behind it wait, including release and end.

    What to do: wait for the stated time, or start the seat again now with
    agenttalk request-restart --for <agent> (a protected seat also needs --force-protected and
    --acknowledge-live-protected-kill), or skip the message with
    agenttalk ack --for <agent> --id <message id> (no dead-letter record). To get the old behaviour
    back, set AGENTTALK_STOP_RETRIES_AT_LIMIT=0 (or false, off, no) in the wrapper's
    environment; it gives the previous behaviour. One qualification: a message that already carries
    a park when you switch it off is driven as before, but the attempts and the time it spent parked
    stay out of its disposal counts.

    With the optional turn journal on, the turn that proved the limit is one failed dispatch (the same
    failure class as with the switch off), nothing more is recorded while the seat waits, and a later
    successful try gives a new dispatch and exactly one completed for the message. A parked seat is
    held to the same rule as a stalled warning in the web console: always counted as needing attention,
    never "All quiet" or "not for you", and no saved "Later" choice hides it.

    Technical detail: a new usage_limit failure word, new fields in the message's attempt record
    (park_state, parked_at, parked_generation, park_count, probe_marker,
    excluded_attempts, parked_seconds_total, limit_failures, limit_window, reset_epoch,
    wake_epoch, last_reset_epoch, probed_wake_epoch, notice_key, notice_routed,
    notice_tries, notice_next_at; added only, none renamed or removed), a marker file
    state/usage-limit-park/<agent>.json, the health reason usage_limit_parked, and a
    usage_limit_park field in status --json, the supervisor report and the web state rows. It
    replaces the approach of pull request #104 (never merged), which read the provider's error text.

    Fix round 1 (this update): a cold read found a few edge cases the first version did not
    handle cleanly, all now fixed:

    • A momentary file-system hiccup while refreshing the marker could, on some Python versions,
      escape and stop the wrapper's own heartbeat - the seat would then look dead until restarted.
      The marker refresh is now one single protected step, start to finish.
    • A damaged or very unusual marker file could carry a time so large or so early that showing it
      broke the very screen trying to show it (status, doctor, or either web console). Every
      time in the marker is now checked against what any of those can actually display; a field
      that cannot be shown is simply left out, never crashes the screen, and the rest of the marker
      still reads normally. A file missing one of its eleven documented fields is now refused
      outright, as the documented contract always said it should be.
    • The "how long has this seat been parked" shown in attention could read as a few seconds
      even after days, because it was reusing the marker's own refresh time by mistake; it now
      comes from when the park actually began.
    • The one liaison notice for a park no longer shows twice (once in the main list, once as its
      own separate question that never went away on its own once the park ended) - it is shown
      once, as a single live warning, like a stalled seat already is.
    • A notice meant for the liaison no longer silently fails to send when the parked seat is
      itself the liaison and a separate lead exists to receive it instead.
    • One unrelated file left in an internal folder could previously stop doctor from listing any
      parked seat at all; it is now skipped on its own.

    What you will notice: nothing new to do - these were all edge cases that could otherwise have
    hidden a real park or looked like an unrelated crash.

    Fix round 2: a further read found that round 1's notice fix only hid the duplicate card,
    without closing the obligation it left behind, and opened a second gap of its own; three
    further edge cases in the park's own bookkeeping were also found. All now fixed:

    • A seat whose park had already recovered could still leave the liaison with an unanswered
      question showing in places that track what the liaison still owes a reply to - round 1 only
      hid the duplicate card, it never closed the question itself. The liaison notice is now
      purely informational everywhere, the same as any other status update: it was never a
      question owed a reply to begin with, so it can never stay stuck looking like one.
    • Publishing the small status file that adds a park's reset time and freshness can fail
      without the park itself being affected - round 1's fix for the point above could then hide
      the only warning a parked seat had. The warning itself no longer depends on that file: it
      shows (without the reset-time detail) whenever the seat's own durable record says it is
      parked, file or no file.
    • A damaged or hand-edited internal count could, in rare cases, cause a message to be
      dead-lettered after only one real try, even with this whole feature turned off. Both
      counts behind that decision are now kept sane before they are used.
    • A status file with a date but no time zone was read as local time instead of being
      rejected, which could make a park look a different age on two different machines. A date
      with no time zone is now refused, same as any other damaged field.
    • The new console's "needs you" panel showed a seat that had just started up the same as one
      that was confirmed stuck or dead. A normal startup now reads as itself, not as a failure;
      a genuinely stuck or dead seat is unaffected.
    • Round 1's fix for a notice going to the wrong place also changed where two OTHER, older
      kinds of notice go when this whole feature is switched off. Narrowed to affect only this
      feature's own notice; the other two are back to their original behaviour.

    What you will notice: nothing new to do - these were all edge cases in the park's own
    bookkeeping, surfaced only by a damaged file, a missing optional file, or an unusual sequence
    of events.

    Fix round 3 (park reader recast, this update, the last): round 2's fallback for a missing
    status file was added to only one of the seven screens that show a park (attention); a
    further read found it could also show a stopped wrapper as freshly parked instead of "not
    responding", and could show an earlier, already-handled message's park instead of a new one.
    Every screen now reads one single, shared answer instead of each finding its own way to cope
    with a missing or outdated status file:

    • status, doctor, the supervisor report, the web seat row and web attention all now show
      a park exactly the same way attention already did - built from the seat's own durable
      record whenever the status file is not readable, not only in attention. Three of those
      five previously showed nothing at all in that case.
    • A park is now always the CURRENT message waiting for that seat, found the same way the
      wrapper itself finds its next message - never an older, already-handled one the status file
      happened to still mention, and never silently missing a second park that started after the
      first was handled.
    • A seat whose wrapper has actually stopped no longer reads as freshly parked just because its
      durable record still says so. Freshness now always needs real evidence that the wrapper is
      still there (its own recent heartbeat), never assumed from the record alone - including
      right after the feature is switched off in a project where it ran before: old records are
      read exactly as honestly as new ones.

    What you will notice: the same screens, the same wording, now simply correct in two further
    situations: a wrapper that stopped answering, and a second message parking after the first was
    already dealt with.

Added

  • agenttalk gateway report: the gateway ledger's money figures from one
    moment, for a program that keeps its own accounts.
    A program that tracks
    what each quota lease spent had to read agenttalk gateway status, which also
    checks the running service and needs an agenttalk project, and could not tell
    money already spent by a running leased turn from money that no lease owns.
    The new command reads only the per-user ledger, all at one moment, and prints
    JSON: each month's committed money, the attempts still unresolved (without
    ids), the open child turns, how far the receipts reach, whether the ledger
    holds spending (as one fixed word, never the hold's text), and, per month, the
    money of leased turns that is spent but not yet on a receipt. With the receipt
    pages, a reader can count every micro-euro exactly once. Every figure is
    defined in the guide, as report version 1.

    What you will notice: a new command, agenttalk gateway report --json. It
    works from any folder, needs no token, never changes the ledger and never ends
    a turn. It exits with code 0 whenever the ledger can be read, even while
    spending is held; otherwise it prints one word (bad_request or
    report_unavailable) and never a path. A readable report is not permission
    to spend.

    What you need to do: nothing.

    Technical details: SpendLedger.report and check_gateway_report in
    src/agenttalk/ovh_gateway.py, _cmd_gateway_report in
    src/agenttalk/cli.py, the contract in docs/QWEN-OVH-TRIAL.md ("The
    ledger report (report version 1)"), and tests/test_ovh_gateway_report.py.
    No ledger migration and no change to either policy hash.

  • Each receipt now names its month. A receipt on a receipt page carries
    charge_period: the UTC month in which all of that turn's charges were made,
    or null when they fall in two months or there was no charge. Before this, a
    reader could not tell which month a receipt's money belongs to.

    What you will notice: receipt rows gain charge_period, and
    child_receipt_report_version in agenttalk gateway status is now 2. The
    month is worked out from the ledger's attempts each time a page is read;
    nothing new is stored.

    What you need to do: nothing.

    Technical details: SpendLedger._charge_period and the charge_period rules
    in check_receipt_page (src/agenttalk/ovh_gateway.py).
    RECEIPT_ENVELOPE_VERSION stays 1: no release has shipped receipt pages yet.

  • An optional turn journal. A wrapped agent can now keep a small,
    append-only journal of what its turns did: when a turn was dispatched, when a
    model process was launched, how the turn ended and how many tokens it used,
    and how each message was finally dealt with. It is meant for anyone who wants
    to count turns, runs or token usage afterwards. It is off unless you turn it
    on, with agenttalk wrap --loop --turn-events or AGENTTALK_TURN_EVENTS=1,
    and with it off nothing changes.

    A turn never waits for the journal: an event is only dropped into a small
    in-memory queue and a separate thread does all the file work. The journal
    adds no file reading and no logging to a turn, and never writes to the
    wrapper's own log. If the queue is
    full or the disk is full, slow or broken, events are dropped and counted, and
    the journal says so instead of pretending to be complete (numbered events, a
    count of what was lost, a record of every stream that was started, and a
    closing record on a clean stop). A message's ending follows the wrapper's own
    definition of a completed turn, and the raw terminal facts are recorded next
    to it. Where the wrapper cannot prove a message's reply landed, the journal
    records "outcome unknown" rather than guess. agenttalk status and
    agenttalk doctor show whether the journal is on, not responding, ended or
    off (a writer counts as running only when its process is still running and
    matches the record), and which modes are not journaled. On macOS, where
    agenttalk cannot read a process's identity, they say the journal's state is
    unknown, so there they cannot tell you that it stopped or failed to start.
    A first-ever start
    failure that creates no journal folder is not shown there. The "off behaves as
    before" proof covers the listed legacy-loop scenarios, not every path.

    Once the journal is cancelled (a start timeout or failure, or close's
    deadline), the writer begins no new step. A step it has already begun (one
    helper: an append, an atomic status write, a sync, a registration read with its
    read-only process-identity lookup) may finish on the writer's own thread. No
    caller ever waits for it. After close times out, no new startup status write
    begins; a status write already started may finish on the writer's own thread.
    A failed sync of a full file is counted and recorded in the
    journal's status record rather than retried (that file's durability is then
    unproven).

    A message's time is recorded only when it is a real time; anything else (a
    path, a name) is recorded as empty, so no private text can reach the journal
    that way. Finding the journal's folder counts against its 2-second start-up
    limit and happens off the wrapper's own thread, so a slow disk cannot hold up
    the wrapper.

    The files are in a per-user folder beside the wrapper logs, which
    AGENTTALK_TURN_EVENTS_DIR can move. For developers: agenttalk.turn_events
    has the writer, the closed record format with its checker, and a reader for
    the files. The reader's cursor moves past a record only when the record is
    handed over, so a reader that stops early resumes where it stopped.

  • The Qwen gateway can now tie a paid child turn to an outside quota
    reference, and keeps a permanent receipt of what that turn spent.
    This is
    the gateway half of quota lease binding. An outside quota program can hand
    out a reference, called a quota lease reference. A child turn opened with
    one can carry its own smaller caps (calls, money, time), never above the
    ledger's own ceilings. When it ends, the ledger writes one receipt with the
    outcome, calls, tokens, cost and close time. The receipt never holds the
    reference itself, only a fingerprint of it (its SHA-256).

    Why it matters: an outside program can now check, turn by turn, what each
    turn it admitted actually cost, from the gateway's own accounting.

    What you will notice: before this, the gateway ledger had no references and
    no receipts. Now, a fresh agenttalk gateway init creates the new ledger
    shape (child-cap schema 4). An existing ledger keeps working exactly as
    before until you migrate it with the new agenttalk gateway binding-install
    command. After that, agenttalk gateway status shows five new receipt
    counts. A new command, agenttalk gateway receipts --after N --json, prints
    the receipts page by page. A binding flag, set with
    agenttalk gateway binding-required --on|--off, makes the gateway refuse
    turns without a reference. It starts off, and only that command changes it.
    On a migrated ledger, a turn that has ended keeps its first ending; before,
    a turn stopped by its call ceiling could later be relabelled as expired.
    Older agenttalk versions refuse a migrated or newly made ledger completely,
    for every operation, so the gateway's runtime must be upgraded before you
    migrate.

    What you need to do: nothing, unless you want to migrate. The wrapper does
    not send references yet, so all turns stay unbound. Do not turn the flag
    on with this version: it would pause every paid Qwen turn until you turn it
    off.
    To migrate, follow "Upgrade an existing ledger" in
    docs/QWEN-OVH-TRIAL.md: stop the gateway, back up the ledger, upgrade the
    gateway's runtime, resolve every open attempt, migrate, then start the
    gateway again. The flag gives
    admission control only. It is not a per-attempt authority check or a sized
    reservation, and turning it on is not activation of paid-quota enforcement.

    Technical details: src/agenttalk/ovh_gateway.py (child-cap schema 4 on
    ledger schema 3, which fences out every older writer; guards against
    replacing inserts;
    child_turns columns quota_lease_ref_sha256, terminal_outcome,
    terminal_at, terminal_source; tables child_receipts and
    receipt_pending with no-delete and no-update triggers; metadata
    quota_lease_binding_required; install_child_cap_binding,
    set_quota_lease_binding_required, quota_lease_binding_state,
    child_receipts_page, sweep_child_receipts, check_receipt_page,
    the *_as_operator helpers the commands call (the ledger reads the front
    token itself, so the command never holds it),
    parse_receipt_page; new keywords on open_child_turn and
    close_child_turn; status() reads in one snapshot and adds
    child_receipt_report_version, child_receipts, child_receipts_pending,
    child_receipts_fallback, child_receipts_through_seq on schema 4 only),
    src/agenttalk/cli.py (gateway binding-install, binding-required,
    receipts). Tests: tests/test_ovh_gateway_binding.py,
    tests/test_ovh_gateway_binding_readers.py,
    tests/test_ovh_gateway_binding_base_fence.py (master c80e1e5's own gateway
    code, read from git, refuses every operation on an upgraded ledger), and a
    golden file recorded from master c80e1e5
    (tests/golden/gateway_schema3_c80e1e5.json).

Fixed

  • CI logs now list every skipped test with its reason (#320). The dev
    gate ran its tests with -q only, so a test skipped on a CI leg left no
    trace: a check that never ran while the run stayed green. The logs now
    end with one line per skipped test and its reason. The first use found
    that the 55 downgrade-fence tests were skipped on every leg (fixed
    separately).

    What you will notice: each leg's pytest log (in the uploaded dev-gate
    evidence) ends with the skip list. Nothing else changes.

    What you need to do: nothing.

    Technical details: dev-gate.json and the pinned pytest contract in
    src/agenttalk/dev_gate.py add -rs. dev_gate.FORWARDED_TEST_VARIABLES
    now declares every variable the gate passes to its test runs, each only
    as its one expected value. tests/test_dev_gate_workflows.py fails when
    the dev-gate leg job sets a variable without a declared role.
    AGENTTALK_AUTHORIZE_SYMLINK_DEVMODE is declared "not forwarded": hosted
    Windows runners create symlinks without developer mode, and #320 showed no
    symlink test skips. One known, accepted skip remains:
    tests/test_ovh_gateway_reasoning.py needs PyYAML, which the wheel test
    environment does not install; the source runs cover it.

  • The downgrade-fence tests now run in CI. These 55 tests prove that
    agenttalk from before quota lease binding refuses every operation on a
    migrated or new gateway ledger, and changes nothing. They loaded that older
    code from git history, but CI tests a copy of the repository without git
    history, so every one of them was skipped on every CI run.

    What you will notice: nothing in the product. CI now runs these tests
    instead of skipping them.

    What you need to do: nothing.

    Technical details: tests/test_ovh_gateway_binding_base_fence.py loads the
    module from tests/golden/ovh_gateway_c80e1e5.py.golden, made with
    git show c80e1e5:src/agenttalk/ovh_gateway.py and pinned by its SHA-256
    (62058a5c...ed32); where git history exists, a test compares it with the
    commit byte for byte. .gitattributes keeps the file LF on every checkout.
    Found with the skip reasons of #320.

  • Tests no longer reach a running gateway by default (#318). A dozen tests
    bind or call the paid gateway's real ports (127.0.0.1:4000 and 4001). They
    ran by default, so on a machine that runs the live gateway, running the
    gateway test files could send a probe request to it. Now they are opt-in.

    What you will notice: before this, those tests ran in every local test run.
    Now they are skipped unless AGENTTALK_TEST_GATEWAY_PORTS=1 is set, and even
    then they are skipped if either port is already occupied when tests are
    collected (when pytest first collects the tests - its one pass over the
    test files, before any test runs), with a reason that says which. That
    check runs once, at collection, so a gateway started later in the run is
    not noticed. CI sets the variable on every dev-gate leg, so CI runs them
    exactly as before.

    What you need to do: nothing. To run them locally on a machine with no
    gateway running, set the variable and keep the gateway stopped until the
    run ends (see docs/DEV-GATE.md).

    Technical details: tests/gateway_port_guard.py (the opt-in and the bind
    check, which reuses the gateway's own exclusive_bind_probe, once per
    session at collection), called from pytest_collection_modifyitems in
    tests/conftest.py for _GATEWAY_PORT_TEST_NAMES; dev_gate._base_env
    forwards the variable only as 1; .github/workflows/tests.yml sets it on
    the dev-gate leg job. Tests in tests/test_gateway_port_guard.py,
    tests/test_dev_gate.py and tests/test_dev_gate_workflows.py.

  • agenttalk gateway receipts now works from any folder. The receipts
    command reads only the per-user gateway ledger, but it first checked for an
    agenttalk project. Run from an ordinary folder, it failed before reading
    anything and printed the folder's path instead of one of its fixed words.

    What you will notice: before this, running agenttalk gateway receipts
    outside an agenttalk project printed agenttalk: not initialized at <folder> and exited with code 2. Now it prints the receipt page from any
    folder. When its arguments parse, every failure prints exactly one fixed
    word (bad_request, receipt_page_refused or receipts_unavailable) and
    never a path. The other agenttalk gateway commands still need a project.
    The guide also now says three things more exactly: a failed install-marker
    update after the database step leaves a state every version refuses until
    binding-install runs again; argument errors print the usual usage text; and
    a referenced turn without a recorded ending stops the ledger, with no
    automatic repair.

    What you need to do: nothing.

    Technical details: src/agenttalk/cli.py (cmd_gateway dispatches
    receipts before _get_store; _cmd_gateway_receipts turns every exception
    into receipts_unavailable), the install_child_cap_binding docstring, and
    docs/QWEN-OVH-TRIAL.md. Tests in tests/test_ovh_gateway_binding.py run the
    command as a separate process from a folder with no project, with a
    restricted environment and a synthetic ledger and home.

  • A rare, harmless test failure on busy CI runners is gone (#304). One
    web-dashboard test checked that a crashed request gets logged, by
    repeatedly re-reading the server's console output for up to five seconds
    and giving up if the message hadn't fully appeared by then. On an
    unusually busy CI machine, that five-second wait once ran out before the
    full message had arrived, failing the test even though the server was
    behaving correctly - costing a 2.5-hour full CI rerun to confirm it was a
    fluke.

    What you will notice: nothing in the product itself; this is a
    test-only fix. The test now waits for the server's own "I just finished
    logging this" signal instead of guessing how long that might take, so it
    can no longer be fooled by a slow moment on a loaded machine.

    What you need to do: nothing.

    Technical details: tests/test_web.py, new helper
    _server_error_logged_event wraps the test's own server instance's
    handle_error (the stdlib socketserver method that prints the
    traceback) to set a threading.Event the moment it returns, used by
    test_thread_route_real_error_returns_500_and_logs. Verified failing
    (red) with the signal suppressed and the real handler never invoked, and
    passing 20 times in a row locally. Refs #304.

  • The new console no longer hides a stalled seat the server already saw
    (#295).
    The console rebuilds its own "looks stuck" warnings from what
    the browser can see of each seat's health, and used to throw away the
    server's own stalled-seat warnings outright. When the server flagged a
    seat the browser's own check disagreed about, the warning simply
    vanished - no badge, no card, "All quiet. Nothing needs you." while a
    real seat was down.

    Now a server warning is replaced only when the browser genuinely derives
    the same seat's incident; every other server warning keeps its own card,
    at its own severity, and stays in the count. A seat flagged by both
    sides still shows once, not twice.

    What you will notice: the panel can no longer show "All quiet" while a
    seat the server flagged is actually down. A warning kept this way cannot
    be put aside with "Later" or "Wait" in this version, and offers no
    "Answer" either - it has nothing behind it to answer. It simply stays on
    screen, at its own severity and counted, until the server itself stops
    reporting the problem. It also never shows up a second time under
    "also happening, not for you" while it is open in the main panel.

    Two earlier attempts let this same warning be put aside like any other
    card, then tried to make that choice remember which side's evidence it
    was about so a later recovery would clear it correctly. Both ran into the
    same wall: a choice saved by an older build (before either attempt
    existed) carried none of that memory, so it could still silently swallow
    a brand-new, unrelated problem on the same seat, or vanish on a reload
    during an outage before anything had actually been confirmed recovered.
    Rather than patch the patch again, this warning is simply made
    un-dismissable instead - a choice can never hide something it was never
    allowed to be made about, old storage included.

    What you need to do: nothing.

    Technical details: console2-model.js's buildTeamView matches a server
    source: "stuck" item to a client-derived incident by the raw agent name
    only - the same identifier both sides already compute their own
    "stuck:" + name id from, so no new key was needed. An unmatched server
    item renders through the ordinary attentionCard() path instead of being
    dropped, but with options: [] (no "Answer") and a serverOnly: true
    marker; console2.js's needsCard renders no Later button for it, and
    isDeferred/isAnswered treat serverOnly as immune to local
    disposition exactly as they already treat an aggregate group card - so
    an id collision with any saved choice, in any storage format, can never
    suppress it. The "needs you" badge counts every server item once, plus
    every client-derived incident, minus the ones matched on both sides, so
    an overlapping seat is never counted twice, and the "also happening"
    sidebar excludes any seat already shown as an open warning in the main
    panel. compareCards sorts on the shared source === 'stuck' field
    (not the client-only kind === 'LOOKS STUCK' label), so a server-only
    stalled card sorts with the stuck cards as the panel's own "stuck agents
    first" text promises. console2.js's own save/load/prune/apply code for
    Later/Wait (later, cleanTable, loadLater, saveLater, uiFor,
    deferCard, waitOnCard, pruneRecovered) is otherwise identical to
    its pre-#295 form. Tests in tests/console2_view.test.mjs and
    tests/console2_stream.test.mjs, the latter through the real app boot/
    click/keyboard/redraw path - including previous-format saved storage
    (no new fields at all) against a feed that fails, then recovers.

  • The turn journal's reader now refuses a record the journal's own
    writer could never produce (#307).
    The checker that reads a journal
    file trusted some numbers and text it should have cross-checked: an
    event could claim more lost events than it had even had the chance to
    see, and a seat's version text could be empty or contain a path, even
    though the writer only ever writes one tight word shape for it. Neither
    of those can happen from agenttalk's own writer, but a damaged file, or
    one made by hand, could still slip past the reader and hand a caller an
    impossible count or a stray piece of text it never expected.

    What you will notice: nothing, if your journal files come only from
    agenttalk's own writer - they already satisfy every one of these checks.
    A damaged or hand-made file that does not is now refused outright,
    instead of being read and passed on as if it were trustworthy.

    What you need to do: nothing.

    Technical details: validate_event in src/agenttalk/turn_events.py now
    enforces dropped_total < seq for every record that carries a seq
    (dispatch_started, dispatch_ended, message_disposed - seq
    counts from 1 and dropped_total counts only loss before that event,
    so the event's own arrival bounds the count) and dropped_total <= last_seq for a stream_closed record (last_seq is how far the
    numbering went). agent_version on a stream_started record is now
    checked against the writer's own _VERSION grammar (reused, not
    duplicated) instead of only a length bound. Tests in
    tests/test_turn_events.py mutate valid records to break each rule one
    at a time (the reader refuses them) and confirm the same records still
    pass unmutated, including the exact boundary each rule allows.

  • The turn journal no longer loses an event without counting it when one
    part of a program reports an event while another part closes the journal
    (#302).
    The journal promises that every lost event shows: each event gets
    a number, and the closing record says the last number used and how many
    events were lost. That promise depended on which of two steps acted first.
    If a turn had already checked that the journal was still open, and the
    journal then finished closing before the turn took its number, the event got
    a number that the closing record never counted. A related case had the same
    cause: two parts of a program reporting events at once could hand the
    journal their numbers out of order. The journal then refused the late event
    and counted more lost events than it had ever numbered, so its closing record
    failed the journal's own checks and was never written.

    Who was affected: nobody, as far as we know. agenttalk's wrapper reports
    events and closes the journal from one place, one after the other, so
    neither case could happen there. Either could as soon as anything reported
    events from a second thread (a second line of work running inside the same
    program).

    What you will notice: nothing in normal use. Before this, those two timing
    cases could leave a journal whose numbers did not add up. Now an event is
    checked, given its number and placed in the journal's waiting line (its
    in-memory queue) in one step, and closing begins only between two such
    steps. An event reported before closing begins is written ahead of the
    closing record. One reported after it gets no number, exactly like an event
    reported after close. Numbers reach the writer in order. So the journal's
    promise is about numbered events: every number it hands out is written or
    counted as lost. A turn still never waits for the journal, and close still
    stops at its time limit.

    Each event's time is read just before it gets its number, outside that one
    step, so a clock that is slow to answer in one turn holds up no other turn
    and not close. One result: an event's time can be earlier than the time of
    the event before it, so read events in number order (the README now says so).

    Also fixed in the same place: on Windows with Python 3.12 or earlier, close
    could give up waiting a moment before its own time limit by the journal's
    clock, because that clock moves in 15.6 ms steps there (seen in 9 of 400
    test runs, by up to 4 ms; never on Python 3.13 or later). In that moment the
    journal's startup status note could still be written just after close had
    returned. Now the time limit ends the moment close gives up, so nothing new
    begins after close returns, as the journal's stated limits already said.

    Also fixed: a clock that gave a time no date can hold (for example one set
    tens of thousands of years ahead) stopped the journal's writer for the rest
    of the run, with no closing record. Now only that one event is lost, it is
    counted, and the journal carries on. A clock that fails outright still costs
    only its own event, counted as lost, as before.

    What you need to do: nothing.

    Technical details: in src/agenttalk/turn_events.py,
    TurnEventSink.emit now repeats its state and closing check, assigns seq
    and calls put_nowait while holding self._lock, the lock close sets
    _closing under. The unlocked check stays as a quick refusal. The caller's
    fields are copied, and the caller's clock is read and converted to
    milliseconds, before the lock is taken; a clock that fails leaves its number
    unqueued, a counted gap. Nothing under the lock runs the caller's code, and
    put_nowait never waits for room. When close declares close_timeout it
    sets _deadline to the earlier of the deadline and now. _process now
    builds the record, format_time included, inside its counted bad-event
    step, and the module docstring states the at order. In
    tests/test_turn_events.py, a
    stand-in for the sink's lock (_HeldAt) holds one emitting thread at an
    exact point while another thread closes or emits:
    test_an_emit_held_after_its_not_closing_check_while_close_completes_takes_no_number,
    test_an_emit_that_resumes_while_close_writes_its_closing_record_takes_no_number,
    test_an_emit_admitted_before_close_began_is_written_ahead_of_the_closing_record
    and test_two_emitters_reach_the_writer_in_number_order. The bounds are in
    test_close_keeps_its_deadline_while_an_emitter_races_it_and_leaves_nothing_running
    and test_f1_a_close_whose_wait_ends_before_its_deadline_by_the_clock_still_ends_the_exception.
    The caller's clock is covered by
    test_a_clock_that_stalls_in_one_emitter_holds_up_neither_another_emitter_nor_close,
    test_a_clock_that_fails_leaves_a_counted_gap,
    test_a_time_no_date_can_hold_is_a_counted_bad_event_and_the_writer_goes_on
    and test_an_event_can_carry_an_earlier_time_than_the_one_before_it_while_numbers_stay_in_order.
    The written files are read back through the journal's own checked reader.
    test_f1_close_overrides_the_start_timeout_exception_once_its_deadline_passed
    no longer waits out the deadline on the journal's clock (the
    until_close_deadline_passed helper is gone). This removes limit 13 that PR
    #300 stated.

  • A seat's health no longer says "rate limited" on any error that merely contains the
    letters "rate" (#305).
    The old check matched the bare substring "rate" - which also
    matched ordinary words like "generate", "separate" and "iterate" - and kept no record
    of what the error actually was, so a lead seeing the label had no way to tell a real
    usage limit from a passing hiccup or a false match. On one real day, a lead saw the
    label three times on two seats and suspected the account's allowance was running low;
    the operator confirmed plenty was left.

    What changes: the seat's health now looks at the provider's own structured answer
    first - a refused request that names a known allowance window is a real usage limit; a
    "please slow down" (HTTP 429) is a brief throttle; "I'm overloaded right now" (HTTP
    529) is a provider capacity problem, not a usage limit at all - recognized whether or
    not the provider's answer also carried a plain numbered HTTP status. That proof, once
    seen, is never overwritten later in the same turn by a weaker, unrelated error - a
    window a lead was already told about does not quietly turn into "a brief throttle" or
    vanish altogether just because the same failed turn also produced a later, generic
    error - but a genuinely later SUCCESS on that same turn correctly cancels it instead:
    a seat whose request the provider ultimately let through is never told it is still
    sitting on an exhausted allowance just because an earlier moment in the same turn was
    refused. Only when no structured evidence exists at all does it fall back to reading
    the error's own words, and even then only on a handful of exact, whole-word phrases
    ("rate limit", "too many requests", "quota") - never the bare word "rate" again, and
    never a phrase found merely sitting inside a longer, unrelated word. A short, safe
    word or two describing what was actually seen travels with the label, drawn only from
    the module's own closed list of names - never a provider's raw wording, even when
    that wording happens to look harmless. The label clears by itself the moment the
    seat's next turn makes real progress.

    What you will notice: agenttalk status and agenttalk supervisor now agree, and
    both say rate_limited(usage_limit window=five_hour), rate_limited(throttled) or
    rate_limited(overloaded) next to a seat's health, instead of just an unexplained
    "rate limited or outage". That flag is silent when the supervisor cannot confirm the
    seat healthy (shown down or unreachable instead), rather than repeating the seat's own
    overridden self-report as if it were still true. Both web consoles show "Usage limit",
    "Throttled" or "Overloaded" with their own wording and color, never the misleading
    "is capped" (with an allowance reset time that cause never supported) a throttle or an
    overload used to get - at any usage level, including a window that happens to read as
    fully used for an unrelated reason. Neither console shows a guessed recovery time
    beside the cause any more, for any reason: an allowance snapshot cannot be reliably
    tied to the exact moment a turn failed, so a seat's health now names only the cause
    (a usage limit and its window, a throttle, an overload, or the older generic wording)
    and never a "resets at" time derived from it: a capped seat's own card no longer shows
    a reset time at all, though each allowance window's own reading - its percentage, and
    its own reset time - stays exactly where it already was, in the usage windows panel;
    that is a fact about the window, not a promise about the seat, and is unaffected. A
    seat that is genuinely parked still shows its own wake time on its park card, exactly
    as before - that comes from the provider's own rejection, not from a capacity reading,
    and is unaffected too. A seat whose
    error text only happens to contain "generate", "iterate" or an unrelated phrase like
    "corporate limit exceeded" is never mislabeled this way. An unrecognized reason name
    (including one that happens to collide with a built-in JavaScript property name)
    always falls back to plain, generic wording, never to broken or missing text. Older
    health records with none of this detail still read exactly as before.

    What you need to do: nothing.

    Technical details: src/agenttalk/wrapper/health.py's WrapperHealthWriter.event
    classifies a retryable ADAPTER_ERROR through _classify_adapter_error: the SAME
    structured proof the usage-limit park decision (#311) already trusts for a rejected
    rate_limit_event naming a known window (usage_park.usage_limit_rejected_window,
    never a second parser), then a narrow, word-bounded whole-phrase text match
    (\bphrase\b, never a bare substring), then the existing unclassified reason. That
    usage-limit proof, once seen, is carried on the writer instance for the rest of the
    turn, but only ever refines an ELIGIBLE PROVIDER failure - the terminal
    classification must already say so (known_global_infra), AND no local cause (a
    fired watchdog, a config-blocked turn, a failed bus write, a held gateway, a setup
    failure) may have actually decided the turn's fate instead; any of those still wins
    outright, exactly as before this feature existed (usage_park.local_cause_present,
    shared with the usage-limit park feature's own identical eligibility gate). It also
    only wins once revalidated against the complete current stream
    (sig["usage_stream"], the same running fold usage_park.note_stream_event builds
    turn-long and the park decision's own fact_from_stream already reads): a later
    terminal result whose is_error comes back exactly False vetoes it, and an
    incomplete stream (no terminal result read at all) decides nothing either way. A
    terminal HTTP 429/rate_limit_error or
    529/overloaded_error is classified separately in classify_failure's
    _infra_reason, reading the wrapper loop's own already-extracted
    sig["structured_errors"] facts (no second parser); its detail is built only from the
    fixed status/subtype constants the reason already matched against, never the raw
    provider value, even when that value is itself token-shaped. wrapper/run.py's
    _structured_infra_summary (the upstream classification boundary) now also recognizes
    these two exact subtypes on their own, with no numeric HTTP status required, reusing
    the same usage_park.SUBTYPE_THROTTLED/SUBTYPE_OVERLOADED constants rather than a
    second vocabulary - previously such a result fell through every branch to the
    unhelpful generic errored_ambiguous/ambiguous_failure. note_stream_event is now
    folded regardless of whether the separate usage-limit-park feature is on, since
    health's own retained-evidence validation needs it either way; the park feature's own
    decision of whether to actually surface a park is unaffected. The health schema
    (src/agenttalk/health.py) gains one new optional field, reason_detail, validated
    through the exact same closed-vocabulary rule as reason_code (safe_token, renamed
    from a private helper so both modules share it) - never free text, and simply absent
    on an older record. supervisor.py's _assessment_health now forwards reason_detail
    into its own projection, so cli.py's shared _rate_limit_reason_flag (shown by both
    status and supervisor, and suppressed by both under the same rule
    _health_column already uses for an unconfirmed-healthy seat) shows the identical
    window on both commands.

    Three full rounds tried to give the consoles a seat-wide recovery time derived from
    capacity snapshots, and each round found a new way it could be wrong: an allowance
    reset shown for a plain provider hiccup; a weekly limit borrowing the five-hour
    window's reset; a five-hour reset shown while an ALSO-exhausted weekly window still
    blocked the seat; a missing or unknown-utilization window dropping silently out of the
    calculation; a capacity reading taken before the failure, or from a different
    provider, accepted as if it proved anything about this one. A capacity snapshot is
    read on its own cadence, independent of any one failed turn, and cannot be reliably
    tied back to the exact moment and provider a rejection happened - so this release does
    not attempt it at all: a seat's health names the cause only (a usage limit and its
    window, a throttle, an overload, or the existing generic wording) and never a derived
    recovery time. A seat that is genuinely parked still shows its own wake time on its
    park card, unchanged - that time comes from the usage-limit park marker, built from
    the provider's own rejection, never from a capacity reading, and that path is not
    touched by any of this. Separately, an unrecognized reason name that collides with a
    built-in JavaScript property name (constructor, toString, ...) no longer finds that
    inherited value instead of falling back to the generic wording (a plain-object lookup
    now checked with hasOwn first).

    Tests in tests/test_wrapper_health_rate_limit.py (ordinary words give no reason, the
    structured usage-limit/throttled/overloaded reasons, the narrowed whole-word fallback
    beside its near-misses, an unrecognized subtype never reaches the stored detail, the
    usage-limit proof survives a later terminal error in the same turn but not into the
    next turn nor past a later genuine success or an incomplete stream, the SAME local-cause
    veto the usage-limit park feature uses (a fired watchdog, a config-blocked turn, a
    failed bus write, a held gateway, a setup failure) also vetoes this retained evidence,
    and only an eligible provider failure is ever refined, the excerpt carries no private
    text, the label clears on the next success, an older record with no reason_detail
    still reads), tests/test_usage_park_drive.py (a terminal result naming the subtype
    alone, with no numeric status, reaches the right reason through the real adapter and
    drive; a later success after an earlier rejection no longer leaves the health record
    claiming the limit), tests/test_usage_park_supervisor_command.py (the window detail
    reaches the supervisor projection; the flag is suppressed when the supervisor cannot
    confirm the seat healthy, on both commands), tests/test_usage_park_rules.py (the
    provider-rejection proof still combines every exhausted window and takes the latest
    reset, including the unknown-reset veto), tests/console2_view.test.mjs (every reason -
    including usage_limit_rejected - shows no recovery time, ever, even if a stray
    recovery field is still present on the data; throttled/overloaded get their own
    state/title; an unrecognized reason - including one that collides with an inherited
    JavaScript property - falls back cleanly; the "also happening" list still carries a
    throttled/overloaded row under its own state) and tests/console_usage_park.test.mjs
    (the park card's own wake time is unaffected).

  • A seat that is only out of its usage allowance no longer loses its working
    context (#317).
    When a wrapped Claude seat's account ran out of allowance,
    every refused retry was counted the same as a sign that the seat's CLI
    session itself had gone bad. After two such refusals in a row the wrapper
    gave up trying to continue that session and started a brand new one - even
    though nothing was actually wrong with it, and even on the quiet periodic
    check-ins a seat runs between messages. Seen on a second team's account
    overnight: a weekly limit refused five seats for about two hours, and at
    least one of them lost its session as a result. Running out of allowance
    says nothing about whether a session still works.

    What you will notice: before this, a seat stuck behind a usage limit could
    start a fresh session and lose whatever it had been working on, even though
    the limit was the only problem. Now, a refusal the seat's own response
    proves was a usage limit (the same proof the "park instead of retry"
    behaviour above uses) never counts toward giving up on the session - whether
    or not that park-instead-of-retry behaviour is turned on. A genuinely broken
    session is still detected and recovered from exactly as before.

    What you need to do: nothing.

    Technical details: resume_failure_is_session_attributable in
    src/agenttalk/wrapper/session.py takes a new usage_limit_proven flag,
    checked before the existing class/text checks (a resume refused while an
    account is over its limit can make the provider's own wording read exactly
    like a broken session, so the text alone is not trustworthy evidence here).
    src/agenttalk/wrapper/run.py computes this flag from
    usage_park.fact_from_stream at both places that decide whether to give up
    on a session (the normal per-message turn and the lead-loop's own periodic
    turn, which never tracked this evidence at all); the per-message turn's own
    tracking is no longer gated behind the park switch, since this evidence must
    exist whether or not parking itself is enabled. Tests in
    tests/test_wrapper_loop.py replay a real captured usage-limit refusal
    (including a variant whose wording is deliberately adversarial - written to
    also look like a broken session) through five refused attempts on both
    turn kinds and both switch positions, and confirm the session id, resume
    state and failure count never move; the existing tests proving a genuinely
    broken session still gives up after two failures are unchanged.

  • A rare, one-off test failure has a confirmed, reproducible source (#313).
    One test that compares what the wrapper leaves on disk after a launch
    failure once reported a file as different from what an earlier version of
    the code had left. A first look at this guessed a machine timing issue; a
    second, more careful look - and an independent cold review - instead
    confirmed a reproducible defect in the test itself: one of that file's
    values is a random ID generated fresh on every run, and the test's own way
    of hiding that randomness before comparing missed one specific shape that
    ID can take (purely digits, no letters at all, which happens about once
    every 280 runs) - a correctly-written file could still be reported as
    wrong, for a reason that had nothing to do with timing. What exactly
    happened on the one historical run that first reported this is not, and
    cannot now be, proven (its own captured contents were not kept) - what is
    proven is that this defect reliably produces that exact same failure
    shape, on demand, on every supported Python version.

    What you will notice: nothing in the product itself in ordinary use - the
    random ID is now always recognised and hidden before comparing, whatever
    digits and letters it happens to contain, but only when it is actually
    well-formed; a corrupted or missing value of that same field still
    reliably fails the comparison, now however that corruption actually
    reaches the file - not only when it arrives through the one function that
    validates its own input. One internal safeguard
    did change: the function that first records a fresh attempt now refuses a
    malformed ID outright rather than writing it, so a bad value can never
    reach disk in the first place - this can only be reached by deliberately
    forcing a bad value in a test; the one real caller always generates a
    well-formed one.

    What you need to do: nothing.

    Technical details: tests/golden_off_scenarios.py (_decoded: the
    last_attempt_id field is now masked by its KEY, the same way the
    existing agenttalk_version field already is, instead of relying on a
    regex that required at least one letter in the matched text - and only
    when the value fully matches the one real shape this field can take,
    exactly twelve lowercase hex characters, so an empty, short or otherwise
    malformed value is never masked away). A first version of this fix (now
    removed, along with its own test) added a fixed pause before reading files
    back, on the theory that the file was taking a moment to become visible; a
    cold read (codex-agenttalk-reviewer-1) showed that theory was not
    supported - the comparison function reported the same text for "file
    missing" and "file present but different," so the original evidence could
    not actually tell the two apart - and reproduced the digit-only-ID defect
    directly, on both Python versions, every time. A delta read on the first
    fix for this defect then found it had its own gap (every string under that
    key was masked, not only a well-formed one), closed in this same change.
    The comparison function now says "missing," "unexpected," or "contents
    differ" instead of one ambiguous phrase for all three, in
    tests/test_turn_events_off_golden.py (_differences). Tests there cover
    a well-formed all-digit ID, a mixed one and an all-zero one, an empty,
    short and non-hex (malformed) one, and confirm a genuinely missing record
    or an altered retry count still correctly fail.

    A final delta read (codex-agenttalk-reviewer-1) found one more case the
    format check alone could not close: a value that is literally this
    module's own placeholder text is, by definition, not well-formed, so it is
    left unmasked just like any other malformed value - and an unmasked value
    that happens to BE the placeholder text then reads identically to a
    genuinely valid, masked one, so that one specific corruption still passed
    as unchanged. No real run can ever produce that exact text, so this is
    recast rather than a third round on the same test file: Store's own
    function for recording a fresh attempt (record_attempt_start) now
    validates the raw ID itself, before it is ever written to disk, so a
    malformed value - including that placeholder text - can never reach the
    file this test reads, through that one function, in the first place.

    A cold read of the recast (codex-agenttalk-developer-4) found that claim
    overreached: the function above protects its own INPUT argument, a
    different boundary than the one the comparison actually reads - whatever
    ends up durably written to the file, however it got there (a serialization
    bug, a future write path that never calls this function, a hand-edited
    file). Proven by leaving a genuinely valid, already-accepted ID in place
    and corrupting only the data handed to the real write afterward, to the
    placeholder text itself: the comparison was empty again, on both Python
    versions - the exact same collision, reopened through the one boundary the
    input guard does not reach. Fixed in the test file's own comparison, which
    is what actually needed to close this: an invalid value is no longer left
    unmasked exactly as captured - it is wrapped in a marker that cannot equal
    this module's own placeholder (or any other value this module ever masks
    a valid ID into), so every invalid shape, not only this one collision,
    reliably fails the comparison no matter how it reached the file.

  • A refused work order (or any other tracked send) no longer prints
    anything that looks like proof it went out (#297).
    agenttalk task
    (and every other command that mints a correlation id - send, propose,
    reply, escalate) used to print (auto request_id: tk-...) even when
    the send was then refused for a bad work_item or stage - for example
    --meta work_item=release-0.96.0 (a dot is not allowed) or --meta stage=review (not one of the six real stages). The printed id looked
    exactly like a successful send, the refusal text that followed was easy
    to miss, and the exit code was lost the moment the output went through a
    pipe. This cost real time three times on this project's own bus.

    What you will notice: before this, a refused send could still show you an
    id to wait on or echo, with nothing sent at all. Now, a refused send
    prints no id and nothing else that could be mistaken for one - only the
    refusal, naming the value it refused and what is accepted (for
    work_item, a corrected example: work_item "release-0.96.0" is not allowed: use lowercase letters, digits and dashes, for example "release-0-96-0"). A refused value is also always shown on one line, even
    if it contains a line break or other unusual characters, so it can never
    be made to print anything that looks like a second, unrelated line of
    output. A value forced with --force past an outdated recipient is
    announced only once the send has actually happened, never before. A valid
    send still prints its id, but only once the message has actually been
    written. agenttalk broadcast to a whole group now also refuses a bad
    work_item/stage before contacting anyone, the same as every other
    send - previously it only found out partway through, after already
    committing to the attempt. An agenttalk send that warns you about an
    unrelated open decision you still owe the recipient now only ever says so
    once that send has actually gone through - a refused or failed send used
    to print that warning's own text claiming it "was still sent" regardless.
    A group-wide task dispatch past an outdated recipient, and an escalation
    that falls back to routing through the lead, likewise only announce what
    they are about to do once nothing else about the send can still refuse it,
    no matter which later check in the same command is the one that ends up
    refusing it.

    One case took a second look to get right: a fan-out to a group that fails
    to reach anyone at all (for an unrelated reason, after metadata already
    passed) used to still report a freshly made-up batch id in its
    machine-readable output, alongside an empty delivered list - indistinguishable
    from a real, resumable id. That id is now left out (null) when nothing was
    actually delivered. A fan-out that reaches at least one recipient before
    failing still reports its real id, because that one genuinely can be
    resumed with --resume. A request-restart still prints its own tracking
    id, after its own record is written; this change does not touch it.

    What you need to do: nothing, other than reading the refusal text itself
    if you see one - it now tells you exactly what to change.

    Technical details: src/agenttalk/work_tags.py (validate_field_formats,
    extracted from normalize's own per-field pass so a caller can run the
    identical check before anything else happens; value's work_item/
    stage messages now name the refused value and, for work_item, a
    corrected example via the new _work_item_suggestion; the refused value
    itself is now rendered through the new _quoted (JSON-string encoding),
    so an embedded newline or control character can never split the
    diagnostic, or anything else, into more than one line). src/agenttalk/ cli.py (_refuse_bad_work_tags runs this check, and returns exit 2 with
    no output, before a request id is ever minted, in send, propose,
    task, reply, escalate and broadcast; _maybe_autogen_request_id
    no longer prints - it only mints, returning the id for the caller to
    print via the new _print_autogen_request_id).

    This last round replaced per-command reordering of the four commands that
    had a notice or an id at risk with shared handling for those four:
    _after_durable_write(written, id_value=None, *notices) prints each
    notice and returns the id unchanged only when its caller tells it written
    is True (at least one copy of THIS send actually reached durable storage),
    else prints nothing and returns None. This is shared code for a
    recurring shape, not a boundary the function enforces by itself - it
    trusts the boolean it is given; each of the four callers computes that
    boolean from its own real outcome (did the write raise, is the fan-out's
    delivered list non-empty) before calling it. Applied to: send (the
    owed-decision notice), task (the --force compatibility notice),
    broadcast (the --force compatibility notice and the batch id, for both
    a fresh send and --resume), and escalate (the no-liaison
    fallback-routing notice and the request_id line, now covering a LATER
    refusal - --origin-request without --origin-id - that a round-2 fix
    narrower than this rule had missed). Every other send-type command
    (propose, reply, relay's two subcommands, composing, progress,
    rescind, release, end) never calls this helper at all - each already
    only reaches its id/notice prints through plain sequential code order
    after its own write, a different and already-sufficient guarantee: a write
    that fails propagates out and skips them, with nothing to gate.

    Tests in tests/test_cli.py and tests/test_work_tags.py cover a dotted
    work_item and an unknown stage each refusing with no output and no
    message written, across multiple commands including broadcast; a
    rejected value containing a newline proven unable to forge a second line
    of output; the --force and fallback-routing advisories proven absent
    from a refused send, including the later-refusal case above; a zero-delivery
    broadcast's JSON manifest proven to carry no id while a partial-delivery
    one keeps its real, resumable id; and one matrix test exercising a clean
    send, a format refusal, and an injected write failure across every
    send-type command this PR covers (question, review-request, wake,
    propose, task, reply, escalate, relay operator-command),
    confirming none of them can print an id ahead of a durable write. Every
    new regression test was shown failing against the prior code before its
    fix, then passing after.

    A cold read (codex-agenttalk-reviewer-1) confirmed the behavior above is
    correct in every path it probed, and found two things worth fixing on their
    own: this entry (and the code comment on _after_durable_write) had
    overstated what that shared function guarantees - corrected above to
    describe it as shared handling for the four commands that call it, not a
    boundary every send-type command passes through; and the matrix test's own
    success check only confirmed a non-error exit code and a stored-message
    count, not that the command actually printed the REAL id of the message it
    wrote - disabling every print call still passed all 24 cases. Both fixed:
    the success check now asserts the exact, real id against the stored
    message, in both plain output and --quiet (the two commands that always
    print a machine-readable correlation line - escalate, relay operator-command - keep doing so under --quiet; the rest suppress their
    bracketed advisory id line, as documented). The task --force help text
    is also corrected: it now says the notice follows a successful send,
    matching what the code has done since the previous round.