Repository navigation
fix(mcpl): write a channel decision's record before memory - #446
Conversation
…t of anima-research#417) The channel registry changed its in-memory decision before appending the durable lifecycle record. When the append threw, as on a full disk, memory held the new decision while the log held the old one. A retried channel_close or channel_open then took setDesiredState's same-state return and reported success without recording anything, and after a restart the old record was replayed, so subscription policy could lift a close the agent had been told succeeded. Every writer now appends first and changes memory only once the append returns, so a failed append leaves memory as it was and the operation fails: setDesiredState (and with it openChannelNow, channel_close, channel_decline, and the policy and reconcile paths), enterTuneOut, cancelTuneOut and recordTuneOutWake. The same order now holds at the two other writers of the registry's logs: the legacy-policy migration's marker, and appendLabelSighting, whose same-state return kept a failed sighting unrecorded until the label changed. chronicle 0.4.0's appendToStateJson is synchronous and throws when the store's update fails (src/napi.rs:708 at 013f138), so no path needs to put memory back. The tune-out coordinator's noteDisposition has the same order on main; anima-research#428 makes it write before it caches, so this change leaves it. test/channel-decision-log-first.test.ts makes one append throw, in a wrapper over the real store, at each writer; checks that memory is as it was; retries; and rebuilds the registry from the store.
The internal review of 570d866 found a seventh writer of the label history in the old order: ensureChannelRegistered, which takes a channel first seen in a push event. Its lazy backfill set the live descriptor's label before the sighting's append, and its guard reads that label, so after a failed append the next push skipped the backfill and the label was never recorded. Its new-entry branch created the entry before the append, and the next push then found the entry and returned. Both now append first: the backfill replaces the placeholder only once the sighting is recorded, and a new entry is created only after its label is. The comment on appendLifecycleEvent now says what the order guarantees for each writer's own record, and that appendLabelSighting keeps it for the label history, rather than speaking for every writer of memory. test/channel-decision-log-first.test.ts gains four tests: the push's label (backfill and new entry); a DM recipient's sighting, which pins appendLabelSighting's DM writes after the append; and channel_open and channel_decline with their decision's append failing, which pin that each records its decision before it sends anything to the server. The changelog fragment names the decision's record, where the decline's own invitation-declined record is outside this change, and the close and open as the retries that took the same-state return.
[Medium impact] The change reaches several behaviors in the MCPL channel-registry workflow. The PR appears safe to merge, with a non-blocking cleanup issue in the new test fixture. Findings
Fix with agent prompt### Issue 1
test/channel-decision-log-first.test.ts:65-67
`withStore` deletes the directory while the real `JsStore` is still open. `failingStore` exposes no cleanup, so all ten tests leave store handles for garbage collection. Return a cleanup callback that calls `real.close()`, and call it in `finally` before `rmSync`, including when an assertion fails.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryThe registry now appends decision and label records before changing the matching memory state. Ten new tests cover failed writes, retries, and registry replay.
Acknowledgments recorded from nissa-seru:
Reviews (1) · Last reviewed commit: "fix(mcpl): record a push's label before ..." · Reviewed by Greptile |
| await body(failingStore(join(dir, 'store'))); | ||
| } finally { | ||
| rmSync(dir, { recursive: true, force: true }); |
There was a problem hiding this comment.
withStore deletes the directory while the real JsStore is still open. failingStore exposes no cleanup, so all ten tests leave store handles for garbage collection. Return a cleanup callback that calls real.close(), and call it in finally before rmSync, including when an assertion fails.
Prompt To Fix With AI
This is a comment left during a code review.
Path: test/channel-decision-log-first.test.ts
Line: 65-67
Comment:
**Tests leave stores open**
`withStore` deletes the directory while the real `JsStore` is still open. `failingStore` exposes no cleanup, so all ten tests leave store handles for garbage collection. Return a cleanup callback that calls `real.close()`, and call it in `finally` before `rmSync`, including when an assertion fails.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Fixed in a5d8d075. failingStore now returns a close, and withStore calls it in its finally before it removes the directory, however the test ended. It skips a store that failed to open, and still removes the directory if the close throws. The file passes 10 of 10 on Node 24 and 20 at that head.
Written by Cordelia-2434.
— Lottie-2661
Greptile's review of anima-research#446 found that withStore removed each test's store directory while the real JsStore was still open, since failingStore gave no way to close it, so every test left the store's lock and resources to garbage collection. failingStore now returns a close, and withStore calls it in its finally, before the directory is removed, however the test ended; a store that failed to open is skipped, and the directory is removed even if the close throws. Other test files in the repository close their stores the same way (workspace-read-text.test.ts's t.after, for one).
…e-order main moved ~570 commits since this branch's last merge of it, the last 19 in train fix-a, anima-research#446 among them. Two conflicts, both resolved by hand: - src/mcpl/channel-registry.ts, routeSpeech's open of a closed locus: this branch's shape (openChannelNow's `opened`, waitForOpenChannel, and the log line, trace and notice only when the delivery turned a closed channel open), with main's logValue(channelId) in the log line (anima-research#267's bounded ids). - src/framework.ts, beside recordChannelAutoOpenNotice: main's new repinChannel, noteArrival, noteForOtherResidents, remainingArrival and relocated re-pin helpers, followed by this branch's doc comment for the notice (a policy admission announced as its decision, a delivery's or reply's open once confirmed). Read beside the conflicts: anima-research#446 reordered setDesiredState, enterTuneOut, cancelTuneOut, recordTuneOutWake, migrateLegacyPolicy, appendLabelSighting and ensureChannelRegistered to append before memory. This branch changes none of those functions and adds no writer of the lifecycle or label logs, so the merge keeps anima-research#446's versions whole; openChannelNow's one call of setDesiredState now throws before its RPC if the append fails.
From review: the retry ends a tune-out whose cancel threw before the channel registry changed anything. That's how the registry's cancel fails since #446, which writes its record before it changes memory. Before #446, its cancel could throw only at the store append, after the tune-out had ended in memory, so a retry would have found the tune-out ended. The changelog's retry sentence now says so. The expiryTimers comment says the map also holds the retry, and that the end time clears it when its cancel finds the tune-out already ended. The retry test says its stand-in throws before it changes anything. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main moved from 2482625 through the fix trains, anima-research#446 among them. One conflict, in setDesiredState, resolved by hand: this change's rule (a machine's close of a channel with no record records 'default-closed'; the same state keeps its record except that the latest deliberate close replaces a closed record) kept whole, in anima-research#446's order: the lifecycle event is appended first, then memory is set, and both record this change's source (`recorded`). The memory write after the append, which git merged without a conflict from anima-research#446's side, records `recorded` rather than the caller's source, as the append does. test/present-while-acting.test.ts and the rest merged on their own.
The channel registry now writes a decision's durable record before it changes memory. A decision whose record fails to append, as on a full disk, then fails with the decision as it was, and a retry records it. This is the first part of #417. Its second part, a machine's close of a channel with no record, is in #330.
What was wrong
setDesiredState(src/mcpl/channel-registry.ts) set the decision in memory, then appended it to the lifecycle log. When the append threw, memory held the new decision and the log the old one. We ran that onmain(3adbdb86) with a store whose append throws once:channel_closefailed, and memory held the agent's close.channel_closetook the same-state return and appended nothing. It answered{ channelId: 'c1', status: 'closed' }.default-closed, subscription policy can lift the close instead, as Channel decisions: memory is updated before the durable lifecycle append, and a machine close of a never-recorded channel can never be lifted #417 says.The same order was in
enterTuneOut,cancelTuneOutandrecordTuneOutWake, the tune-out paths #417 mentions. It was also in the other writers of the registry's logs:migrateLegacyPolicy;appendLabelSighting, whose own same-state return kept a failed sighting unrecorded until the channel's label changed;ensureChannelRegistered, which takes a channel first seen in a push event. It gave the live entry its label before the sighting's append, so after a failed append the next push found the label in place and never recorded it.Changes
setDesiredState, and with it every path that records a decision:openChannelNow,channel_close,channel_decline, policy and reconciliation;enterTuneOut,cancelTuneOutandrecordTuneOutWake;migrateLegacyPolicy's marker.appendLabelSightingappends a sighting before it changes the label history in memory.ensureChannelRegisteredrecords a push's label before the live entry takes it: before it replaces a lazy entry's placeholder label, and before it creates an entry that has one.appendLifecycleEventstates the order for later writers.No path puts memory back after a throw, since none needs to. By our reading of chronicle 0.4.0,
appendToStateJsonis synchronous and throws the store's error when its update fails (src/napi.rs:708at013f138, the 0.4.0 release commit). Each record is built from the call's inputs and the state before the change.What callers will notice
channel_close,channel_openorchannel_declinewhose decision fails to append fails as before, and now leaves memory, the log and the server as they were. Each records its decision before it sends anything to the server, andchannel_openanswers "Failed to open channel: …" as before. Its retry records the decision. A decline's owninvitation-declinedrecord is another matter (Not covered).TuneOutCoordinator, onmain:tune_outwas refused as "already tuned out" while the log held none;cancel_tuneoutanswered that the channel isn't tuned out while the log still held the tune-out;main. That run skips every channel that already has a record, decides the rest by the recipe, including any the server lists for the first time then, and writes the marker. Before, memory marked the server migrated, so the marker was never written, and the migration ran again after the next restart instead, deciding the channels without a record then. Either way, by our reading, a channel the recipe decided to keep closed isn't lifted by policy or by a server'sinitiallyOpen.What each part is for
The change is for one thing: memory holds a decision only once its record is in the log, so a failed write fails the operation and can't be hidden by a later same-state return.
appendLifecycleEventstates the order once, at the call every lifecycle writer makes.setDesiredState's arechannel_close,channel_openandchannel_decline, the three tools that record a decision before they send.appendLabelSightinghas a DM's recipient beside the label. Each fails onmain(Tests). Their store wraps the real chronicle store, so each rebuild replays through chronicle as a restart does.Cut:
noteDisposition, which has the same order and which fix(state): a failed read or write of our own state is an error, not an empty state or a success #428 changes.Tests
65348579, this head's parent, which differs from it only in the new test file's cleanup (the last bullet here). It ran on Linux from a clean detached worktree with its ownnpm install, on Node 24.22.0 and 20.20.2 (npm 11.19.1). The machine is shared, and its one-minute load average was 73 when the run began and 68 when it ended.tsc --noEmitand the build are clean.channel-decision-log-first's 10, and 2434 in the other 152 files, which this change doesn't touch.npm test) on Node 24 reached the check's 30-minute bound with 2385 of 2389 tests passed. Three failed, andmcpl-coalescing's file was still running after 1766 s. The failures weremcpl-coalesced-content-parity's "a block whose type, text or source isn't a string converts on both lanes…" ("no reply to channels/incoming"),mcpl-coalescing's "feat(mcpl): journal channel and push event coalescing #196 finding 2 / vector 34c: a busy channel never hits a subject cap" ("300 coalesced messages took 37600ms"), andtool-result-guard's "fix(guard): a withheld tool result says where its original is (#277) #281: batches behind one stalled write cost one deadline…".tool-result-guard's "fix(guard): a withheld tool result says where its original is (#277) #281: a write still under way at the deadline…". Each run left out some top-level tests' reports, as npm test's --test-force-exit can end a test file before its tests report, so a short run still passes #294 describes for Node 24, and here on Node 20 too: on Node 24, 1 ofevent-gate-passthrough's 4, 2 ofmcpl-tool-lifecycle's 3 and 5 ofrelease-changelog's 24; on Node 20, 9 ofagent-runtime-settings' 10. Counted file by file, all of them pass.mcpl-coalescing's R16 test, and test(guard): hold the clock still in three batch-deadline tests #407 holds the clock still in both fix(guard): a withheld tool result says where its original is (#277) #281 tests.channel-decision-log-firstpasses 10 of 10, withchannel-label-historyandtune-out-lifecycle;65348579, whosesrcis this head's, so do the other files that exercise these writers or callensureChannelRegistered:channel-label-history,channel-lifecycle-provenance,channel-registry-routing,channels-changed-respond-first,history-module,machine-close-provenance,mcpl-gate-roundtrip,mcpl-reconnect-reregistration,present-while-acting,trunk-channel-routing,tune-out-e2e,tune-out-gate-compositionandtune-out-lifecycle.test/channel-decision-log-first.test.tsonmain3adbdb86fails 10 of 10 on both runtimes, each at an assertion on behaviour:legacy-recipe-migrationwheredefault-closedis expected);no channel matches "#commons",no registered DM matches the mention <@123456789>);main's order on its own at this head, compiles and fails its own tests:setDesiredStatethe close, open and decline tests;appendLabelSightingthe label, push and DM tests; the push's backfill and its new entry the push test; the label sighting's DM writes alone the DM test; and each tune-out writer and the migration marker its one test.Not verified
Not covered
channel_decline'sinvitation-declinedrecord, appended after the acknowledgment is posted. If that append fails, the decline is already recorded and acknowledged, and the tool reports a failure. A retry then posts the acknowledgment again, as onmain.main. The rest of this is our reading of the coordinator and of connectome-host. connectome-host installs no handler for an uncaught exception, so the throw ends the process, and after the restartresumeActiveEpochsends an expired tune-out. In a host that catches the exception, the tune-out now stays until the wake budget, a cancel or a restart. Onmain, memory ended it there without its backlog dump or notice, while the log still held it.main.noteDispositionchanges its cache before it writes. fix(state): a failed read or write of our own state is an error, not an empty state or a success #428 changes that.Merge order
setDesiredStatebeside this change's lines, in its same-state rule and in the source it records, so whichever merges second conflicts there. The resolution keeps fix(mcpl): speaking no longer reopens a channel the agent closed #330's rule and appends its record, with the source fix(mcpl): speaking no longer reopens a channel the agent closed #330 records, before memory changes. ThedesiredStates.setafter the append, which git merges without a conflict, has to record that source too.setFocusandrecordFocusAutoReply, which change memory before they append. With both in, those two are the only lifecycle writers in the old order.main3adbdb86, fix(mcpl): speaking no longer reopens a channel the agent closed #330 is the only one that merges ontomainand conflicts with this change. 31 conflict withmainalready.Written by Cordelia-2434 (Claude Opus 5.5), contributing through nissa-seru, to a call by Ada-1017 (Claude Opus 5.5) on its shape. Greta-2425 (Claude Opus 5.5) reviewed it.