Repository navigation
agenttalk 0.97.0
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,doctorand
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, includingreleaseandend.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-protectedand
--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, setAGENTTALK_STOP_RETRIES_AT_LIMIT=0(orfalse,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 onecompletedfor 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_limitfailure 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 reasonusage_limit_parked, and a
usage_limit_parkfield instatus --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
attentioncould 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
doctorfrom 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 wayattentionalready did - built from the seat's own durable
record whenever the status file is not readable, not only inattention. 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. - A momentary file-system hiccup while refreshing the marker could, on some Python versions,
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 readagenttalk 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_requestor
report_unavailable) and never a path. A readable report is not permission
to spend.What you need to do: nothing.
Technical details:
SpendLedger.reportandcheck_gateway_reportin
src/agenttalk/ovh_gateway.py,_cmd_gateway_reportin
src/agenttalk/cli.py, the contract indocs/QWEN-OVH-TRIAL.md("The
ledger report (report version 1)"), andtests/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,
ornullwhen 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_versioninagenttalk gateway statusis now2. 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_periodand thecharge_periodrules
incheck_receipt_page(src/agenttalk/ovh_gateway.py).
RECEIPT_ENVELOPE_VERSIONstays 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, withagenttalk wrap --loop --turn-eventsorAGENTTALK_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 statusand
agenttalk doctorshow 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_DIRcan 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 freshagenttalk gateway initcreates the new ledger
shape (child-cap schema 4). An existing ledger keeps working exactly as
before until you migrate it with the newagenttalk gateway binding-install
command. After that,agenttalk gateway statusshows 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_turnscolumnsquota_lease_ref_sha256,terminal_outcome,
terminal_at,terminal_source; tableschild_receiptsand
receipt_pendingwith 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_operatorhelpers the commands call (the ledger reads the front
token itself, so the command never holds it),
parse_receipt_page; new keywords onopen_child_turnand
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_seqon 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-qonly, 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.jsonand the pinned pytest contract in
src/agenttalk/dev_gate.pyadd-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.pyfails when
the dev-gate leg job sets a variable without a declared role.
AGENTTALK_AUTHORIZE_SYMLINK_DEVMODEis 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.pyneeds 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.pyloads the
module fromtests/golden/ovh_gateway_c80e1e5.py.golden, made with
git show c80e1e5:src/agenttalk/ovh_gateway.pyand pinned by its SHA-256
(62058a5c...ed32); where git history exists, a test compares it with the
commit byte for byte..gitattributeskeeps 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 unlessAGENTTALK_TEST_GATEWAY_PORTS=1is 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 ownexclusive_bind_probe, once per
session at collection), called frompytest_collection_modifyitemsin
tests/conftest.pyfor_GATEWAY_PORT_TEST_NAMES;dev_gate._base_env
forwards the variable only as1;.github/workflows/tests.ymlsets it on
the dev-gate leg job. Tests intests/test_gateway_port_guard.py,
tests/test_dev_gate.pyandtests/test_dev_gate_workflows.py. -
agenttalk gateway receiptsnow 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 printedagenttalk: 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_refusedorreceipts_unavailable) and
never a path. The otheragenttalk gatewaycommands 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-installruns 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_gatewaydispatches
receiptsbefore_get_store;_cmd_gateway_receiptsturns every exception
intoreceipts_unavailable), theinstall_child_cap_bindingdocstring, and
docs/QWEN-OVH-TRIAL.md. Tests intests/test_ovh_gateway_binding.pyrun 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_eventwraps the test's own server instance's
handle_error(the stdlibsocketservermethod that prints the
traceback) to set athreading.Eventthe 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'sbuildTeamViewmatches 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:" + nameid from, so no new key was needed. An unmatched server
item renders through the ordinaryattentionCard()path instead of being
dropped, but withoptions: [](no "Answer") and aserverOnly: true
marker;console2.js'sneedsCardrenders no Later button for it, and
isDeferred/isAnsweredtreatserverOnlyas immune to local
disposition exactly as they already treat an aggregategroupcard - 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.compareCardssorts on the sharedsource === 'stuck'field
(not the client-onlykind === '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 intests/console2_view.test.mjsand
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_eventinsrc/agenttalk/turn_events.pynow
enforcesdropped_total < seqfor every record that carries aseq
(dispatch_started,dispatch_ended,message_disposed-seq
counts from 1 anddropped_totalcounts only loss before that event,
so the event's own arrival bounds the count) anddropped_total <= last_seqfor astream_closedrecord (last_seqis how far the
numbering went).agent_versionon astream_startedrecord is now
checked against the writer's own_VERSIONgrammar (reused, not
duplicated) instead of only a length bound. Tests in
tests/test_turn_events.pymutate 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.emitnow repeats its state and closing check, assignsseq
and callsput_nowaitwhile holdingself._lock, the lockclosesets
_closingunder. 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_nowaitnever waits for room. Whenclosedeclaresclose_timeoutit
sets_deadlineto the earlier of the deadline and now._processnow
builds the record,format_timeincluded, inside its counted bad-event
step, and the module docstring states theatorder. 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
andtest_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
andtest_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
andtest_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_passedhelper 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 statusandagenttalk supervisornow agree, and
both sayrate_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'sWrapperHealthWriter.event
classifies a retryableADAPTER_ERRORthrough_classify_adapter_error: the SAME
structured proof the usage-limit park decision (#311) already trusts for a rejected
rate_limit_eventnaming 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 foldusage_park.note_stream_eventbuilds
turn-long and the park decision's ownfact_from_streamalready reads): a later
terminal result whoseis_errorcomes back exactlyFalsevetoes it, and an
incomplete stream (no terminal result read at all) decides nothing either way. A
terminal HTTP 429/rate_limit_erroror
529/overloaded_erroris classified separately inclassify_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 sameusage_park.SUBTYPE_THROTTLED/SUBTYPE_OVERLOADEDconstants rather than a
second vocabulary - previously such a result fell through every branch to the
unhelpful genericerrored_ambiguous/ambiguous_failure.note_stream_eventis 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 asreason_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_healthnow forwardsreason_detail
into its own projection, socli.py's shared_rate_limit_reason_flag(shown by both
statusandsupervisor, and suppressed by both under the same rule
_health_columnalready 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 withhasOwnfirst).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 noreason_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 -
includingusage_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) andtests/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_attributablein
src/agenttalk/wrapper/session.pytakes a newusage_limit_provenflag,
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.pycomputes this flag from
usage_park.fact_from_streamat 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.pyreplay 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_idfield is now masked by its KEY, the same way the
existingagenttalk_versionfield 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 badwork_itemorstage- 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--forcepast 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 broadcastto a whole group now also refuses a bad
work_item/stagebefore contacting anyone, the same as every other
send - previously it only found out partway through, after already
committing to the attempt. Anagenttalk sendthat 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-widetaskdispatch 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. Arequest-restartstill 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 fromnormalize's own per-field pass so a caller can run the
identical check before anything else happens;value'swork_item/
stagemessages now name the refused value and, forwork_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_tagsruns this check, and returns exit 2 with
no output, before a request id is ever minted, insend,propose,
task,reply,escalateandbroadcast;_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 itwritten
is True (at least one copy of THIS send actually reached durable storage),
else prints nothing and returnsNone. 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--forcecompatibility notice),
broadcast(the--forcecompatibility notice and the batch id, for both
a fresh send and--resume), andescalate(the no-liaison
fallback-routing notice and the request_id line, now covering a LATER
refusal ---origin-requestwithout--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.pyandtests/test_work_tags.pycover a dotted
work_itemand an unknownstageeach refusing with no output and no
message written, across multiple commands includingbroadcast; a
rejected value containing a newline proven unable to forge a second line
of output; the--forceand 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--forcehelp text
is also corrected: it now says the notice follows a successful send,
matching what the code has done since the previous round.