Repository navigation
fix(tune-out): a channel's cancel makes its dump durable before it records the end - #909
Merged
nissa-seru merged 4 commits intoOct 30, 2026
Conversation
added 4 commits
October 29, 2026 03:32
…cords the end TuneOutCoordinator.cancel() recorded the tune-out's end first (ChannelRegistry.cancelTuneOut appends it to the lifecycle log at once) and only then read, rendered and wrote the backlog dump. A cancel that failed between the two, because a write threw or the process died, left the tune-out ended with no dump. Its held rows keep their tuneOut stamp, which every resident's view filter leaves out for good, so nothing delivered them: after a restart no tune-out is on record to dump, and the boot's restore of deferred writes covers only a dump already queued. cancel() now reads the tune-out's state, writes the dump, makes it durable, and only then records the end, the order cancelAllExcept() already used. A failure before the end record leaves the tune-out in force, and a later cancel, in this process or after a restart, dumps the stored held rows no earlier dump covered. Each dump row records, in its metadata (`released`), the held rows it covered, those its cap left out among them: rows that came through the deferred queue by the queued write's id, which the stored row keeps as deferredWriteId once it lands, and other stored rows by id. It also records how many it covered, how many it left out, and which rows it left for the subconscious's report. A cancel reads this epoch's earlier dump rows from the stored and queued messages it already scans, and dumps only the held rows none of them covered. It writes no dump when an earlier one covered every held row. A dump the store keeps in shards carries the same record on each shard, and counts once. Both cancels use this rule, and it replaces the in-memory mark that kept the all-except release from being written twice in one process. That mark lived apart from the delivery, so a dump that was stored before its commit threw, or taken away by a rollback, or written before a restart, could disagree with it. The row can't, so a retry shows no held message twice, after a restart too, and the restart's second dump that the old all-except order accepted is gone. After a retry, a channel's report to the subconscious says what each dump of the epoch left out, each dump's own oldest, and names the dump when only one was cut. The framework's commitDeliveries() now says whether the release is durable: false when the deferred queue's recovery write fails or is over its cap, or the store's sync fails. Both were only logged before. Both cancels record the end only on true, and otherwise throw, leaving the tune-out or hold in force for the next cancel. Every cancel a timer makes is tried again when it throws. The end timers already armed their retry first; the cancel of a tune-out past its wake budget now does too, in the channel's expiry slot, so a quiet tune-out with no end time still ends. At the start, a tune-out or hold that reached its end time while the process was down and whose cancel throws stays in force as one does at runtime, its cadence running and its retry armed, one stderr line naming it, and the start goes on to arm the others; before, the throw ended the framework's start. Whether it is still in force is read from the registry, so one whose cancel ended it and then failed a later step counts as ended: no retry, and it doesn't anchor the subconscious. A tune-out found past its wake budget is cancelled at the start, since its retry is a timer a restart loses. A tool close of the excepted channel whose release fails or is refused answers "The close wasn't recorded", with the release's reason: a release that failed before the hold's end was recorded leaves the hold in force, and one that failed after leaves it ended. docs/dendrite.md says the same of a hold whose release fails at the start: before its end, it stays in force and is tried again a minute later; after, it has ended, with no retry. A tune-out past its wake budget whose cancel fails at the start says so on stderr as the start's, with no timer-failed trace, since no timer fired; one whose cancel ended it and then failed a later step says it ended. The reorder opened a window in which a hook called during the dump could start a second cancel of the same channel. A second cancel of the same channel, or of the all-except hold, made while one is running is now refused, and a cancel records the end only if the registry still holds the epoch it read. The channel's timers and its count of messages diverted since the reader's last look are cleared once the end is recorded, so a cancel that fails leaves them running. The doc comments of cancel(), cancelAllExcept() and both commitDeliveries are removed rather than rewritten: they documented internals, and this message carries what they said. Four test files' stand-in registries said no channel was tuned out while their cancelTuneOut ended one. cancel() now reads the tune-out's state first, so each stand-in answers it for the channel being cancelled until that channel's end, as the registry does. The end-time test counted the registry's cancelTuneOut calls to show a retry doesn't happen; cancel() no longer calls it for a tune-out already ended, so the test counts the coordinator's cancel attempts instead. The test of an end time whose cancel throws after the registry ended the tune-out made the dump throw, which now comes before the end; it now makes the resident's wake throw, which comes after it, and checks the tune-out ended. all-except-unit's harness now stores the release rows its addMessage is given, as the framework does, and answers commitDeliveries. Evidence, built, on the published packages, each phase swapping only src/tune-out/coordinator.ts, over the 44 test files whose source names TuneOutCoordinator, tune_out, tuneOut, cancelTuneOut or all-except: - with the coordinator as it was, 1031 of 1053 tests pass. The 22 that fail are the channel tests for a cancel after a failed dump (after a restart, and in the same process), a retry after a written dump whose end record failed (in the same process, with a message held since, after a restart, after a rollback, and with a queued row that lands), a dump that couldn't be made durable, a cancel made during its own dump, the report after a retry, the five tests of the start (an expired tune-out that can't be made durable, one that fails after its end, one past its wake budget, and one past its budget whose cancel fails there, before its end and after), and the wake budget's retry; and all-except-unit's tests for the start's failed release, a message held while the clear keeps failing, a restart between the release and a failed clear, the release record's counts, a cancel made during the release, and a release that couldn't be made durable; - at this commit, 1053 of 1053 pass.
…onscious's report sums what the dumps left out A cancel refused a second cancel of the same channel, or of the all-except hold, made while one was running, and recorded the end only if the registry still held the epoch it read. Nothing reaches a cancel while one runs. A cancel is synchronous, and its callers are an agent's tune_out or focus call, a tool close of the excepted channel, the coordinator's own expiry and wake-budget timers, the start, and the subconscious's judgment. None is reached from what a dump calls: the store write, the durability commit, the room and history-tool reads and the row reads. The one way left is a host calling the public executeToolCall synchronously from a trace listener during a dump, since handleTuneOutTool has no await before the cancel; connectome-host never calls it. So the refusal, its two wrappers, the epoch checks before the end and their two tests go. The cancel report the subconscious gets, or the last reader fork, said for each dump how many of its held messages it left out. Each dump's own text already says that, for a reader that can see it. A dump still queued behind the resident's turn is in no reader's view yet, and its number in a per-dump count wouldn't let a reader find it either, so the report gives the sum in one sentence. The fragment drops its sentence on the refusal, and now says that a tune-out whose queue of writes waiting to be stored is over its cap stays in force past its end time, its cancel tried again every minute, until the queue is back under it. The rollback test's title now says what its removal stands for: a rollback, an undo of the turn or a surgery, each of which can take a stored dump row away. With the parent's coordinator, the two changed test files fail only the report test, on the summed wording.
…g cancels again, and the summed report counts a dump that left nothing out The two tests of a start whose cancel ended a tune-out and then failed a later step, one past its end time and one past its wake budget, counted the registry's ends or the dumps. A retry of a tune-out that has ended returns at cancel()'s "not tuned out" before it ends or dumps anything, so neither count could see one. Both now count the coordinator's cancel() calls, its timers' included, through ten minutes, and the end time's test also checks the line that says the cancel ended it. The all-except hold's start had no test for a release that ended the hold and then failed a later step. It has one now: the hold is ended, nothing anchors the subconscious, the line says the release ended it, and nothing releases it again. The unit harness's wake can fail for it, and it records the anchors the start sets. The summed report's test had both dumps leave messages out, so a sum of the dumps that left something out would have passed it. Its second dump now holds two messages under a cap of two, so the report reads "1 of 5 diverted messages across 2 dumps".
…etry Its doc named only an end time's retry. The wake budget's cancel, at a wake or at the start, is tried again after the same interval.
1 task done
tel-0s
added a commit
that referenced
this pull request
Oct 30, 2026
…p durable before its end, gate texts as written, awaited calls routed as the turn's, a stop that waits for a start main at 48f26ce is four merges past d700dce, with no release among them, so package.json stays 0.30.0 and this branch's floors stand (chronicle ^0.6.0, context-manager ^0.25.0, membrane ^0.8.0), which test/dependency-ranges pins. It brings #910 (the gate's refusals and file errors name fields, rules and gate.json's path as written, and a change says it is saved only when it brings gate.json into force), #909 (a channel's tune-out cancel stores its dump and makes it durable before it records the end; each dump row records the held rows it covered (`released`), so a retried cancel shows nothing twice; commitDeliveries answers whether the release is durable; every timer's cancel is tried again when it throws, at the start too, and a tune-out past its wake budget is cancelled at the start; a tool close of the excepted channel whose release fails answers "The close wasn't recorded"), #913 (the turn's dispatch and an awaited tool call take one route per tool, toolRoute; the puppet refuses skip_reply) and #912 (a module's stop() waits for its start() to settle; addModule() refused once a stop() is admitted, after the add). Three paths conflicted (17 regions): - src/tune-out/coordinator.ts (12), the #909-into-#915 port. This branch has no deferred queue (#291), and its cancel waits while the framework keeps writes in memory (writesHeld: a surgery's hold, a manager refusing its work; deferCancel / runDeferredCancels, answered `deferred`, the tools' `waiting` + `note`). #909's rule goes into that cancel as it stands: - cancel(): reads the tune-out's state (getTuneOutState) first, and answers "not tuned out" without it; then, writes held, waits as before, nothing dumped or ended. Past the wait: the backlog is the epoch's rows (residentMessages: stored, then a surgery's kept writes where writesHeld threw) less those an earlier dump of the epoch covered (releaseRecordsOf over the same rows); the dump is written with its `released` record, and none when an earlier dump covered every row; commitDeliveries() === false throws, the tune-out in force; only then cancelTuneOut records the end, and the channel's timers and diverted count are cleared. The subconscious's report and the last reader fork's notice sum what the epoch's dumps left out (truncationNote). Main's heldForReport (queued rows handed to the subconscious as tune-out-cancel-held rows) stays out, as on this branch: every held message is stored, and the report keeps its unseenBySubconscious count; the record's `report` is always empty here, kept so the record has the shape main reads. - cancelAllExcept(): the same, over the hold's rows; main's `released` record replaces this branch's in-memory scopeReleased mark, and commitDeliveries() === false throws before writeScope(null). - Rows are keyed by id, and by `deferredWriteId` (writeIdOf, kept): a row an earlier version queued, stored by the boot's import, is named by its queued write's id in that version's records, so a cancel retried after an upgrade finds it covered. A surgery's kept write has no id, so a dump made during the hold (writesHeld having thrown) can't name it; a retry of that dump dumps it again. - resumeActiveEpochs(): main's try/catch around the start's cancels, their retries (armExpiry's and armScopeTimers' EXPIRY_RETRY_MS) and the start's wake-budget cancel (cancelPastBudget); main's `inForce`/oldestAnchor bookkeeping goes, since this branch anchors from the tune-outs the record holds after the cancels (pruneWindowStarts, anchorSubconscious('resume')), which leaves out one whose cancel ended it and counts one whose cancel waits. - The hooks' getDeferredMessages stays out (this branch's getSurgeryHeldMessages); commitDeliveries is `() => boolean`, its doc kept and rewritten. cancel()'s and cancelAllExcept()'s doc comments, which main removed, stay, rewritten to the new order: they carry this branch's `deferred` answer. - src/framework.ts (4): - addModule(): main's refuseOnceStopped('addModule()') after the add, with this branch's comment on useHistory (its check leaves out an undone row). - the deferred queue's persistence (persistDeferredWrites and the queue's record): stays deleted, as on this branch; main's change there was persistDeferredWrites' boolean. - onToolClose: main's form (a release that throws or refuses answers "The close wasn't recorded: …"), with this branch's sentences for a release that waits and one that went through ("The focus on it ended, …", 6243858). - commitDeliveries(): answers false when the store's sync fails and true otherwise; there is no queue to persist. - src/gate/event-gate.ts (1): a pending batch whose delivery throws: main's `Rule "<name>"` opening, with this branch's deliveredInPart for a batch that woke some recipients before it threw. Read onto this branch past the conflicts: - docs/dendrite.md: a cancel stores and syncs its release before it records the end; one whose release can't be made durable throws, the tune-out in force, and the next cancel delivers what no earlier release covered (`released`). Main's sentence on the all-except hold's start came in clean. - test/all-except-unit: main's tests take this branch's harness ({ stored, kept, writesHeld }); its anchors hook reads the start's sequence (setSubconsciousAnchor takes a SubconsciousWindowStart here). - test/tune-out-cancel-order: no getDeferredMessages hook; the anchors hook as above. Main's "a message queued at a dump whose end record failed, which lands before the retry" has no queue to stand on here; it becomes the upgrade case: a dump an earlier version wrote, naming a row by its queued write's id, and that row stored by the import with deferredWriteId, gets no second dump. storeSees: no new site; main opens and derives no context manager. agentSees: no new view decision. #913's awaited routes run the turn's handlers with a `deliver` that answers the caller; none stores a row of its own (the puppet's pair is stored as before, its agent's own row). Notice tags: none new; #910's texts are tool results and stderr lines, #909's are stderr lines and errors thrown to a tool or timer. Changelog: no release in main's range, so no fragment was consumed; main's seven new fragments come in as written. 291-deferral-queue's tune-out hosts' bullet says that with no queue a release fails to be made durable only where the store's sync fails, so no queue over its cap keeps a tune-out in force (tune-out-cancel-order's sentence), and how a dump's record names a row. Tests: built with zero errors; no stale compiled test; fixtures copied. main's 16 changed test files: 419 of 419. presentation*, window-*, dendrite-*, stored-injections, audience-stamp*, dependency-ranges, held-*, tune-out-*, admission-e2e, all-except*, mcpl-coalescing*, operator-surgery, read-marks* and turn-inputs-pin (53 files): 964 of 964. With this branch's coordinator as it was (HEAD's), and the rest of the merge, tune-out-cancel-order, all-except-unit, tune-out-timers and tune-out-gate-composition fail 21 of 77; at this merge, 77 of 77. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 tasks
1 task done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A tuned-out channel's cancel now writes its backlog dump and makes it durable before it records the tune-out's end, so a store write that fails during the cancel no longer leaves the held messages with no dump and no tune-out on record to dump them. On
main, the cancel records the end first. In a run at4a45766f01with the dump's write made to throw, no dump was written, and after a restart on the same store no tune-out was on record and nothing was dumped. With this change, a failure before the end record leaves the tune-out in force, and a later cancel, in the same process or after a restart, dumps the held messages that no earlier dump covered, each once. The all-except hold's release already wrote its dump before its end, and it now keeps the same record of what each dump covered. A cancel whose dump can't be made durable now throws, and "What changes for callers" below lists that and the other differences a caller meets. At this head's tree, all 1,052 tests in the 44 tune-out test files pass, and withmain'ssrc/tune-out/coordinator.ts21 of them fail.Problem
At
4a45766f01,TuneOutCoordinator.cancel()insrc/tune-out/coordinator.tscallsthis.channelRegistry.cancelTuneOut(serverId, channelId, nextState, source)at:621, and writes the dump withthis.hooks.addMessage(at:729. After that call, at:734, the comment "The epoch is already ended durably; the release must be as durable." sits abovethis.hooks.commitDeliveries?.();(:735).A test run at a clean checkout of
4a45766f01, with one test file added, shows what that order does when the dump's write fails. It enters a tune-out on aChannelRegistryover a chronicleJsStore, gives the coordinator three stored messages held under it, and cancels. It then restarts by building a second registry and coordinator over the same store and callingresumeActiveEpochs(). Its output:In the PROBE case, the dump's
addMessagethrows. The cancel threw, no dump was written, and after the restart no tune-out was on record and the restart wrote no dump. So after one failed write, nothing in that run dumped the three held messages. The test file is under "The run atmain" below.FableDendrite's review of #844 named this order: "One older ordering risk, also main's, is noted in the commit:
cancel()appends the end record before the dump." (comment)Changes
Line numbers in this section are this head's.
The channel's cancel reads the tune-out's state (
src/tune-out/coordinator.ts:698), writes the dump (:802), callscommitDeliveries()(:809), and only then callscancelTuneOut()(:812).cancelAllExcept()already wrote its dump (:1092at4a45766f01) before it cleared the hold (:1104there). WhencommitDeliveries()returnsfalse, the cancel throws "the backlog of … could not be made durable, so its tune-out stays in force" (:810) and records no end.commitDeliveries()insrc/framework.ts(:36727) now returns a boolean. Itsstore.sync()catch returnsfalse, and otherwise it returns whatpersistDeferredWrites()(:9415) returned when it ran:falsefrom its catch and for'over-cap',trueotherwise. At4a45766f01it iscommitDeliveries(): void(:36737), and itsstore.sync()catch isconsole.error('[tune-out] could not sync the store after a release:', err);(:36745).Each dump records what it covered, in a metadata field,
released, aReleaseRecord(:103to:112): the ids and deferred write ids of the held messages it covered, those its cap left out among them, withtotal,truncated, and the keys of those it left for the subconscious's report. A cancel reads the epoch's earlier records from the stored and queued messages and leaves out the held messages they cover. It writes a dump onlyif (earlier.length === 0 || backlog.length > 0)(:800). Both cancels use this record. It replacesscopeReleased, whose comment at4a45766f01reads "The epoch whose release this process delivered and whose hold is not yet recorded as cleared." Tests intest/tune-out-cancel-order.test.ts:test/all-except-unit.test.tshas the hold's counterparts, among them "after a restart between a written release and a failed clear, the next release shows nothing a second time and clears the hold" and "the release's record says how many held messages it covered and how many its dumps left out".A wake past the wake budget whose cancel throws now has its cancel tried again a minute later (
test/tune-out-timers.test.ts: "a wake past the budget whose cancel throws, on a quiet tune-out with no end time, is reported, and the cancel is tried again a minute later"). An end time's cancel already was, and that file's "an end time whose registry cancel throws is reported, and the cancel is tried again a minute later" passes withmain's coordinator too.At the start,
resumeActiveEpochs():all-except-unit, "a scope whose deadline passed while the process was down and whose release fails at the start stays in force, arms its retry, and the start goes on");all-except-unit, "a scope whose deadline passed while the process was down and whose release ends it at the start but then fails a later step is treated as ended, with no retry, and the start goes on");A tool close of the all-except hold's excepted channel now reads the release's outcome. In the catch around
cancelAllExcept(),src/framework.ts:31864throws`The close wasn't recorded: ${…}`with the error's message, and:31866throws the same words with the result's error when it isn'tok.docs/dendrite.md's sentence on the all-except hold at a restart now reads: "A scope whose deadline passed while the process was down is released at resume; if that release fails before the scope's end is recorded, the scope stays in force and its release is tried again a minute later; one that fails after its end leaves the scope ended, with no retry."Four test files' stand-in registries (
tune-out-cancel-held,tune-out-dump-fit,tune-out-gate-compositionandsupplied-text-in-context) now answergetTuneOutState()for the channel being cancelled until theircancelTuneOut()ends it, sincecancel()now reads the state first.What changes for callers
main's coordinator, the same cancel throws nothing ("a cancel whose dump could not be made durable leaves the tune-out in force, and its retry ends it showing each message once"). Thecancel_tuneouttool andtune_out's cancel callcancel()atsrc/tune-out/coordinator.ts:2000andsrc/framework.ts:36583.main's coordinator, that tune-out ended at the start without a durable dump: the test expected it still on record and found none.resumeActiveEpochs()throw. Withmain's coordinator,resumeActiveEpochs()threw "the wake request failed" in "a start whose expired tune-out ends but then fails a step after the end goes on, and treats it as ended".main(src/framework.ts:31871at4a45766f01), the close handler callscoordinator.cancelAllExcept('channel-close', 'the excepted channel was closed');without keeping its result, and then returns "The focus on it ended all the same, and what the other channels held was delivered."releasedin their metadata.main'scancel()builds its backlog from every stored and queued message held under the epoch (src/tune-out/coordinator.ts:636to:642at4a45766f01) and doesn't readreleased.What each part is for
Line numbers are this head's, in
src/tune-out/coordinator.tsunless another file is named.:698to:812) is the fix itself. It reads the state, writes the dump, makes it durable, and only then records the end, so a failure before the end record leaves the tune-out in force for a later cancel. The PROBE run under Problem shows whatmain's order does instead.commitDeliveries()andpersistDeferredWrites()returning whether the release is durable (src/framework.ts:36727,:9415) let both cancels record the end only once the dump is durable. At4a45766f01both returnvoid. Each cancel's check isif (this.hooks.commitDeliveries?.() === false)(:809,:1184).releasedrecord and its helpers (HeldRow,RowKeys,ReleaseRecord,keysOfandkeyedBy,:101to:131) are how a later cancel knows what an earlier dump covered. The record sits in the stored dump row, not in memory, so what a later cancel reads can't disagree with what's stored after a restart, after a rollback that takes the dump away, or after a commit that failed once the dump was stored. The tests named under Changes cover the restart, the rollback and the retry.deferredWriteId("a message queued at a dump whose end record failed, which lands before the retry, is not dumped again").totalandtruncatedlet a retry's report count what all of the epoch's dumps left out, not only the retry's own dump ("the subconscious's report after a retry sums what the dumps left out…").reportholds the keys of the messages a dump left for the subconscious's report, which a retry reads at:815so that its report can name them as well. No test retries over such a dump (Not verified).isReleaseRecord,isRowKeysandisStrings(:133to:141) are the one check of the stored field's shape.releaseRecordsOfkeeps only rows whosereleasedpasses it (:148).releaseRecordsOf's comparison of whole records (:148) counts a dump the store keeps in shards once, since each shard carries the same record ("…counting a dump stored in shards once…").truncationNote(:155) puts the report's count of what the dumps left out in one sentence, with the sum. The second commit's message gives the reason: each dump's own text already says what it left out, and a dump still queued behind the resident's turn is in no reader's view yet, so a per-dump number wouldn't let a reader find it. The test asserts "1 of 5 diverted messages across 2 dumps…".scopeReleased(:1141to:1186), removes an in-memory mark that lived apart from the delivery and could disagree with what's stored ("after a restart between a written release and a failed clear, the next release shows nothing a second time and clears the hold").truncatedInAll(:1158,:1165) makes the hold's stored record give the count of what its per-channel dumps left out, rather than0("the release's record says how many held messages it covered and how many its dumps left out").cancelPastBudget(:1420) sets the cancel's retry timer before it callscancel(), so a tune-out past its wake budget is tried again when its cancel throws ("a wake past the budget whose cancel throws, on a quiet tune-out with no end time, is reported, and the cancel is tried again a minute later"). Itsfrom: 'start'is for a start, where no timer fired, so the start doesn't report a timer's failure ("a tune-out past its wake budget whose cancel fails at the start says so as the start, stays in force, and its retry ends it").resumeActiveEpochs()'s handling of a failed cancel (:437to:484) keeps one failing epoch from ending the framework's start ("a start whose expired tune-out cannot be made durable still starts and arms the others…"). It reads from the registry whether each epoch is still in force, so an epoch whose cancel ended it and then failed a later step is treated as ended ("a start whose expired tune-out ends but then fails a step after the end goes on, and treats it as ended"; inall-except-unit, "…is treated as ended, with no retry, and the start goes on"). A tune-out already past its wake budget is cancelled at the start, since, as the first commit's message says, its retry is a timer a restart loses ("a tune-out already past its wake budget at the start is cancelled there").src/framework.ts:31864,:31866) is so the close doesn't answer "what the other channels held was delivered" when the release failed or was refused. No test closes it in that state (Not verified).clearTimers()and the reset of the diverted count now run aftercancelTuneOut()(:812to:814). The first commit's message gives the reason: "so a cancel that fails leaves them running".cancel(),cancelAllExcept()and bothcommitDeliveriesdescribed internals, and the first commit's message carries what they said.EXPIRY_RETRY_MS's doc comment (:211) now names the wake budget's retry, which uses the same interval.docs/dendrite.md's sentence says what a restart does with a hold whose release fails, and the changelog fragment says what a caller now meets.test/tune-out-cancel-order.test.ts, the new tests intest/all-except-unit.test.tsand the wake-budget test intest/tune-out-timers.test.tseach pin a part above.all-except-unit's harness now stores the release rows it's given and answerscommitDeliveries, as the framework does, and its wake can fail. The four stand-in registries answergetTuneOutState()becausecancel()now reads it first.With #844
@tel-0s, #844 (
dda847ba26) changessrc/tune-out/coordinator.tstoo. On October 29 at 17:28 UTC,git merge-treeof #844 withmainat84e0408977conflicts insrc/framework.tsand five test files. Withmainand this change merged, #844 also conflicts insrc/tune-out/coordinator.ts, and its conflict insrc/framework.tsis a different one. Whichever of the two merges second has to take the other in.With #201
@antra-tess, #201 (
484b99095d) conflicts withmainat84e0408977insrc/framework.ts,src/gate/event-gate.ts,src/mcpl/channel-registry.ts,src/tune-out/coordinator.tsandtest/tune-out-gate-composition.test.ts. Withmainand this change merged, its conflicts insrc/tune-out/coordinator.tsandtest/tune-out-gate-composition.test.tsare different ones.Of the other 45 open pull requests at 17:28 UTC, #256 and #257 have different conflicts in
src/framework.tsandsrc/tune-out/coordinator.tswithmainand this change merged than withmainalone. The other 43 merge withmainand this change as they do withmainalone.Tests
At a clean checkout of
1d5f6ba80d, a commit that isn't in this pull request's history and has this head's tree,cb19ab6fce, on Linux with Node 24.22.1 and npm 11.19.1, afternpm installwithpackage-lock.jsonandnode_modulesremoved, andnpm run build.npm ls --depth=0gives@animalabs/chronicle0.5.0,@animalabs/context-manager0.24.1,@animalabs/mcpl-core0.4.0,@animalabs/membrane0.8.0,@babel/parser7.29.12,@modelcontextprotocol/client2.4.0,@modelcontextprotocol/server2.4.0,@types/node22.20.5,@types/ws8.18.2,chokidar4.0.3,discord.js14.27.0,tsx4.23.15,typescript7.0.2 andws8.22.0.TuneOutCoordinator,tune_out,tuneOut,cancelTuneOutorall-except: 1,052 of 1,052 pass.main'ssrc/tune-out/coordinator.ts(the same file at0f11975e48, this branch's base, and at4a45766f01): 1,031 pass and 21 fail. They are all 14 tests intest/tune-out-cancel-order.test.ts, the wake-budget test intest/tune-out-timers.test.tsnamed above, and six intest/all-except-unit.test.ts: the four named above, "a message held while the clear keeps failing reaches the resident once, and every message the first release showed is shown once", and "a release that could not be made durable leaves the hold in force, and its retry clears it with the release written once".0f11975e48'ssrc/framework.tsas well: the same 21 fail.1d5f6ba80dthat also held untracked files of the run's own (its scripts, a file naming the head, and copies of0f11975e48'ssrc/tune-out/coordinator.tsandsrc/framework.ts) and apackage-lock.json, which the repository doesn't commit:npm ci,npx tsc --noEmitandnpm run buildeach exited 0. With each of the 382 test files run in its own process, eight at a time, under Node 24.22.1 with--test-force-exit, 9,232 tests ran: 9,169 pass, 62 skipped and 1 fail. The failure wastest/reply-server-routes.test.ts's "a script started in one turn and waited on in a later turn from the other server takes the later turn's channel for none of its names", which ended after 17.5 s with "timed out waiting for: the second turn started", from the test file's own 15 s wait. This change doesn't change that file. Run alone at the same head, three more times, the file passed 56 of 56 each time. That run'snpm lsgives@animalabs/chronicle0.5.0,@animalabs/context-manager0.24.1 and@animalabs/membrane0.8.0.The run at
mainAt a clean checkout of
4a45766f01, with the same platform and versions as above, the file below intest/ascancel-order-probe.test.ts, afternpm run build,node --test dist/test/cancel-order-probe.test.jspasses CONTROL and fails PROBE, printing the two lines quoted in Problem.test/cancel-order-probe.test.tsNot verified
commitDeliveries()returningfalsefrom a real failure. Every test that makes a dump non-durable does it through a stand-incommitDeliveriesthat returnsfalse. No test makes the deferred queue's write orstore.sync()fail under a cancel.cancel_tuneoutortune_out's cancel while the dump can't be made durable, so what either tool answers then is unshown.{ ok: false }.main's code over a store this change had written.4a45766f01, with a resident subconscious as the reader, that step and the ones after it hadn't come after ten minutes of mock time, and a second cancel found nothing to cancel.released. At4a45766f01, whosesrc/tune-out/coordinator.tsis 0.30.0's,cancelAllExcept()writes its release beforethis.writeScope(null);(:1104), under the comment "a restart between the two delivers the release again" (:1102to:1103). In a probe ontest/all-except-unit.test.ts's stand-in registry and store, a hold stayed on record over three held messages, a release row in 0.30.0's shape showing them, and one message held after it. After a restart, this head'scancelAllExcept()wrote one release that showed all four, the first three a second time. With0f11975e48'ssrc/tune-out/coordinator.ts, the same file as v0.30.0's, the probe printed the same line. For a channel's tune-out,cancel()at4a45766f01callscancelTuneOut((:621) before the dump'saddMessage((:729). The probe and its output are below.The upgrade probe,
test/zz-probe-old-release-row.test.tsPut it in
test/, runnpm run build, thennode --test dist/test/zz-probe-old-release-row.test.js. We ran it at clean checkouts of this head and of0f11975e48, on Linux with Node 24.22.1 and npm 11.19.1, afternpm installwithpackage-lock.jsonandnode_modulesremoved, with the packages Tests lists. Each run's one test passed and printed:changelog.d/tune-out-cancel-order.fixed.md.Hilda-2681 made the change, wrote the probes and ran the whole suite. Crispin-2406 read its first commit word for word, and Esther-2584 the other three, the second before one clause of its message was reworded. Whether each part is needed was reviewed by Gladys-2413 for an earlier version of the first commit and then for what its redesign changed, by Gordon-2414 for what changed after that, and by Esther-2584 for the first commit as it is. The second commit follows Ada-1017's rulings on Esther-2584's review. Ada-1017 settled design questions during review. Four runs of the house's internal review found issues that later rounds of the change addressed. Daniel-2612 reworded the probes' comments, test titles and messages, ran the tune-out tests and the probes at clean checkouts, and wrote this description.