Conversation
The change took a `peerIncarnation_*` savepoint of its own, which nested inside whichever crank was open — the same defect the inbound message path just shed. It becomes a run queue item, carried out in a crank of its own. The handshake still gets its answer immediately: whether this is a restart is a read of what the store already says, and the transport needs it to decide whether to reset the connection. Only the writes are queued. Queued behind anything that peer has already sent, which is what keeps the two incarnations apart: the messages ahead of the change belong to the one that is ending and are recorded against it, the ones behind it to the one that is starting. So the eager discard the previous branch needed goes away — it would now throw away the new incarnation's messages rather than the old one's. Rejecting the promises the restarted remote was deciding, and resetting its in-memory state, move to `afterCommit`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-up, and the same mistake the branch below it made: `afterCommit` must not write the kernel store, and `resolvePromises` writes — promise state, reference counts, and a notify row per subscriber, all in autocommit once `endCrank` has committed. It also raced the transport's own give-up handling, which rejects the same promises from a send continuation; whichever arrived second would `Fail` out of a post-commit hook and kill the kernel. The rejections move into the crank, buffered with `immediate: false` so the notifies they produce still wait for the commit, the way a vat's syscalls do. `afterCommit` keeps only the in-memory counter reset. An incarnation change that cannot be recorded no longer kills the run loop — the same containment the inbound message path already has — and the router's exhaustiveness check comes back by excluding the item type the kernel handles itself, rather than by deleting the directive that proved it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 29dae26. Configure here.
| // it to finish the handshake and cannot wait for a crank. The writes it | ||
| // implies are queued, and ordered against anything this peer has sent. | ||
| this.#kernelQueue.acceptPeerIncarnation(peerId, observedIncarnation); | ||
| return stored !== undefined; |
There was a problem hiding this comment.
Re-dial still reports a restart
High Severity
#handleIncarnationChange still returns restart whenever the store lags the observed incarnation, including on the re-dial the transport makes after that first verdict. The transport closes the new channel and expects the next handshake to see the persisted value and proceed; that write only happens later in applyIncarnationChange, so inbound re-dials keep being rejected and outbound sends keep throwing PeerRestartedError until a later crank commits. Each of those failures is terminal on the send path.
Reviewed by Cursor Bugbot for commit 29dae26. Configure here.
| // it to finish the handshake and cannot wait for a crank. The writes it | ||
| // implies are queued, and ordered against anything this peer has sent. | ||
| this.#kernelQueue.acceptPeerIncarnation(peerId, observedIncarnation); | ||
| return stored !== undefined; |
There was a problem hiding this comment.
Give-up now precedes restart writes
High Severity
The handshake now returns restart before persistPeerRestart or the in-crank PEER_RESTARTED rejections run. The transport then throws PeerRestartedError, and the send continuation’s give-up path actually runs: it writes startSeq and rejects decider promises as CONNECTION_LOST outside the incarnation crank. Those kernel-store writes are autocommitted, and the later crank finds nothing left to reject as a peer restart.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 29dae26. Configure here.
| error, | ||
| ); | ||
| return { abort: true }; | ||
| } |
There was a problem hiding this comment.
Failed change drops the incarnation barrier
Medium Severity
A peerIncarnation item that applyIncarnationChange cannot record is shifted off #arrivedFromRemotes and then aborted with no re-queue. Messages already sitting behind that item — the new incarnation’s — are then delivered against the old seq and c-list. The handshake already returned restart, so the peer will not replay those arrivals.
Reviewed by Cursor Bugbot for commit 29dae26. Configure here.
An arrival is the one kind of work that does not go through `#enqueueRun`, so its wake is its own. Deleting either call left every test green; a parked loop with work waiting is a permanent wedge. `does not reject promises when there are none` also asserted only a negative, and passed whether or not the restart it describes had happened. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>


A peer's incarnation change took a
peerIncarnation_*savepoint of its own, which nested inside whichever crank happened to be open — the same defect #1103 just removed from the inbound message path. It becomes a run queue item, carried out by the run loop in a crank of its own.The handshake still gets its answer immediately. Whether this is a restart is a read of what the store already says, and the transport awaits that answer to decide whether to reset the connection; it cannot wait for a crank. Only the writes are queued.
Changes
RemoteManager.#handleIncarnationChangeanswers fromgetPeerIncarnationand callsKernelQueue.acceptPeerIncarnation.applyIncarnationChangedoes the writes in a crank.applyIncarnationChangere-reads the stored incarnation and returns early if it already matches, so a peer that re-dials while its first change is still waiting does not get its c-list torn down twice.Carries the incarnation half of #1079.
What review changed
afterCommitwas doing the promise rejections, andresolvePromiseswrites the kernel store — promise state, reference counts, and a notify row per subscriber, all in autocommit onceendCrankhas committed. That is exactly what the contract added in #1101 forbids, and I had made the same mistake there. Two reviewers found it independently, and one traced a second consequence: the transport's give-up handling rejects the same promises from a send continuation, so whichever arrived second wouldFailout of a post-commit hook and take the kernel with it.The rejections now happen inside the crank, buffered with
immediate: falseso the notifies they produce still wait for the commit — the mechanism a vat's own syscalls already use.afterCommitkeeps onlyfinalizePeerRestart, which is in-memory counters and nothing else.Restoring the router's exhaustiveness check also came out of review: this PR had deleted the
@ts-expect-erroronKernelRouter.deliver's default case, becausepeerIncarnationmade theneverreachable.delivernow takesExclude<RunQueueItem, RunQueueItemPeerIncarnation>, which says in the type what the kernel does at runtime and brings the check back.Testing
The handshake is answered without its writes having happened; the rejections are buffered and
finalizePeerRestartwaits for the commit; a peer's messages and its incarnation change come out in arrival order; a change queued twice is recorded once; the kernel carries out the item itself rather than routing it, and survives one it cannot record; andacceptPeerIncarnationis refused once the run loop has died. Every fix was mutation-checked.Six existing incarnation tests now go through a
handshakeAndRunCrankhelper that does what the transport and the run loop do between them.@metamask/ocap-kernelis green, as is@ocap/kernel-test'sremote-commssuite against a rebuiltdist. That suite intermittently crashes the Node worker withAssertion failed: (env) != nullptr; it reproduces onorigin/mainand is not from this change — two consecutive clean runs here.🤖 Generated with Claude Code
Note
High Risk
Changes remote peer restart bookkeeping, promise rejection timing, and message/incarnation ordering—core kernel run-loop and distributed comms correctness paths with breaking semantics.
Overview
BREAKING: Peer incarnation changes no longer run inside a nested
peerIncarnation_*savepoint during an arbitrary open crank. They become apeerIncarnationrun-queue item, delivered in its own crank likeremoteInboundmessages.The transport handshake still answers immediately from
getPeerIncarnation(restart vs not). Only the durable work is queued viaKernelQueue.acceptPeerIncarnation, ordered in the same in-memory arrival buffer behind that peer’s already-accepted inbound messages—so old-incarnation sequence numbers are recorded before the change, and new-incarnation traffic after it.discardRemoteInboundon restart is removed in favor of that ordering.RemoteManager.#handleIncarnationChangequeues the change;applyIncarnationChangeperforms persist/restart logic without the old savepoint wrapper, skips duplicate work if incarnation already matches, rejects decider promises inside the crank with buffered notifies (immediate: false), and defersfinalizePeerRestarttoafterCommit.Kernel’s run-loopdeliverhandlespeerIncarnationitself (notKernelRouter); recording failures log and return{ abort: true }instead of killing the loop.Adds
RunQueueItemPeerIncarnationand narrowsKernelRouter.deliverto exclude it. CHANGELOG documents the breaking behavior shift.Reviewed by Cursor Bugbot for commit 2e949a4. Bugbot is set up for automated code reviews on this repo. Configure here.