Repository navigation
Suspend a thread's pooler credentials on archive instead of deleting them - #196
Merged
Merged
Conversation
added 15 commits
October 13, 2026 23:07
…them Archive used to delete a thread's scoped and nested credentials, so a session still holding them after an unarchive got 401s, and restoring them crash-safely needed a tombstone with a restore window. Now archive only writes the archive marker at the current epoch; the records stay loaded and on disk (digests only) and authentication rejects them while the thread is archived. Unarchive lifts the suspension and the same tokens work again. Only a delete (or a sweep lookup that bb reports gone) removes credentials. bb's thread state stays the authority: the startup and periodic sweeps suspend archived threads and lift suspensions for live ones, and a suspended thread presenting its token is rechecked with bb at most every 30 seconds, so a missed unarchive event recovers on the next request.
…en, and check deletion before archive If both archive marker writes fail, the kept credential files would reload as live after a restart until the startup sweep reached the thread. Remove them in that case while keeping the in-memory records suspended, and write them back on an in-process unarchive. The environment and unarchive checks now treat a thread bb reports deleted as gone before looking at its archive time, and the sweep looks up archived threads after the others.
…can be written d741f90 deleted a suspended thread's files when the archive marker could not be written, so a restart before the unarchive lost them. Rename each file to suspended-<name> instead, keeping its content; load treats a suspended file as an archived thread's credential and an unarchive renames it back. If the renames fail too, the files stay as they are and the thread stays suspended in memory. After a start a thread's tokens are accepted only once bb has confirmed the thread live (the startup sweep, or a lookup when the token is presented), so a thread whose suspension never reached the disk is rejected until bb answers, never accepted early.
A cached confirmation let a thread archived in bb keep authenticating until the next sweep when its archive event was missed. authenticate and authenticateNested now ask bb each time (concurrent calls for one thread share one in-flight lookup, and no answer outlives it): live accepts and lifts a local suspension, archived rejects and suspends, gone rejects and deletes. When the lookup fails or times out, a token is accepted only if bb confirmed its thread live earlier in this process and no archive is known locally.
…be written Rename the thread's credential files to deleted-pending-<digest>-<name> instead of suspended-. Load marks a pending thread deleted, never loads the file as a credential, writes the deleted marker and removes the pending files, retrying on the next start when the write fails.
Create the deleted marker in place when the atomic writes fail, then a deleted-thread-<digest>.d directory, which load treats as a deletion. Rename each credential file to deleted-pending-, removing it when the rename fails. A power-set test over refused content writes, renames, removals and directory creation computes the residual table, with crash walks after the record and during the next start.
… the deletion directory's mkdir
albrand
pushed a commit
that referenced
this pull request
Oct 14, 2026
albrand
pushed a commit
that referenced
this pull request
Oct 14, 2026
added 12 commits
October 14, 2026 07:34
…scribe the filesystem-first order
…s even with no local record
…nt lookups in the race tests
added 7 commits
October 14, 2026 11:52
…database Every cell and crash point of the refusal and crash matrix now also runs in the server test harness: the thread is deleted through DELETE /api/v1/threads/:id, the real thread.deleted payload reaches the real account-pool plugin, and every lookup after the delete is the real SDK reading the thread row of a server rebuilt from the serialized database at each restart. The store-level power-set says it uses a lookup stub.
vi.spyOn kept every call's arguments and results, about 47 MB per cell, and the two-file matrices ran out of heap on CI. Plain overrides keep the heap flat. The matrix writes its table to stdout, which passed-only keeps, and reports its worker's peak memory.
Its live heap stays near 160 MB; without a cap V8 let each worker reach about 3.5 GB resident before collecting, close to the runner's memory with four workers. A leak now fails the matrix instead of hiding under the default limit.
Node 22 keeps composite AbortSignals in its internal gcPersistentSignals set. A retained signal's Error reason held the fake host's dispose closure, which held the matrix's lookup closure and with it every run's restarted server harness. The lookup now goes through a module-level link cleared at the end of each run, the matrix stops each plugin service with a string reason, and each file asserts its retained heap after a full GC grows less than 64 MB from cell 1 to cell 80.
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.
Human comments
What was wrong
When a thread was archived, the pool deleted its scoped and nested credentials (
removeThread). Unarchiving it (restoreThread) only cleared the archive marker. Any session still holding the old tokens then failed every model call with401 Invalid Account Pooler bearer token. That includes native CLIs, which cannot take a new token, managed turns, and fleet self-archive/unarchive. #193 tried to restore deleted credentials from a tombstone kept for 24 hours. After nine review rounds it still documented an accepted trade-off: a crash partway through an archive meant the thread's credentials were not restored. The owner rejected that trade-off. This PR replaces #193 and removes the failure path instead of narrowing it.What changed
plugins/account-pool/src/thread-tokens.ts:suspendThread(archive) bumps the archive version, marks the thread archived in memory and writes the archive marker (archived: true) at the thread's current epoch, falling back to the fallback marker as before. Records stay loaded and on disk (digests only). Authentication rejects them while the thread is archived, and minting for an archived thread still throws.suspended-<name>with its content unchanged. Load treats a suspended file as an archived thread's credential: it is loaded and held as archived. If a rename fails too, the file stays where it is and the thread stays suspended in memory.restoreThread(unarchive) lifts the suspension at the same epoch, renames suspended files back and reloads anything not yet in memory, so the same tokens are accepted again. It rereads an unreadable marker first; while the marker stays unknown it reloads nothing from disk, so a stale file a delete left behind cannot come back. Lifting the suspension in memory does not depend on the marker write or the renames. A failed one leaves the disk saying "archived", which the next check against bb corrects.removeThreadis used only for delete (and for a sweep lookup that reportsgone). It always advances the epoch and removes the thread's files, including suspended ones.admit,askBb).authenticateandauthenticateNestedlook up the thread's state with bb on each call. Concurrent calls for the same thread share one in-flight lookup, bounded by the sweep lookup timeout, and no answer is cached beyond that flight.liveaccepts (and lifts a local suspension),archivedrejects and suspends,gonerejects and deletes. A missed archive or unarchive event therefore takes effect on the next request. When the lookup fails or times out, the token is rejected (401) and nothing is deleted or suspended; a failed lookup is retried once within the same flight (not after a timeout). The store logs a warning, at most once a minute per thread:forThread,forNested,admit). Both calladmit, the same single-flightaskBbthat authentication uses, before they take the store lock, and refuse unless bb answers live. A failed or timed-out lookup refuses ("Cannot mint a credential for a thread bb has not confirmed live.") and changes nothing, the same fail-closed rule as authentication.gonedeletes through the existing gone path, then the mint throws "Cannot mint a credential for a deleted thread.";archivedsuspends, then it throws "Cannot mint a credential for an archived thread.". The local deleted set stays as an extra guard, and bb is the authority. The env export (contributeFor) already asked bb before minting (checkArchived, thensettleThread) and returns no pool credential for an archived or gone thread; it does not throw, and that is unchanged. Cost: each env resolution now makes two more of bb's in-process thread lookups (bb.sdk.threads.get, one SQLite read each), one for the thread credential and one for the nested credential; a cached credential is still returned without minting, but only after its lookup.timed outreplacesfailedon a timeout.) The same tokens work again once bb answers live.verifiedThreadsis removed. The lookup cost was not measured;bb.sdk.threads.getruns in the same server and no cache was added.restoreThread,admit,isArchived).suspendThreadandremoveThreadbump the thread's archive version and set the suspension synchronously, before any await.restoreThreadnow does its disk work first (marker write, renaming suspended files back, loading credentials). It checks the version before each step and commits the in-memory restore only after a final synchronous check. If an archive or delete arrived meanwhile, it leaves the thread suspended and returns false; the queued suspend or delete then writes its marker last.admitaccepts only if the restore committed and the version is unchanged right before it returns, andauthenticate/authenticateNestedcheck the version again just before returning a route.isArchiveddoes not apply a re-read marker, or clear a read failure, if the version moved during the read. The env-export path (revokeIfArchived) treats a restore that did not commit as archived. A successfularchived: falsemarker write no longer clears marker-failure state; the restore's commit does.commitArchiveState). Every write toarchivedThreads,activeArchiveMarkersandarchiveEpochsgoes through it, so it bumpsarchiveVersion. The writes are: archive and delete (synchronous, before any await), the epoch those two write, a restore's final commit, an unarchive event that finds the thread already live, a marker read from disk, and a suspended file loaded from disk. A commit by an archive or delete also records that version as the thread's last suspension.suspendThreadIfVersion,removeThreadIfVersion).settle, at most two lookups) and is answered from that, or rejected if the second answer is stale too.unarchiveThread) always starts its own lookup rather than joining one already in flight. A lookup joins an in-flight one only if both started at the same generation.revokeIfArchived(env export) and the thread.unarchived handler now go through the samesettlepath as token checks.removeThread,isDeleted). A delete records the thread indeletedThreadDigests(sha256 of the thread id) in its synchronous prelude, and writesdeleted-thread-<digest>.jsonthrough the same temp, fsync and rename path as the other markers. Startup reads deletions from the marker file names, before any credential file, and deletes any surviving credential file of a deleted thread.forThreadandforNestedthrow for a deleted thread.restoreThreadnever commits for one.settlereturns null for a deleted thread whatever bb answers, which covers token checks, the unarchive handler and env export. When bb answers live after the delete, it logs:Account Pooler kept thread <threadId> deleted: bb reported it live after it was deleted, and a deleted thread's credentials are never restored or minted again.removeThreadrethrows. The record is retried on the next event, sweep and start (below).deleted-thread-*files.deletedAtsynchronously in SQLite (markThreadDeleted) before it emitsthread.deleted(apps/server/src/routes/threads/base.tslines 718-722 onours). The failed-create path emits first and writes in the same synchronous tick (thread-create.tslines 537-542). The plugin's first write happens only after an await, so it always follows the commit.deletedAt. The only writes set it to a time:packages/db/src/data/threads.tsline 2135 and the failed-create emit above.deletedAt.bb.storage.kv.setruns a synchronous SQLite insert into bb.db, with no buffering, in the same database asdeletedAt(plugin-api.tslines 723-737,packages/db/src/data/plugin-storage.tslines 33-47).recordDeletion,persistDeletion,holdDeletedFiles,recordPendingDeletion). Onthread.deleted, and on a lookup or sweep answer of gone,removeThreadtries in this order:deleted-thread-<digest>.json;deleted: true, when the thread held credentials;open(…, "w")), then thedeleted-thread-<digest>.ddirectory;deleted-pending-<digest>-<name>; that rename is itself a record;bb.storage.kv.set("deleted-thread:<digest>", { deletedAt })in bb's database.persist,createDeletedRecord,createDeletedDirectory,holdDeletedFiles,syncDirectory). Every filesystem path counts as landed only after its content write (where it has content), the file sync and a sync of the store directory all succeed. Any error on the write, the file sync or the directory sync means the record did not land, and the next path is tried, exactly as after a refused open. No error is swallowed into success. The pending renames are followed by a directory sync too; if it fails, a warning says so, and the deletion stays unrecorded and is retried.rm) only after at least one durable record landed. The kv row lands before the first credential removal whenever kv accepts it. A pending file is removed only once a durable record of another kind exists: kv, a marker or the directory. If a rename fails, that file stays untouched and a warning names it. Nothing is removed in that case, even whenrmworks.removeThreadthen throws, and the thread stays deleted in the process: both token paths are rejected and both mints throw.thread.deletedevent and by every sweep. A thread known only from its pending files is retried at every start and every sweep. Each retry records first, then removes exactly the files the failed attempt left in place.initializeloads everydeleted-thread:kv row into the in-memory deleted set, together with thedeleted-thread-*markers and the pending files, before any credential file is read (see "Partial markers at start" for when a marker counts). Every later record write updates that set.initialize,confirmsDeletedRecord,confirmsRecord,loadArchiveMarker,loadFile).deleted-thread-<digest>.jsonfound at start counts as a confirmed deletion only if its content parses as{ threadDigest, deleted: true }for that digest and syncing the file and then the directory succeeds. Adeleted-thread-<digest>.ddirectory needs the directory sync. An archive marker withdeleted: trueneeds the same file and directory sync. A marker left by a process crash can still be unsynced, so it is synced before anything relies on it.loadFileremoves a deleted thread's credential files only once its deletion is confirmed. Before, a thread known only from its pending files or from an unconfirmed marker lost its remaining credential files at load.deleted: truekeeps blocking restore and mint as before and is never read as a deletion, so a failed archive write never turns into one.thread-tokens-suspend.test.ts, both lifetimes). This is the store-level model: itsThreadLookupis a stub that answers gone, and the plugin's store is driven directly. The same matrix through bb's real delete route and bb's restarted database is the "Server deletion matrix" below. Its output, verbatim, from CI run 38584387035 at 5efd26e (the process lifetime prints the same rows). No run accepted an old token or minted.How it is computed:
bb.storage.kv.set), content of the three deletion records (the atomic marker with its temporary file, the archive markers, the in-place marker), rename, rm/unlink, and mkdir of the.ddirectory. Content stays one class, and its refusal is drawn at one point per cell: the open, the write after open, the file sync, or the directory sync. A subset with content is run once per point (content@open,content@write,content@sync,content@dirsync). That gives 16 subsets without content plus 16 × 4 with it, 80 cells per file count, × {1, 2} credential files × both lifetimes. Three separate classes would give 256 subsets per file count, mostly combinations where the earliest refused point already decides every path. Faults apply during the delete and are lifted at the restart, the worst case for minting.initializeonly), mints a thread and a nested credential, then presents both old tokens. That is the window Hermes named in round 12: the first contact after the restart is a mint, with no local record left. The crash restart and the final restart, which sweep, follow it.deletedAtbefore it emitsthread.deleted, and nothing clears it. The server deletion matrix below reads that row for real. The kv-refused live branch of rounds 11 and 12 is gone.rm,unlinkorrenamein every run, the test checks the disk and kv. A credential file may be removed, or renamed to anything butdeleted-pending-, only while a durable record of any kind exists. Adeleted-pending-file may be removed only while a durable record of another kind exists. The test asserts zero violations.EXPECTED_RESIDUAL, written from the rule rather than from the code's output. The rule: no old token and no mint, in every cell. The "durable record" column still varies, and the invariant still holds with zero violations./dev/shm) where it exists and is removed when the run ends, and one host-token store is shared by every run.thread-tokens-suspend.test.tsruns in its own vitest project (bb-plugin-account-pool:deletion-residual,sequence.groupOrder: 1), after every other account-pool file, because at 4a175ea the power-set running besidethread-tokens.test.tspushed one of its tests past 5 s under full load (probe run 38560662490). The power-set tests carry an explicit 600 s timeout, five times the 119.7 s worst case measured then. At this head the power-set takes 36.6 s and 37.7 s in the PR CI (run 38584387035, the fresh run of this head's account-pool input hash), up from 24.7 s at e12a324, because every run now adds an unswept first start. The fresh-neighbour probe at 3e5f38e (run 38570665390: 3e5f38e plus empty files inpackages/dbandpackages/plugin-sdk, so those packages and every dependent run uncached) passed with account-pool 707 of 707. Under that full load the power-set took 100.9 s (durable) and 78.7 s (process), under a sixth of its 600 s timeout.thread-tokens.test.tstook 5.1 s for its 79 tests. The neighbouring@bb/dbthread-pruning took 5.8 s and@get-bb/plugin-sdkshared-declaration-emit 10.2 s, against 30 s timeouts. The slowest other account-pool tests are the two upstream-transport connect-timer tests (9.5 s and 7.2 s of 20 s) and "exposes Codex device login over RPC and the two-step CLI" (3.1 s of 5 s, the same in every run, loaded or not).apps/server/test/helpers/account-pool-delete-matrix.ts, run byaccount-pool-delete-matrix-{durable,process}-{1,2}.test.tsin the@bb/server:account-pool-delete-matrixvitest project,sequence.groupOrder: 1, after every other server file). It runs the computed table's cells and crash points through the real stack instead of the store-level stub: every subset of refused plugin kv, content, rename, rm and directory mutations, content refused at its open, write, file sync or directory sync, one and two credential files, both lifetimes, and every crash point of the delete, of the first start after it and of the next start. Each of the four files covers one lifetime and one credential count, 80 cells each, 320 in all, and none is sampled.createTestAppHarness, an in-memory SQLite database fromcreateConnectionandmigrate; no mocked database). Each run seeds a thread and deletes it through the realDELETE /api/v1/threads/:idroute. The route runsmarkThreadDeleted, then the realthread.deletedpayload (toThreadResponseFromThread) goes throughsetPluginThreadEventEmitterinto the real account-pool plugin, loaded in the fake plugin host the harness uses inturn-failed-retry.test.ts. Each run asserts the route answered 200 and exactly onethread.deletedwas delivered.serialize()(createTestAppHarness({ db: createConnection(bytes) })), and the plugin is reloaded (lifecycle.reload, the same kv and data directory) after losing every unsynced change.bb.sdk.threads.getis the real SDK client (createBbSdkwithcreateHttpTransport), whose fetch is the restarted server'sapp.fetch. Every lookup after the delete reads the thread row from the restarted database through the realGET /api/v1/threads/:id, which answers 404thread_not_found. Nothing answers for bb. Each run asserts that the restarted row hasdeletedAtand that both checked starts asked bb.deleted-thread:writes. Every injected refusal and crash throws the matrix's ownInjectedFaultclass. After a crash point, every write in the directory and every plugin kv write fails, and the crashed injection stays installed until the next start's factory, so nothing the crashed instance does afterwards reaches the disk.delete@N,first@Nandrestart@Nare enumerated from the same start of the run without a crash, in the same state. A crash is a process death: once crash N fires, no mutation at index N or later in that injection may succeed, and the crashing operation (thethread.deleteddelivery, the first start's reload, or the restart's reload and sweep) must still be running when it fires. Either failure is a crash defect and fails the run; that is what proves the crash ended the world even where the plugin absorbs the EIO. A crash that fires at another mutation or never also fails; a crash point beyond its start's mutations is listed and fails the file (there are none). As a sanity check, the crash signal must carry the oneInjectedFault(codeEIO, mutation N) that the injector created when crash N fired. Every error a crashed operation raises must be anInjectedFault(or carry it ascause); any other error fails the run. The only other error accepted is the plugin's "could not record the deletion of thread " fromthread.deleted, and only when that delete had an injected refusal or crash. The matrix counts and prints those per file.lifecycle.reload) over the crashed data directory and the restarted database. The matrix asserts that it is neither the crashed instance nor any replaced one, and that it has not swept. It is probed before its sweep, then the sweep runs, and it is probed again. A replaced instance is never probed. The run without a crash ends the same way: a clean second start that sweeps, then a final start probed before and after its sweep.forThreadandforNestedwith the plugin's mint refusal (any other rejection is an unexpected error); the env export, with any contribution failure other than that refusal counted as an unexpected error, (resolveProviderEnv("claude-code"), native for the process lifetime) carries noANTHROPIC_AUTH_TOKENand no nested token; the old thread token gets 401 onPOST /v1/messagesand the old nested token 401 onGET /availability; and the invariant check finds zero violations.forThreadmints the thread token, because the export always mints both. The delete, the lookups and every check after it are the same in both.Tests (server), fresh): all 320 cells read "old token accepted: no | mint succeeds: no | export carries a pool credential: no", over 13,652 runs (3,280 in each one-file file and 3,546 in each two-file file). Per one-file file: 1,890 delete, 646 first-start and 664 crash-restart crash points; per two-file file: 2,120, 682 and 664. Every one ended the world at its crash point: the operation was still running when its EIO fired, and no mutation at or after it succeeded. 0 beyond, 0 missed, 0 crash defects, 0 unexpected errors, 0 failing runs and 0 invariant violations in each file. The injected EIO also came out through the plugin's own error path in 0 delete, 480 first-start and 480 restart crash runs per file; the rest the plugin absorbed by design. Each file took 379 s to 554 s, and the server job took 22 minutes of its 30. The verbatim rows are in the green log.--max-old-space-size=1024, and each file asserts that its retained heap after a full GC grows by less than 64 MB from cell 1 to cell 80. At 173c306 (run 38625913755): durable 1 file 142, 161, 179 MB at cells 1, 40 and 80; process 1 file 142, 161, 179 MB; durable 2 files 143, 165, 185 MB; process 2 files 142, 165, 185 MB. The heap limit was 1072 MB and the peak resident size 485 to 539 MB per worker.ThreadLookupreturnslive | archived | gone. The sweep restoreslive, suspendsarchivedand deletes onlygone. It covers only threads that hold credentials (the live install has 453 historical archive markers) and looks up archived threads last.server.ts:thread.archivedcallssuspendThread.Which states lose a credential: only delete (the delete event, or bb reporting the thread gone). Apart from that, a credential ends only through its own expiry (process credentials, 30 days) or a machine-token rotation (after the existing 10-minute grace). No archive, unarchive, crash, marker failure, rename failure or unreachable bb removes one.
Where acceptance waits for bb after a start: every authentication asks bb, so no token is accepted after a start until bb has answered for its thread at least once in that process. When bb hangs after a start, tokens are rejected until it answers, which is a deliberate change from
ours, where they were accepted; a thread confirmed earlier in the process keeps working through a bb hiccup.Unchanged: no wire, protocol or stored-event changes.
Rollback: an older build reads an
archived: truemarker at the files' epoch and deletes those files, which is the old revoke-on-archive behaviour and fails closed. It ignoressuspended-*anddeleted-pending-*files and leaves them in place, so it never loads them as credentials.Docs:
accounts-and-routing.md,bb-guide-plugins.mdanddocs/configuration.mdnow describe suspend, unarchive and delete. The table realignment inconfiguration.mdcomes from oxfmt. The account-pool source and test files were already out of format onoursand are reformatted here.How you verified
thread-tokens-suspend.test.ts(run for both durable and process lifetimes):red-prevhead-4a686356e…; the server test with "expected 200 to be 401", plus 6 store tests across both lifetimes).ours(2550d6f): 9 failures. Red on 4508e97: 8 failures. Hermes's server test fails on both with "expected 200 to be 401"; the earlier round-1 server test passes on 4508e97.ours(2550d6f): 9 failures. Red on 31f2e85: 5 failures, namely Hermes's server test ("expected [ 200, 200 ] to deeply equal [ 401, 401 ]"), Hermes's store test in both lifetimes, and the admit walk in both lifetimes ("admit paused at point 2, then archived: in flight: expected { token: true, nested: true } …"). On 31f2e85 the restore and suspend walks pass, because the next request asks bb anyway; the stale suspension clear shows through the in-flight admit.unarchiveThread.ours(2550d6f): 27 failures. Red on 03ad33e: 10 failures, namely Hermes's server and store tests (both lifetimes), the admit walk holding a stale archived or gone answer ("admit holding a stale archived answer, then unarchived: in flight: expected { token: false, nested: false } to deeply equal { token: true, nested: true }"), and the sweep walk holding a stale gone answer, which deleted the credentials of a thread that had been unarchived ("sweep holding a stale gone answer, then unarchived: next request: expected { token: false, nested: false } …").settleThread,unarchiveThread,restoreThread, the sweep and both mints, then the same after a restart with bb live. Old tokens are rejected, nothing restores, both mints throw, no credential files remain, and the warning is logged.ANTHROPIC_AUTH_TOKEN, including after a plugin reload.removeThreadas their archive step, then expected restore and a fresh mint to work. The deletion ones now assert that deletion stays final. The three marker-failure ones (transient read error, corrupt marker, fallback marker) now archive withsuspendThreadand assert that the same credential works after restore.ours(2550d6f): 25 failures.ANTHROPIC_AUTH_TOKEN, and no credential files remain. The archive-marker copy carries the deletion.suspended-with the same content set, and in process nothing restores or mints. After a restart with bb reporting gone, the deleted marker is written and the files are removed. Then, with bb live, nothing restores or mints.ours(2550d6f): 8 failures.suspended-files restored after a restart with bb live):deleted-pending-with the original content set, and no deleted marker exists while writes still fail. Once writes succeed, the next start writes the deleted marker and removes the pending files, and the thread stays deleted then and after another restart.ANTHROPIC_AUTH_TOKENafter the reload. This is the round 6 server test, parametrized to also refuse every deletion write.deleted-pending-names.removeThreadthrows. In process, settle, both mints, both token paths, restore and unarchive all stay refused. The directory's names and contents are byte-identical to before the delete.deleted-pending-names). The residual test passes there, because the behaviour it shows is unchanged. Red onours(2550d6f): 7 failures..ddirectory is the only deletion record, and the credential file is removed.deleted-pending-path now also refuse the.ddirectory, the server test's all-writes-fail case included, since otherwise the directory records the deletion first.server.test.ts), new case "only bb's database could record the deletion and no credential file could be renamed": after the reload both token paths return 401, env resolution returns noANTHROPIC_AUTH_TOKEN, and the files are gone. The "no deletion record could be written" case now also refuses the kv write. Every case asserts the kv row count.DeletionRecordsnor the kv parameter.EXPECTED_RESIDUAL, and it prints 308 runs per lifetime that accepted an old token or minted; 296 of them are in the kv-first window. Every other test passes on dfc9e0a, including Hermes's round 9 case.createDeletedRecordswallowed write and sync errors and returned true; the matrix counted a successful open as a record, injected content failures only at open and stubbed sync to succeed; the in-place file and directory fallbacks had no directory sync):EXPECTED_RESIDUALin 44 of 160 rows, and it prints 505 (durable) and 504 (process) runs that accepted an old token or minted. The kv-working named cases pass on fc84033 too, because the kv row lands there before any removal.deletedAtbefore it emitsthread.deleted;forThreadchecked local state, not bb):apps/server/test/threads/account-pool-delete.test.ts): it loads the real account-pool plugin in the fake plugin host, the harnessturn-failed-retry.test.tsuses, withbb.sdk.threads.getreading the real thread row from the harness database. The thread is deleted through the realDELETE /api/v1/threads/:idroute:markThreadDeleted, thenemitPluginThreadDeletedthroughsetPluginThreadEventEmitter, delivered to the plugin asthread.deletedwith the realtoThreadResponseFromThreadpayload. The plugin'sdeleted-thread:kv writes and every filesystem record (marker opens, deleted-pending renames, the.dmkdir) are refused from before the delete through the restart (lifecycle.reload, the same kv and data directory). After the restart the row'sdeletedAtis set; the env export returns noANTHROPIC_AUTH_TOKENand no nested token; the thread token gets 401 on/v1/messagesand the nested token 401 on/availability; bb was asked; and there is no deletion record anywhere, in kv or on disk, before or after. This test passes on e12a324 too, because the export path already asked bb there: it is coverage, not the red.server.test.ts) now expects 6 lookups instead of 4: the two new ones are the thread and nested mints asking bb. The same token and the 200 still hold.server.test.ts) is now "mints nothing when the thread is archived while its environment resolves, because the mint asks bb itself". The second lookup is now the mint's own; it sees archived and refuses, so no credential file is written and nothing needs suspending. The test now asserts that no file of that thread exists, which is stronger than asserting a suspension marker.thread-tokens-suspend.test.ts) now fails the first lookup after the mint instead of the first lookup overall, which is now the mint's. It still asserts that one authentication makes exactly two lookup calls and succeeds.forThreadresolved with a token instead of throwing. The power-set's unswept first start mints in all 160 cells of the table (3420 runs per lifetime mint, and none accepts an old token, because authentication already asked bb there), so every row reads "mint succeeds: yes"; it reports zero invariant violations. The real-route server test passes on e12a324 too (1 test, 204 ms, in run 38566953196 with the same Turbo input hash as this red), because the env export already asked bb there: it is coverage, not the red.deletionOutcomesets a synthetic bb answer and callsstore.removeThread()directly, so the matrix did not answer from bb's real thread row; only the single all-refused case inaccount-pool-delete.test.tsused the real route):DELETE /api/v1/threads/:idand the realthread.deletedpayload, and every lookup after the delete is the real SDK reading the thread row of a server rebuilt from the serialized database at each restart. Nothing answers for bb there.createAppinstalls its own plugin thread-event emitter, so a restarted harness took overthread.deleted. The matrix reinstalls its emitter before every DELETE, and every run asserts exactly one delivery and every completed run at least one delete mutation, so the matrix cannot pass on bb's lookups alone.vi.spyOnkeeps every call's arguments and results until the worker exits. On fs and kv that was about 47 MB per cell (heap after forced GC: 192 MB after cell 1, 1853 MB after cell 36), and the two-file files ran out of heap in CI run 38584387035. The matrix overrides the methods directly, with no call history.AbortSignalkeeps composite signals in its internalgcPersistentSignalsset. One of them had anErrorreason whose captured stack held the fake plugin host'sdisposeclosure, its options and the matrix's lookup closure, and with it every run's restarted server harness. Heap snapshots at cells 2 and 8 showed +774 Hono apps, +8140 drizzle query builders and +3375 SQLite statements, and with a 1 GB heap all four files ran out of memory (run 38595856117). The lookup now goes through a module-level link that is cleared when each run ends, and the matrix stops each plugin service with a string reason. On Node 22.23.3 with the 1 GB cap, the durable two-file file retains 142 MB after a full GC at cell 1, 160 MB at cell 40 and 175 MB at cell 80, with a peak resident size of 436 MB. The packaged bb runs Electron's Node 24.20.0, where this retention does not occur, so the installed app is not affected.--max-old-space-size=1024. Each file records its retained heap after a full GC at cells 1, 40 and 80, and fails if it grows by 64 MB or more, so a leak fails the matrix instead of hiding under the default limit.deletedAt, exactly onethread.deletedwas delivered, both starts asked bb, no old token is accepted and the export carries no credential. Only the mint at the unswept first start succeeds, through the plugin's own storeforThreadandforNested, because the export and authentication already asked bb on e12a324. Zero invariant violations. Its memory stays flat too: 141 MB at cell 1 and 168 to 173 MB at cell 80.restart(crashAt, sweep), the catch swallowed any error oncecrashAtwas set, nothing asserted that the error was the injected fault or that mutations reachedcrashAt, and after a failed reload the next probe could test the previous instance instead of a start over the crashed state):InjectedFaultclass. A crashed start may raise only that (directly, ascauseor in anAggregateError); anything else is an unexpected error and fails the run. The injector records the index of the mutation where the crash fired, and each run asserts it is exactly N fordelete@N,first@Nandrestart@N. A crash that fires elsewhere or never fails the run. A crash point beyond its start's mutations is listed per file and fails the file; every file has 0.first@N), so all three starts with crash points are covered: the delete, the first start and the crash restart. The final start never crashes; it is the recovery start under test.forThreadandforNestedmust reject with the plugin's mint refusal (deleted, archived, or not confirmed live by bb). An env export failure other than that refusal, which the fake host logs and turns into[], is an unexpected error.thread.deletedhandler reports "Account Pooler could not record the deletion of thread " without the underlying error. The matrix accepts that message only for the deleted thread and only when that delete had an injected refusal or crash. It is counted per file: 2056 in each one-file file and 2142 in each two-file file at 58b44f6. With no injected fault, the message is an unexpected error.expectInjectedfailed on unexpected errors but accepted an empty error list, so a crash run counted as "reached" even if its injected EIO was swallowed and never surfaced):Design decision. The plugin absorbs write failures on purpose:
recordDeletioninthread-tokens.tstries the deleted file, both archive markers, a second deleted file,createDeletedRecord,createDeletedDirectory, the held-aside files and the kv row in turn, and maps each attempt's error tofalseor an empty catch;createDeletedRecordandcreateDeletedDirectoryreturnfalseon any error;removeProviderFiles(...).catch(() => undefined)absorbs cleanup failures.So for many crash points no error leaves the plugin at all, and making it surface every absorbed EIO would be a resilience regression: a delete or a start would fail on a write it is built to survive. The property under test is therefore "a crash is a process death, and refusal holds after it", not "the EIO propagates out of the plugin".
The proof that the crash ended the world. Once crash N fires, no mutation at index N or later in that injection may succeed, and the crashing operation must still be running when the crash fires. The operation is the
thread.deleteddelivery, the first start's reload, or the restart's reload and sweep. Either failure is a crash defect and fails the run. A swallowed crash that lets the write at N or a later one proceed fails here.Sanity check, not proof. The injector makes one
InjectedFault(codeEIO, mutation N) when crash N fires and rejects the injection's crash signal with it. Each run asserts that the signal carried exactly that instance. Later writes after the crash throw separateInjectedFaultobjects. The dead operation is awaited to completion with the crashed injection still installed before the next start, and anything it raises must be anInjectedFault.Printed per file: crash runs that ended the world at their crash point, per start, against the number exercised; crash runs whose EIO also came out through the plugin's own error path, as evidence of how often the plugin absorbs it; and the crash defects, all 0.
Mutants, each run as a full process, one-file matrix with only the helper changed, logs kept:
thread.deletedhandler and records an empty delivery): fails at the assertion level onexpect(first.deleteMutations.length).toBeGreaterThan(0), because the delete never reaches the plugin inside its injection, so no crash point is enumerated.The log is
/tmp/p7-196/mutant-red-r16.log.Red on e12a324 (CI run 38625914382; e12a324 plus only the matrix helper, the four matrix files and the server vitest config from 173c306, at b4f6990): all four matrix files fail at the assertion level and every other server file passes (391). All 320 cells read "old token accepted: no | mint succeeds: yes | export carries a pool credential: no", with 1,810 failing runs in each one-file file and 1,890 in each two-file file (7,400 in all: 56 runs without a crash, 6,336 delete@N, 336 first@N and 672 restart@N). Every failing run has 0 crash defects (its crash ended the world at its mutation) and 0 unexpected errors; the restarted database has deletedAt, exactly one thread.deleted was delivered, every probe asked bb, no old token is accepted and the export carries no credential. Only the mint through the plugin's own store forThread or forNested succeeds. 0 violations, no OOM, retained heap 142 MB at cell 1 and 175 to 181 MB at cell 80.
ours, with archive mapped to the oldremoveThread.server.test.tsnow reports archived and deleted state consistently with the events it emits. Server tests now expect rejection while bb hangs or fails, and acceptance once it answers.ours394a006 is an ancestor): the PR's CI run 38625913755 passed (10 jobs, 2 skipped), including typecheck, lint andoxfmt --check.@bb/serverran fresh (cache miss, input hash d1b22fad94276f70): 396 files passed and 5 skipped (401), 3954 tests passed and 3 skipped (3957), includingaccount-pool-delete.test.ts(1 test, 280 ms) and the four server deletion matrix files (durable 1 file 498 s, process 1 file 499 s, durable 2 files 554 s, process 2 files 379 s), every one under the 1 GB heap cap and with retained heap flat as listed above. account-pool: 16 files, 707 tests passed (a cache hit of input hash 8f68d6b4fd7e2162, run fresh in the PR's CI run 38584387035 at 5efd26e, whoseplugins/account-pooltree is identical); there the power-set test took 36.6 s (durable) and 37.7 s (process), and the whole suspend file 78.7 s.Supersedes #193.