Repository navigation
fix(host-mode): quiesce() and resume() resolve only once the host-mode record agrees with the host - #453
Merged
nissa-seru merged 4 commits intoOct 17, 2026
Conversation
…quiesced runQuiesce set this.quiesced, quiesceReason and quiescedAt, then wrote the host-mode record with persistHostMode. When that write threw (the recovery file's, or framework/state's on a store-only config), quiesce() rejected with the host quiesced in memory while the record still said it was serving. A restart then booted serving, and a second quiesce() returned early at the already-quiesced check and resolved without writing the record. Both are shown on main, for the recovery file and for the store-only slot. The record is now written first, with the time it records, and the flag, reason, time and unabandonable list are set only once it's written. A failed write rejects with nothing changed, so context writes still land, and the next quiesce() quiesces and writes the record. resume() keeps its order: it clears the flag before its flush and the record last, as its comment chooses for a crash, so a failed write there leaves the record quiesced, which the next boot honours. Test: host-quiesce's new test makes the host-mode write fail, for the recovery file and for a store-only config. quiesce() rejects with the write's error, the host is still serving with no reason, a context write lands rather than being deferred, the next quiesce() quiesces, and a restart boots quiesced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ore-only sync fails the call After quiesce() or resume() answers, the host and its durable host-mode record now agree, or the call fails. The first commit made quiesce() write its record first; its review found two more ways the two could disagree after a call that answered: A retried resume(). resume() serves before it clears the record, so a failed clear leaves the record quiesced, the order its comment chooses for a crash. But a second resume() returned at runResume's check for a host already serving, resolved, and never cleared the record, so the next restart booted quiesced after a resume() that said it worked. hostModeRecordMayBeQuiesced now says whether the record may say quiesced: set at a quiesced boot and by runQuiesce before its write (a write that fails partway, such as a store write whose sync fails, may still reach the record), and cleared once resume() has cleared the record. On a host already serving, runResume clears the record when the flag says it may be quiesced, and otherwise answers as before, writing nothing. A failed sync without a recovery file. There the store is the record, and persistHostMode logged a failed store.sync() and returned, so quiesce() and resume() resolved before their record was durable. That sync now throws, so the call rejects. With a recovery path the file is the record, and the store's sync only narrows the window for its own writes (the legacy marker's removal), so a failed one is still logged: failing the call there would leave the file quiesced with the host serving. Text. The runQuiesce comment no longer says a failed write leaves the host serving "as the stored record says", which didn't hold after a failed resume(). persistHostMode's doc comment says where the record is, since it isn't the first key in framework/state with a recovery path. The changelog fragment covers both calls. Tests: host-quiesce's failed-quiesce test now uses a fixture whose store-only restart closes the store and opens it again, reads the record after the failure, checks that no reason or time is reported, reads the context write back, restarts before the retry, and checks the reason and time after the last restart. Two tests are new: a resume() whose clear fails, after this process's quiesce() and after a quiesced boot, for the recovery file and store-only, then a retry that clears the record and a resume() on the serving host that writes nothing, as it writes nothing after a resume() that cleared the record; and the store-only sync failing during quiesce() and resume(), with the recovery file's failed sync logged while the quiesce holds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… an in-flight resume() The fix round's review found two things the round's new clear in runResume did that the clear on resume()'s quiesced path doesn't: A surgery. The clear on a serving host whose record may say quiesced wrote the store (store-only: framework/state and a sync; with a recovery file: the file, the legacy marker's removal and a sync) without refuseResumeUnderSurgery, which the quiesced path calls before its own clear. So a resume() while a switchBranch or rollback held the store wrote into it. It now refuses first, with the same agent-busy error. Another resume()'s flush. resume() clears `quiesced` before it awaits its flush and clears the record after it, and the flag that says the record may be quiesced stays set until then. A second resume() during that flush took the serving-host branch and cleared the durable record before the first's flush was done, where on main it wrote nothing and the comment says the durable flag is cleared only after the flush. resumeInFlight, set around the flush and the clear, now makes the second resume() leave the record to the first, as on main. What overlapping operator calls should answer is a separate design. Text. runResume's comment names both ways memory can serve while the record says quiesced: a resume() whose clear failed and a quiesce() whose write failed partway. persistHostMode's doc comment says a boot with a recovery path reads the legacy slot only while no file exists, and its store-only comment says a failed sync fails the call while the write, already in the store's log unsynced, can still reach disk. The changelog fragment says so, and covers the refused and deferred clears. Tests: the resume-retry test now has a retry whose clear fails too, which rejects with the record still quiesced, before the retry that clears it. Two tests are new, for the recovery file and store-only: a resume() on a serving host whose record may say quiesced is refused while a surgery holds the store, writing nothing, and clears the record once it's done; and a resume() during another's held flush answers serving and leaves the record quiesced, which the first clears once its flush lands the deferred write. That test releases the held flush in a finally, so a failing assertion doesn't leave stop() waiting on the first resume(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…alls that overlap The fragment's first line said quiesce() and resume() answer only once the host-mode record agrees with the host, or fail, while its own resume() bullet describes a resume() that answers during another's flush, before the record is cleared. The line now says so, as the description's Not covered does. (Ezra-2423's read of 3adbdb8..307860b.) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Required label not found on this PR. |
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 17, 2026
main gained, among others, anima-research#453 (F134: quiesce() writes its record first), anima-research#426 (a failed create() releases what it acquired) and c9d612e (the deferred queue's recovery drops entries the store can't take, one entry per id, and numbers seqs that aren't valid after the others). One conflict, in restorePersistedDeferredWrites. main changed the per-entry handling inside its catch around the whole restore, which logged any failure and restored nothing; this branch had replaced that catch with refusals for a failed read, a form that isn't a queue, and a failed check for writes that already landed. The resolution keeps this branch's refusals and takes main's per-entry handling whole between the form check and the landed check: wellFormed and its dropped-entry lines, one entry per id, the seq numbering with deferredSeq set from lastSeq, and the rewrite when entries were dropped, repeated or renumbered as well as when some had landed. Entries inside a queue the boot takes keep main's handling, as this change's rule says. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 17, 2026
main had taken 26 commits since 3af13bf: anima-research#367 (a refusal is an error result), anima-research#411 (tune-out errors), anima-research#453 (quiesce writes its record first) and anima-research#207 (end-turn accounting). One conflict, in src/framework.ts: both sides add a private field to AgentFramework at the same place, anima-research#247's inboundAcceptanceObserver and anima-research#207's streamBoundaryEnds. Both are kept. None of main's new lines reads or writes anima-research#247's turn silence, its cause, its drafts or its receipts. anima-research#207 records a tool-ended turn as a completion at the endTurn boundary itself, and leaves driveStream's turn_ended cancel, where the turn's receipt is written, as it was. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 task done
nissa-seru
pushed a commit
to nissa-seru/agent-framework
that referenced
this pull request
Oct 17, 2026
…ima-research#367, anima-research#411, anima-research#453 and anima-research#207 The conflict, and what it keeps: - runResume's finally: anima-research#453's reset of resumeInFlight, then this branch's reopen of the MCPL data planes with readyMcplPlanes(), with no data-plane gate. anima-research#453 changed only the flag there. The barrier funnel beside it on main is the gate this branch removes. anima-research#453's other changes merge without a conflict. quiesce() writes the host-mode record before it sets the flag, createOn marks a quiesced boot's record as possibly quiesced, and a resume() on a serving host clears the record when it may still say quiesced. anima-research#367, anima-research#411 and anima-research#207 merge without a conflict, and in src/framework.ts and src/types/framework.ts they change no member or declaration this branch changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
After
quiesce()orresume()answers, the host and its durable host-mode record now agree, or the call fails, apart from calls that overlap, which Not covered names.quiesce()writes the record before it marks the host quiesced. When that write failed,quiesce()rejected with the host quiesced in memory while its record said it was serving. A restart then booted serving, and a secondquiesce()resolved without writing the record.resume()keeps its order: it serves before it clears the record, so a failed clear leaves the record quiesced, the safe direction. A secondresume()used to resolve without clearing the record, so the next restart booted quiesced. It now clears it, refused while a surgery holds the store, asresume()is on a quiesced host.quiesce()resolved before its record was durable. That now fails the call.It applies #428's order for the framework's own state, the write first and memory after, to the host's quiesce.
What was wrong
We ran this on
mainwith the host-mode write failing, for the recovery file and for a store-only config. Those runs aren't part of this PR.quiesce()rejected with the write's error, andgetHostModeStatus()then saidquiesced: true, with the reason given.quiesce()resolved and wrote nothing, sincerunQuiescereturns early for a host already quiesced.By reading, and by this PR's tests on
main's sources:resume()whose clear failed left the host serving and the record quiesced, as its order chooses. A secondresume()returned atrunResume's check for a host already serving, so the record stayed quiesced.persistHostModelogged a failedstore.sync()and returned, soquiesce()andresume()resolved before their record was durable.Changes
runQuiescewrites the record first, with the time it records. It then setsquiesced,quiesceReason,quiescedAtandlastUnabandonable, and a write that throws leaves them as they were.hostModeRecordMayBeQuiescedsays whether the record may say quiesced.runQuiescebefore its write, since a write that fails partway, such as a store write whose sync fails, may still reach the record.resume()has cleared the record.runResume, on a host already serving, clears the record when it may say quiesced, such as after aresume()whose clear failed or aquiesce()whose write failed, and otherwise answers as before, writing nothing.agent-busyerror the clear on a quiesced host gives.resume():resumeInFlightis set while aresume()flushes its deferred writes and clears the record. Aresume()arriving then leaves the record to it and answers as before.persistHostModewithout a recovery path syncs the store and lets a failed sync throw, so the call rejects. The write is already in the store's log, unsynced, so it can still reach disk, which is whyrunQuiescesets the flag before it writes. With a recovery path, the file is the record, and a failed sync of the store is logged, as before.framework/state'shostModeotherwise. A boot with a path reads thathostModeonly while no file exists.What each part is for
runQuiesce: a failed write leaves the host serving, and a retry isn't taken for a quiesce already done.hostModeRecordMayBeQuiescedandrunResume's clear: a retriedresume()finishes the clear the first one couldn't, and so does aresume()after a quiesce whose write failed partway.resumeInFlight: without it, aresume()during another's flush took the serving-host path and cleared the record before that flush was done. Onmainit wrote nothing, andrunResume's comment says the durable flag is cleared only after the flush.quiesce(), for the recovery file and for a store-only config, whose restarts open the store again. The host is still serving, with no reason or time, and no record was written. A context write lands rather than being deferred, and a restart boots serving. The next quiesce writes the record, and a restart then boots quiesced, with its reason and time.resume(), for both. The call rejects with the host serving and the record quiesced. A retry whose clear fails too rejects with the record still quiesced, and the next retry clears it. A restart then boots serving.resume()is refused and writes nothing, and once the surgery is done it clears the record.resume()during another's flush, held for the test, for both. It answers serving and leaves the record quiesced, and the first clears it once its flush lands the deferred write.quiesce()rejects, and theresume()after it clears the record the write may have left. Aresume()whose sync fails rejects, and the next clears the record. With a recovery file, a failed sync is logged, and the quiesce holds across a restart.Not taken: wrapping the write's error in a message of the framework's own. The error the write throws reaches the caller as before, and the host's status says whether it's quiesced.
What callers will notice
quiesce()whose record can't be written rejects as before, and the host now stays serving, so its context writes aren't deferred. Callingquiesce()again quiesces once the record can be written.resume()retried after one whose clear failed clears the record, where it used to resolve with the record still quiesced. So does aresume()on a host still serving after aquiesce()whose write failed, and it rejects if that clear can't be written.quiesce()orresume(). After aquiesce()that failed this way, the record can still reach disk, so a restart can boot quiesced unless aresume()clears it first.resume()on a serving host that clears the record is refused withagent-busywhile a surgery holds the store, asresume()on a quiesced host is.Evidence
On Linux with Node 24.22.0:
test/host-quiesce.test.ts.main's sources, four of the five fail, and the file's other 34 pass.resume()writes nothing, so a retry whose clear fails resolves.resume()writes nothing, so it isn't refused.quiesce()whose sync fails resolves.resume()during another's flush passes onmain, which writes nothing there. It pins the in-flight guard against this change's own clear, as its removal below shows.1119711c, whose source and tests the head keeps: each of the 11 parts, taken out on its own and rebuilt, fails a test that pins it:runQuiesce;hostModeRecordMayBeQuiescedbefore the write and at a quiesced boot;runResume's clear on a serving host, its surgery refusal and its check for aresume()in flight;resumeInFlightonce the flush and the clear are done;1119711c:host-quiescepasses 39 of 39,mcpl-coalescing67 of 67,shutdown-errors28 of 28,stop-with-host-operations58 of 58, andmaintenance-timer47 of 47.Not covered
resume()clears the flag before it awaits its flush of deferred writes, and nothing serializes operator calls. So aquiesce()in that window writes its record, andresume()'s clear then overwrites it. A secondresume()in that window answers before the first has cleared the record. By reading, the flush itself awaits nothing today, so the window yields only to microtasks: a call reaches it only from code in the same task, asPromise.all([framework.resume(), framework.quiesce()])would, or from a microtask that runs in it.Written by Hilda-2681 (Claude Opus 5.5). Ezra-2423 (Claude Opus 5.5) reviewed its code at
1119711c.