Skip to content

fix(host-mode): quiesce() and resume() resolve only once the host-mode record agrees with the host - #453

Merged
nissa-seru merged 4 commits into
anima-research:mainfrom
nissa-seru:hilda-2681/quiesce-writes-first
Oct 17, 2026
Merged

nissa-seru merged 4 commits into
anima-research:mainfrom
nissa-seru:hilda-2681/quiesce-writes-first

Conversation

@nissa-seru

Copy link
Copy Markdown
Contributor

After quiesce() or resume() 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 second quiesce() 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 second resume() used to resolve without clearing the record, so the next restart booted quiesced. It now clears it, refused while a surgery holds the store, as resume() is on a quiesced host.
  • Without a recovery file, the store is the record. A failed sync after its write was only logged, so 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 main with 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, and getHostModeStatus() then said quiesced: true, with the reason given.
  • A second quiesce() resolved and wrote nothing, since runQuiesce returns early for a host already quiesced.
  • A restart booted serving.

By reading, and by this PR's tests on main's sources:

  • A resume() whose clear failed left the host serving and the record quiesced, as its order chooses. A second resume() returned at runResume's check for a host already serving, so the record stayed quiesced.
  • On a store-only config, persistHostMode logged a failed store.sync() and returned, so quiesce() and resume() resolved before their record was durable.

Changes

  • runQuiesce writes the record first, with the time it records. It then sets quiesced, quiesceReason, quiescedAt and lastUnabandonable, and a write that throws leaves them as they were.
  • hostModeRecordMayBeQuiesced says whether the record may say quiesced.
    • It's set at a quiesced boot, and by runQuiesce before its write, since a write that fails partway, such as a store write whose sync fails, may still reach the record.
    • It's cleared once resume() has cleared the record.
  • runResume, on a host already serving, clears the record when it may say quiesced, such as after a resume() whose clear failed or a quiesce() whose write failed, and otherwise answers as before, writing nothing.
    • A surgery: that clear refuses first while a surgery holds the store, with the agent-busy error the clear on a quiesced host gives.
    • Another resume(): resumeInFlight is set while a resume() flushes its deferred writes and clears the record. A resume() arriving then leaves the record to it and answers as before.
  • persistHostMode without 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 why runQuiesce sets 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.
  • Its doc comment says where the record is: the recovery file when there's a path for one, framework/state's hostMode otherwise. A boot with a path reads that hostMode only while no file exists.

What each part is for

  • The order in runQuiesce: a failed write leaves the host serving, and a retry isn't taken for a quiesce already done.
  • hostModeRecordMayBeQuiesced and runResume's clear: a retried resume() finishes the clear the first one couldn't, and so does a resume() after a quiesce whose write failed partway.
  • The surgery refusal on that clear: a surgery can hold the store while the host serves, since it needs every agent idle, not the host quiesced. The clear writes the store, so it waits as the clear on a quiesced host does.
  • resumeInFlight: without it, a resume() during another's flush took the serving-host path and cleared the record before that flush was done. On main it wrote nothing, and runResume's comment says the durable flag is cleared only after the flush.
  • The store-only sync's throw: there the sync is what makes the record durable, so its failure is the write's.
  • The recovery file's sync stays logged: there the record is the file, and failing the call would leave the file quiesced with the host serving.
  • The tests:
    • The record write fails during 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.
    • The record clear fails during 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.
    • A surgery holds the store while the host serves with a record that may say quiesced, for both. resume() is refused and writes nothing, and once the surgery is done it clears the record.
    • A 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.
    • The store-only sync fails. A quiesce() rejects, and the resume() after it clears the record the write may have left. A resume() 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

  • A quiesce() whose record can't be written rejects as before, and the host now stays serving, so its context writes aren't deferred. Calling quiesce() again quiesces once the record can be written.
  • A resume() retried after one whose clear failed clears the record, where it used to resolve with the record still quiesced. So does a resume() on a host still serving after a quiesce() whose write failed, and it rejects if that clear can't be written.
  • Without a recovery file, a failed store sync after the record's write now fails quiesce() or resume(). After a quiesce() that failed this way, the record can still reach disk, so a restart can boot quiesced unless a resume() clears it first.
  • A resume() on a serving host that clears the record is refused with agent-busy while a surgery holds the store, as resume() on a quiesced host is.

Evidence

On Linux with Node 24.22.0:

  • The tests are the five new ones in test/host-quiesce.test.ts.
  • Red-first: on main's sources, four of the five fail, and the file's other 34 pass.
    • The failed quiesce fails because the host reads as quiesced after the failed write.
    • The failed resume fails because a retried resume() writes nothing, so a retry whose clear fails resolves.
    • The surgery fails the same way: the retried resume() writes nothing, so it isn't refused.
    • The failed sync fails because a store-only quiesce() whose sync fails resolves.
    • A resume() during another's flush passes on main, which writes nothing there. It pins the in-flight guard against this change's own clear, as its removal below shows.
  • Removals, at 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:
    • the record before the flag in runQuiesce;
    • setting hostModeRecordMayBeQuiesced before the write and at a quiesced boot;
    • runResume's clear on a serving host, its surgery refusal and its check for a resume() in flight;
    • resetting resumeInFlight once the flush and the clear are done;
    • clearing the flag after each of its two clears;
    • the store-only sync's throw;
    • the recovery file's logged sync, which an existing test, a resume whose chronicle sync fails, also pins.
  • At 1119711c: host-quiesce passes 39 of 39, mcpl-coalescing 67 of 67, shutdown-errors 28 of 28, stop-with-host-operations 58 of 58, and maintenance-timer 47 of 47.
  • The whole suite is this PR's CI: typecheck, build and tests on Node 20 and 24, on Ubuntu and macOS.

Not covered

  • Operator calls that overlap. resume() clears the flag before it awaits its flush of deferred writes, and nothing serializes operator calls. So a quiesce() in that window writes its record, and resume()'s clear then overwrites it. A second resume() 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, as Promise.all([framework.resume(), framework.quiesce()]) would, or from a microtask that runs in it.
  • A recovery file's durability. It's renamed into place without an fsync, so after a power loss the record a call answered for may not be on disk.
  • A recovery file's temporary file can stay behind when its write or its rename throws.

Written by Hilda-2681 (Claude Opus 5.5). Ezra-2423 (Claude Opus 5.5) reviewed its code at 1119711c.

Hilda-2681 and others added 4 commits October 17, 2026 14:21
…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>
@greptile-apps

greptile-apps Bot commented Oct 17, 2026

Copy link
Copy Markdown

Required label not found on this PR.

@nissa-seru
nissa-seru merged commit 0169159 into anima-research:main Oct 17, 2026
6 checks passed
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant