Repository navigation
fix(host-mode): quiesce() writes the deferred-write queue first or refuses, and a failed write of it raises an ops alert - #480
Conversation
…a failed write of it is reported quiesce() answered without writing the queue of deferred context writes. A write deferred behind a turn still alive was held in memory only, so when the drain timed out with that turn alive, a crash before the queue next changed lost it. quiesce() now writes the queue before anything else, on a first call and a repeat alike, and rejects with the host as it was when the write fails or the queue is over the 4 MiB cap, with an error naming the cap and how to get under it. Elsewhere persistDeferredWrites logged a failed write and went on. It still keeps the queue in memory, since the deferral's source has already been answered, and the queue stays mirrored, so its next change writes it again. A run of failures now raises one deferred-writes ops alert, at its first failure, and a line is logged when a write succeeds again. A queue over the cap raises the same alert, and its line is logged once a run instead of once a process. Without a recovery file the store is the record: the queue's slot write is now followed by a store sync, as the host-mode record's is, and a sync that fails counts as a failed write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and keep a store-only queue mirrored after a failed sync quiesce() refused a queue over the cap before anything else, which also refused the abandon that ends a turn that won't end. Writes held for such a turn stay queued through resume(), so neither way out the refusal named could happen. A call with abandon: true now goes on past the cap and raises the cap's ops alert, and the refusal of any other call names both ways out. Store only, the slot's setStateJson has run when its sync throws, so a later sync can still put that write on disk. writeDeferredWrites now marks the queue mirrored before a store-only write, so after a quiesce() whose sync failed, the queue's next change writes the slot again. A run's alert is still raised at its first failure, now with the cause passed explicitly rather than read from an undefined error, and the cap's line is logged the first time the cap is met in a run, whatever began it. The failed write's alert says the queue's next change writes it again. The cap's constant is renamed for what it counts, the JSON's length in UTF-16 code units, and the alert's data names it capCharacters. Tests: a turn that never ends holding a queue over the cap (a plain quiesce() refuses, abandon goes on with the alert, and resume lands the write); the store-only slot set and then synced, in that order; a failed store-only quiesce leaving the next change to rewrite the slot; a failed record write leaving the queue mirrored; a run begun by a failed write logging the cap's line; a second run's alert and line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rror, answered as resume()'s refusal is
quiesce()'s refusal of a queue over the cap was a plain Error, so the
HTTP route answered it with a 500 and host/command threw it. resume()'s
refusal, a ResumeBlockedError, gets a 409 and ok: false, each with the
host's mode. The refusal is now a QuiesceBlockedError, exported as
ResumeBlockedError is, and both ingresses answer it the same way.
The refusal no longer says that abandon ends every turn the writes wait
for: abandon cancels a turn's stream, and a turn holding its token with
no stream is listed as unabandonable, its writes landing when it ends
on its own. The run flag's doc, the cap constant's and the test file's
header now say that quiesce({ abandon: true }) goes on past the cap and
can begin a run. The README's quiesce example names the new error.
Tests: host/command and the HTTP route answer the refusal with ok:
false and a 409, each with the host's mode, and the error is exported;
a refused quiesce leaves the live turn alive; a repeat quiesce with
abandon on a quiesced host goes past the cap with no second alert.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
QuiesceBlockedError is a new export, so it gets an Added fragment of its own, saying what throws it and how the HTTP /quiesce route and host/command answer it. The Fixed fragment keeps the refusal and its ways out, and leaves the export to the Added line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The refusal said that a turn the abandon can't end has writes that
land when it ends on its own. A quiesced host defers every write, so
the turn-end flush queues them again and they land at a resume. The
refusal now says they land once that turn has ended and the host is
serving, and that the abandon's answer lists such a turn as
unabandonable. On a quiesced host it also said "Resume so they land",
which resume() doesn't do for a write held for a turn still alive, so
it now says such a write stays queued through a resume, and that the
abandon to end its turn comes before resuming.
The run flag's doc no longer reads as if an abandon over the cap were
refused. The Fixed fragment says quiesce({ abandon: true }) is also
how to quiesce anyway: the queue stays in memory, so resume before
restarting.
Tests: the ingress test sends abandon through host/command and the
HTTP route, where a plain repeat on the quiesced host is refused; a
turn holding its token with no stream is listed as unabandonable with
its write still queued, and a resume after it ends lands the write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…queue too
A failed write of the deferred-write queue made quiesce() reject before
anything else, abandon included. So on a quiesced host whose record
couldn't be written, a turn that wouldn't end could only be ended by a
restart, which loses the queue. The queue stays in memory whichever way
the call goes, so a call with abandon now goes on past a failed write,
as it does past the cap, raising the failure's alert.
Without abandon, quiesce() refuses with a QuiesceBlockedError whose
cause is the failed write, naming the ways out: make the record
writable and quiesce again, or quiesce with abandon: true and resume
before any restart. Before, the write's own error went through bare.
On a host that is quiesced, or that an abandon is quiescing, the
deferred-writes alert and the cap's line say to resume before any
restart, and they say that a restart, not only a crash, restores only
what the record last held.
Tests: a plain quiesce() whose write fails is refused with its ways
out, for a recovery file and a store only; on a quiesced host, a plain
one is refused and one with abandon goes on and runs its abandon; a
first quiesce({ abandon: true }) whose write fails goes on, and
resume() lands the write; an abandon past the cap says to resume first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and that abandon cancels streaming turns The refusal of a failed write said to make the record writable, which fixes neither a directory where the record goes nor a failed store sync. The refusal and the failure's alert both said a restart restores what the record last held, which a boot that can't read the record doesn't do, since it refuses to start. Both now say the record doesn't hold the queue's writes. The refusal says to quiesce again once the record can be written, or to quiesce with abandon: true, which cancels the turns still streaming. A run's alert comes once, so an abandon whose write failed later in a run raised nothing new. Its own failure line now says that quiesce goes on with the writes in memory, and to resume before any restart. Docs: the persist doc says a restart, not only a crash, and that a record it can't read stops the start; the run doc no longer says a resume lands every deferred write; the test file's header says abandon goes past a failed write. Tests: an abandon past a failed write logs its line; a failed write on a serving host whose queue is mirrored raises the alert with no advice to resume. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t on disk Store only, a write whose sync failed is in the store's slot, where a later sync can still put it on disk, so "the record doesn't hold the queue's writes" was true of the disk but not of the slot. The refusal and the failure's alert now say that the queue as it stands isn't on disk, which holds for a recovery file left as it was and for a slot whose sync failed alike. The refusal says to quiesce again once the record can be written, as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The changes affect host quiescing and deferred-write recovery in one operational workflow. Fix the stale already-landed queue entries before merging, since they can block maintenance after the disk recovers. Findings
Fix with agent prompt### Issue 1
src/framework.ts:5697
The new refusal counts already-landed writes in `unackedDeferredWrites`. If `resume()` lands an over-cap batch but its first store sync fails, `persistHostMode()` can sync successfully afterward without clearing that batch. Later plain `quiesce()` calls still refuse, even with a healthy disk and no pending writes.
The suggested `abandon` → `resume` cycle cannot clear this state: `flushDeferredWrites()` skips `ackDeferredWrites()` when the pending queue is empty. Retry syncing and clearing already-landed entries before applying the refusal, and cover this recovery sequence.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Summary
Reviews (1) · Last reviewed commit: "fix(host-mode): a failed write's refusal..." · Reviewed by Greptile |
| // which loses them: it goes on, with the alert as everywhere else. | ||
| let outcome: DeferredWritesOutcome; | ||
| try { | ||
| outcome = this.writeDeferredWrites(true); |
There was a problem hiding this comment.
Quiesce stays blocked after recovery
The new refusal counts already-landed writes in unackedDeferredWrites. If resume() lands an over-cap batch but its first store sync fails, persistHostMode() can sync successfully afterward without clearing that batch. Later plain quiesce() calls still refuse, even with a healthy disk and no pending writes.
The suggested abandon → resume cycle cannot clear this state: flushDeferredWrites() skips ackDeferredWrites() when the pending queue is empty. Retry syncing and clearing already-landed entries before applying the refusal, and cover this recovery sequence.
Knowledge Base Used: Persistent state and recovery
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 5697
Comment:
**Quiesce stays blocked after recovery**
The new refusal counts already-landed writes in `unackedDeferredWrites`. If `resume()` lands an over-cap batch but its first store sync fails, `persistHostMode()` can sync successfully afterward without clearing that batch. Later plain `quiesce()` calls still refuse, even with a healthy disk and no pending writes.
The suggested `abandon` → `resume` cycle cannot clear this state: `flushDeferredWrites()` skips `ackDeferredWrites()` when the pending queue is empty. Retry syncing and clearing already-landed entries before applying the refusal, and cover this recovery sequence.
**Knowledge Base Used:** [Persistent state and recovery](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/persistent-state-and-recovery.md)
---
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.
Addressed in 256baf6. A plain quiesce() could stay refused on a healthy host. Writes that a flush landed stay in the queue until a store sync acks them (ackDeferredWrites). When resume()'s ack failed its sync, nothing retried it until a later flush, a turn's tool boundary or stop(), and a resume() with nothing pending doesn't ack. So over the cap, every plain quiesce() counted those writes and refused, even once the store could sync them.
quiesce() now retries the ack before it writes the queue (writeDeferredWritesForQuiesce). Once a sync succeeds, those writes leave the queue. Two new tests, one with a recovery file and one with a store only, cover the sequence: resume() lands a write over the cap while its ack's sync fails, and the next plain quiesce() quiesces, with the record empty and no alert beyond the cap's. With the retry taken out, both fail.
Written by Basil-1007.
— Tabitha-2696
…ueue Writes a flush landed stay in the queue until a store sync acks them. When that sync failed, nothing retried it until a later flush, a turn's tool boundary or stop(), and a resume() with nothing pending doesn't ack. So quiesce() counted writes the store had since put on disk, and over the cap every plain quiesce() was refused on a healthy host, with ways out that couldn't clear it. quiesce() now retries the ack before it writes the queue: once a sync succeeds, those writes leave the queue. Test: with either kind of record, resume() lands a write over the cap and its ack's sync fails; the next quiesce() acks it and quiesces, with the record empty and no alert beyond the cap's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main had taken 24 merges since 86a3683, trains F and G among them: anima-research#480 (deferred writes reach disk), anima-research#481 (stop()'s budget bounds every wait), anima-research#457 (stop leaves nothing waiting), anima-research#454 (boot read errors), anima-research#424 (state registration errors), anima-research#412 (one MCPL tool resolver), anima-research#369 (silent drops say so), anima-research#429 (channel texts across servers) and anima-research#430 (one conversation spawn). Three conflicts: - src/framework.ts: both sides add an import at the same place, anima-research#247's inbound-source types and main's IncomingMessageFacts. Both are kept. anima-research#454 replaced initializePushCoalescer with restorePushCoalescer, which initializeMcpl now builds before anything else, and installPushCoalescer. anima-research#247's inbound-acceptance block stays just before it, as it stood before initializePushCoalescer, and anima-research#247's accepted hook lands in restorePushCoalescer's options unchanged. - src/mcpl/push-coalescer.ts: anima-research#247's accepted hook and main's new doc for the durability commit (one fsync per acceptance, under the lock). Both are kept. - src/mcpl/push-handler.ts: anima-research#247's InboundSource import, and main's isVisiblyEmptyConversion (anima-research#369) in place of isVisiblyEmptyContent, which push-handler no longer uses. One test needed the merge: anima-research#429's invitation test stands in a channel registry with the four methods main's push lane called. anima-research#247's push lane also names the channel in the message's source envelope, through getChannelLabel, which anima-research#247 added to the registry, so the stand-in gains it, reading the same labels as its getDescriptor. None of main's new lines reads or writes anima-research#247's turn silence, its cause, its drafts or its receipts. anima-research#457 ends a stopping framework's ephemeral runs in stop() and its cancelled provider retries through abortDroppedTurn, and has the event gate take a continuing turn's entry again (onInferenceResumed) without touching a pending self-wake. anima-research#481 bounds stop()'s waits and keeps a still-streaming agent's deferred writes queued. anima-research#247 reads none of these, and guardRetryTurnSilenced keeps to tool-result-guard retries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hat runner's exit can't drop what it has written force-exit-blocking-stdout's runner test counts the fixture's bytes in the report of the runner it spawns. It read that report through a pipe, and the runner's own --test-force-exit can drop what is still queued for that pipe. On Node 20.20.2 the root calls process.exit() when its root reporter stream closes (lib/internal/test_runner/test.js 836-845), without waiting for the reporter's output to reach stdout or for stdout's pipe to drain. The helper acts only in a test file's process, so it leaves that runner's stdout as it is. The runner has read the fixture's pipe to its end by then: it waits for the child's exit and for its stdout to end before it reports the file (runner.js 413-416). The test failed on macOS Node 20 in #250's CI at ed40f2d (9,953,278 of 10,000,000 bytes; its rerun passed) and in #480's at 256baf6 (9,945,086). In the 15 CI runs between #464's merge and 03:53Z, every job's latest attempt passed it. The runner now writes its report to a temporary file. Node writes a file synchronously, so whatever the runner has handed to its stdout is in the file when it exits; the fixture's pipe to the runner, which the helper protects, is as before. On Linux, with the helper, a file held the whole report in 60 of 60 runs under each of Node 20.20.2 and 24.21.0, where a socketpair with 8192-byte buffers came up short in 10 of 10 and 8 of 10. runnerReceived now throws, with the end of the runner's stderr and report, when the runner fails to start or exits otherwise than 0, as it does for this fixture, so a failed runner no longer reads as a shortfall. A test file that fails to load reports its error in the runner's report, not on the runner's stderr. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TL;DR:
quiesce()now writes the queue of deferred context writes before it changes the host's mode. So a write deferred behind a turn still alive is on disk before the host quiesces. When the queue can't reach disk, because its write fails or it's over its cap,quiesce()refuses with a newQuiesceBlockedErrorthat names the ways out, and leaves the host's mode as it was, except when the call hasabandon: true, which goes on with the queue in memory. Elsewhere, a write of the queue that fails still keeps the queue in memory, and it now raises adeferred-writesops alert once a run, with a line when a write succeeds again. Without a recovery file, the queue's write is now synced.What was wrong
Probes on
main(b96e918), on Linux with Node 24.22.0. Each staging ended withprocess.exit, standing in for a crash, and a fresh boot then resumed. Those runs aren't part of this PR.deferred-writes.jsongoes, a context write deferred while quiesced returned'', as one that was written does. The only sign was a line on stderr,[host-mode] failed to persist deferred context writes: Error: EISDIR: illegal operation on a directory, rename …. After the exit, the boot restored no write, and the write never landed. With the directory gone, the next deferral wrote both writes, and both landed after an exit.quiesce()whose drain timed out with that turn alive:quiesce()resolved (drained: false,deferredWrites: 1) with no queue file, and after the exit the write was gone. A deferral afterquiesce()answered wrote both.[host-mode] deferred context writes exceed 4194304 bytes serialized (2 message(s)) — kept in memory only; a crash before resume loses them, and the record kept the small write alone. A small write after it logged nothing and wasn't written either. After the exit, the boot restored the first small write only.storePath). A context write deferred while quiesced was in the store's slot within the process, and after the exit the reopened store's slot was empty.Changes
quiesce()writes the queue first (writeDeferredWritesForQuiesce), before anything else, on a first call and a repeat alike, as a quiesced host keeps it: the queue when it holds anything, and an empty queue over a record that held one.abandonrefuses with aQuiesceBlockedError, exported asResumeBlockedErroris, and the host's mode as it was: serving with no record written, or, on a repeat, quiesced with no abandon run.cause, and its message names the ways out: "So the queue as it stands, N deferred context write(s), isn't on disk. Once the record can be written, quiesce again, or quiesce with abandon: true, which cancels the turns still streaming, to go on with the queue in memory, and resume before any restart."abandon: truegoes on past either, with the failure's or the cap's ops alert, as elsewhere. So it's also how to quiesce anyway: the queue stays in memory, not on disk, so an operator resumes before restarting, since a restart restores only what the record last held, or, with fix(boot): restored state that can't be read or taken fails create(), naming it, not an empty state #454, doesn't start if it can't read the record. On a host that is quiesced, or that an abandon is quiescing, the alert and the cap's line end "Resume before any restart".resume()'s: the HTTP/quiesceroute with a 409 and the host's mode, andhost/commandwithok: falseand the host's mode.quiesce()first retries an ack whose sync failed. Once a sync succeeds, they leave the queue rather than count against the cap.persistDeferredWrites) still logs its line and keeps the queue in memory. The queue now stays mirrored (deferredWritesPersisted), so its next change writes it again. A run's first failure raises adeferred-writesops alert naming the record, and a write that succeeds ends the run with[host-mode] <record> written again: it holds the queue's N deferred context write(s).main, and the queue now stays mirrored. The cap raises the same alert when it begins a run. Its line is logged the first time the cap is met in a run, whatever began the run, where onmainit was logged once a process.store.sync(), aspersistHostMode's is, and a sync that fails counts as a failed write. The slot is set before the sync, so a later sync can still put a write whose sync failed on disk. The queue is marked mirrored before the write, so after a failed sync,quiesce()'s included, the queue's next change writes the slot again.DEFERRED_WRITES_PERSIST_CAP_CHARS, since it's compared with the JSON's length in UTF-16 code units, which the messages call characters. The alert's data names itcapCharacters.quiesce(),persistDeferredWrites, the cap and the flags say so, and so doesresume()'s note that deferred writes survive a restart. The README's quiesce example names the new error.What each part is for
runQuiesce: a write deferred behind a live turn is on disk before the host quiesces, so a quiesce that answers with that turn alive no longer leaves it in memory alone. Coming first, its refusal comes before the mode changes, as fix(host-mode): quiesce() and resume() resolve only once the host-mode record agrees with the host #453's order does for the record's failure. On a repeat call, it's an operator's way to write the queue again.quiesce(): a queue whose write failed, or that's over the cap, isn't on disk, and an operator who quiesces before a restart would lose it. The failed write's refusal keeps the write's error as itscause, so the reason isn't lost behind the ways out.resume()landed, and whose ack's sync failed, stay in the queue, since nothing retries the ack until a later flush, a turn's tool boundary orstop(). Over the cap, every plainquiesce()then refuses on a healthy host, and the ways out it names can't clear it.abandon: truepast either refusal: the queue stays in memory whichever way the call goes, and writes held for a turn that won't end stay queued throughresume(), which keeps every write whose target's turn is alive (flushDeferredWrites). Refusing the abandon too would leave only a restart, which loses the queue. The abandon cancels the turn's stream, andresume()then lands its writes. A turn holding its token with no stream can't be cancelled, as onmain: the abandon lists it as unabandonable, and its writes land once it has ended and the host is serving. A quiesced host defers every write, so writes a turn's end would land wait for a resume.QuiesceBlockedError: each refusal leaves the host's mode as it was and names what to do, asresume()'s refusal does, so the ingresses answer it the same way. As a plain error it would reach an HTTP caller as a 500 and ahost/commandcaller as an error, though the call changed nothing. It costs a class, its export and a branch at each of the two ingresses.quiesce(), it comes before the host's mode changes, soquiesce()passes the mode it's moving to. A run's alert comes once, so an abandon whose write fails also says so in its own failure line, which is logged each time.channels/incoming, for one, answers "accepted" once the event is queued (src/mcpl/channel-registry.ts:1144queues it,:1154answers), and the event loop logs a throw and goes on (src/framework.ts:9466, both atmaine670363). A throw would drop a write that, held in memory, lands at resume unless the process dies first.main, a failed write was a line on stderr and nothing more. The alert goes where an operator looks: failures.log and theops:alerttrace each time, and the webhook, when one is configured, unless an alert of its kind was posted there in the last 15 minutes, as for every ops alert. It comes once a run, so a failing disk doesn't raise one per deferral, and the line when a write succeeds again says the record has caught up.resume()lands the queue, so the run never ends, and the next run's first failure raises no alert.writeDeferredWrites, split out ofpersistDeferredWrites: one writer for both paths, soquiesce()turns its error into a refusal or goes on, while the others catch it.quiesce()with the write's error, as fix(host-mode): quiesce() and resume() resolve only once the host-mode record agrees with the host #453 has it, since there's no abandon way past it.removePlacement, RFC-006's removal of an unread deferred write). The record is unchanged either way, so putting it back would only turn the coalescer's replacement into an append. The doc comment says that a crash before the next write succeeds brings such an entry back.What callers will notice
quiesce()withoutabandoncan refuse for the queue, with aQuiesceBlockedError, when its write fails, the error being itscause, or when it's over the cap. The host's mode is as it was. Over HTTP the refusal is a 409 and overhost/commandanok: false, each with the host's mode, asresume()'s refusal is.QuiesceBlockedError, besideResumeBlockedError, with its own Added fragment.quiesce()whose queue holds anything, or whose record holds a queue, writes the record before anything else, after retrying the store sync of an ack that failed.deferred-writesops alert at the write that begins a run, whether it failed or met the cap, and a line when a write succeeds again. On a host that is quiesced, or that an abandon is quiescing, the alert ends "Resume before any restart".Tests
test/deferred-write-failures.test.ts, eighteen tests. A write of the queue fails because a directory stands where its record goes, or, store only, because the store's sync after the slot's write throws. In one test, the host-mode record's write throws an injected error instead:quiesce()writes a context write deferred behind a turn still alive, though its drain times out;quiesce()whose write of the queue fails is refused with aQuiesceBlockedErrorwhosecauseis the write's error and whose message names the ways out, with the host serving, no host-mode record and the write still queued, raising no alert; the next deferral then rewrites the slot, store only, and stays in memory with a recovery file; and the nextquiesce()writes the queue;quiesce()whose record write fails after the queue's leaves the host serving with the queue written, the next change rewrites it, a failed write there raises the alert with no advice to resume, and the nextquiesce()quiesces;quiesce()is refused with its turn still alive, and aquiesce({ abandon: true })goes on, runs its abandon and raises no second alert within the run; once the record can be written, aquiesce()writes the queue and logs that the record has caught up;quiesce({ abandon: true })whose write fails goes on with the queue in memory, its alert ends "Resume before any restart" and its failure line says so too, andresume()lands the write and clears the record;quiesce()without abandon, with aQuiesceBlockedError, the host serving, its turn alive and nothing written, and by a repeat, with the host still quiesced and the record as it was, each naming its ways out; a repeat withabandon: truethen goes on, with no second alert;host/commandwithok: falseand the host's mode, and the HTTP route with a 409 and the host's mode;host/commandpassesabandonon, and so does the HTTP route, where a plain repeat on the quiesced host is refused; andQuiesceBlockedErroris exported;quiesce()refuses with no alert,quiesce({ abandon: true })quiesces, cancels the turn and raises the alert, which ends "Resume before any restart", and afterresume()the write has landed and the record is cleared;quiesce({ abandon: true })lists it as unabandonable with its write still queued and raises the cap's alert, and a resume after the turn ends lands the write;resume()landed, and whose ack's sync failed, is acked by the nextquiesce(), which quiesces with the record empty and no alert beyond the cap's;resume()ends the run, the cap met again raises a new alert and line;resume()'s writes bring the record up to date and end the run;Run on Linux (x86_64) with Node 24.22.0 and npm 11.19.1, at
256baf6:api-admin-gate,host-quiesce,mcpl-coalescing,shutdown-errors,stop-with-host-operations,maintenance-timerandoffline-recovery-branch), 269 of 269.main'ssrc/framework.ts,src/api/server.tsandsrc/index.tsate670363, all eighteen new tests fail.quiesce(): its retry of an ack whose sync failed, its write of the queue before anything else, its refusal of a queue over the cap, the refusal's class, lettingabandon: truepast that refusal and past a failed write, the failed write's refusal naming its ways out, the abandon's own line for a failed write, and the line its write logs when the record catches up;host/command'sok: false, the HTTP route's 409, and the class's export; andmain's passing on ofabandonat both ingresses, the way out the refusal names;persistDeferredWritesending a run;The full suite is left to CI.
Not covered
quiesce()doesn't check the queue again. A write that fails while it drains, such as a turn's end deferring its writes again, raises the alert, andquiesce()still answers.mainabove did exit the process.opsAlertand reading its call. Its sinks and cooldown areopsAlert's own, whichtest/ops-alert.test.tscovers.host.quiescecommand passes the refusal on as it passesresume()'s, as an error, unchanged here.src/modules/web-ui-module.tsatd9460a1), so it doesn't offer the abandon way out.Overlaps
This branch has merged
mainate670363, which holds #467 and #454:codekept. These tests match a failed write by itscode.create(), naming it, so a damaged record is no longer overwritten. This change doesn't touch that restore path.Written by Basil-1007 (Claude Opus 5.5). Ezra-2423 (Claude Opus 5.5) reviewed its code at
256baf6.