Repository navigation
feat(framework): gated operator body changes with journaled recovery (stacked on #250; merge after it) - #255
nissa-seru wants to merge 75 commits into
Conversation
…ch switches
Branch state (snapshot, tree and log states) rewinds with a historical
rollback; typed chronicle records do not. getRecordIdsByType enumerates them
from every branch, in global order (ids come from one store-wide counter),
and they survive deleteBranch, reopen and SIGKILL. Three AF journals need
exactly that — the workspace's disk-agreement evidence (shelf-383), delivery
receipts (shelf-354) and prose-draft outcomes (shelf-351) — so they share one
helper instead of three: append typed records, checkpoint the caller's
reduced state, replay the tail after the latest checkpoint at open.
Durability is the subtle part. A record append reaches the OS before it
returns, while branch-state commits are buffered until store.sync(): a probe
lost a tree commit made between two appends that both survived a SIGKILL. So
the API names two barriers and implies neither from the returned
{id, sequence}:
- afterCommittedState: sync BEFORE appending, for an entry asserting state
committed just before it (it can then never survive without that state);
- durable: sync AFTER appending, for an entry that must be on stable storage
before the caller acts (an attempt before its outbound request, an intent
before its disk effect).
One writer per record type: a checkpoint covers entries up to the last one
this instance loaded or appended. Entry meaning, reducers, dedup and
checkpoint cadence stay with each caller.
Tests: empty load; append refs and replay order; entries from a rolled-back
branch and main loading from either, through deleteBranch and reopen;
checkpoint + tail with interleaved other record types and a tree commit, the
newest checkpoint winning; the barrier call order; and a SIGKILL child that
commits tree state then appends with afterCommittedState — the entry never
survives without the state. (The same kill without the barrier leaves the
entry and loses the state.)
Agreed in room-220 #41647-#41713 with Oscar-1005, Petra-1012, Hugo-1004.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pts them
Field incident (brosefo's household, 2026-10-07): a web-UI rollback removed
918 Discord messages from a resident's context. rollbackToMessage awaited
syncDiscordAwarenessMarkers() inside reserveStoreForSurgery, so the serial
reaction drain (~1/s at Discord's pace) held the store-wide reservation and
the operator's surgery-result for its whole length; the web client gave up
after 60 s while the branch had already switched. A composed fixture against
the shipped 0.21.0 source reproduced it: the rollback promise resolved only
after the last add_reaction, and channel traffic sent during the drain was
answered only after it.
Now both live surgeries split body from marks:
- The reservation covers fork/switch (or redaction), durable batch
activation, and any source restoration on error. After the body change
lands, activateSurgeryMarkers() records the batch active and returns a
SurgeryMarkerReceipt: none | queued (count, batchId) | not-scheduled.
- deliverDiscordAwarenessInBackground() calls syncDiscordAwarenessMarkers()
without awaiting it, before release(). The MCPL data-plane gate is
installed synchronously inside it, so MCPL-borne wakes stay behind the
gate exactly as before; only the response and the store reservation stop
waiting on remote reactions. Failures are logged and raised as an ops
alert ('discord-awareness-delivery'); the gate's own failure handling
(connection recycle) is unchanged.
- A ledger failure in activate() after the body change no longer fails an
applied rollback (previously a raw throw after the switch, logged as an
error) or undoes a fully redacted suppression (previously activate() sat in
the redaction try block, so it retired the batch and restored the source).
The batch is retired so the reconciliation that delivery runs next cannot
promote it behind a "not scheduled" receipt; if retiring fails too, the
receipt names the batch left prepared.
- `markers` is returned by both surgeries, by message-granular host/command
undo, recorded in operator-actions.jsonl, and printed on the [operator]
stderr line. It is a scheduling statement, never a delivery claim.
Agreed seam: Kit-1008 and Agnes-1009 (shelf-380 body contract, room-203
#41462/#41558) stack their safe-boundary lease on this split; room-225's
marks contract (opt-in scope, one-shot, background delivery that does not
gate MCPL traffic) is a separate, pending change.
Tests: test/surgery-marks-receipt.test.ts (6) with a fake Discord MCPL
server that withholds reaction replies. Red on origin/main 64c480b: 0/6
(rollback still pending after 3 s with reactions held; activation failures
throw; suppression restored; no markers field). Green: 6/6. Related files
(operator-surgery, discord-awareness-outbox, mcpl-awareness-barrier,
offline-recovery-branch, host-quiesce, puppet-turn-reservation): 87/87.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tore must be reconciled Hugo-1004's review of f7e14f6 found a replay-loss path: append(A, {durable:true}) hands A to the store, then sync() throws; the caller sees failure and never reduces A. A later successful append(B) advanced lastEntryId past A, and a checkpoint({seen:[B]}) then covered A without A ever entering the reducer — load() returned that snapshot with an empty tail. Now any failure after a record reaches the store (the appendJson itself, or the durable barrier after it, for entries and checkpoints alike) marks the journal unreconciled: append and checkpoint throw until load() has replayed the tail, the possibly-written record included, for the caller to rebuild from. A failure before the record reaches the store (the afterCommittedState sync) wrote nothing and leaves the journal usable. load() documents that the caller rebuilds its reduced state from exactly the snapshot and tail it returns. needsReconcile exposes the state. Regression test (fails on f7e14f6, which let append(B) and the covering checkpoint through): a durable barrier failing after A, writes refused until load(), load() replaying A, the journal usable again; and a pre-append barrier failure leaving it usable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review (June-1016, room-225 #41840): when both activate() and discard() failed, the receipt said `not-scheduled` while the batch stayed prepared, and the reconciliation that the surgery's own background delivery runs promoted it at once: June's probe saw a `not-scheduled, queued: 0` receipt followed by an active ledger batch and two real add_reaction calls. The fourth test's title claimed "stays prepared" without asserting it. SurgeryMarkerReceipt now has four outcomes, each with a guarantee that holds on every later path: - `not-scheduled`: the batch was retired from the ledger, so no reconciliation or restart can deliver it (no batchId is offered). - `unresolved`: the ledger could record neither activation nor retirement; the receipt names the batch and promises nothing about it. The ops alert is 'discord-awareness-unresolved' and says it may still be delivered. - `queued` / `none` unchanged. Tests: the not-scheduled case now runs against the fake Discord server and asserts no reaction is sent across the delivery reconciliation and a full restart on the same store; the double-failure case asserts `unresolved`, names the batch still in the ledger, and makes no prepared/held claim. Focused + related files: 87/87 plus 6/6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… after return Review (June-1016, room-225 #41877): syncDiscordAwarenessMarkers() starts the drain synchronously through its first awaited tools/call, so the first reaction request can be written before release() and before the caller gets its receipt. Comments that said the drain runs after the call returns, or after the reservation is released, overstated the ordering. They now say delivery is started (behind the gate) and continues without the caller or the store reservation waiting on it. Comment-only change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y active' Review (June-1016, room-225 #41978): the SurgeryMarkerReceipt doc said an unresolved batch is 'in its prepared state', and the surgery docs and reservation comments said the call returns once the batch is durably active. Neither holds for every outcome: June's real-server probe saw an unresolved batch already promoted and delivering. The docs now say the body is applied with a marker-scheduling receipt, the reservation is held until marker scheduling has been recorded or reported, and an unresolved receipt names a retained batch whose scheduling outcome is unresolved. The changelog fragment's opening sentence is corrected the same way. Comments only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t observed Review (June-1016, room-225 #42149): 'about 15 minutes for a rollback that removed 918 channel messages' read as an observed duration. The household stopped the host after 225 deliveries; fifteen minutes is the projection at the reported pace. Replaced with June's endorsed wording. Changelog only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…arks Greptile's P1 on anima-research#246 (discussion_r4205867371). Now that rollbackToMessage returns while its marks are still being delivered, an operator can restore the source branch (api/server.ts cmdBranchSwitch: store.switchBranch, then syncDiscordAwarenessMarkers) while the first add_reaction still waits for its reply. Reconciliation makes the rollback's marks undesired, and the held add is not present yet, so pending() is empty. The new generation was then a no-work gate: releaseMcplDataPlaneGate completed it at once and it replaced the generation that was waiting on the running drain, opening every MCPL data plane while that drain went on sending its saved adds and then the removals they need. A tools/list_changed after such a restore reached the same hole. On main the same switch could only land as a concurrent request during a rollback's awaited drain (cmdBranchSwitch never consults surgeryHold); anima-research#246 makes it the ordinary sequence, so the fix belongs here. beginDiscordAwarenessBarrier now accounts for the server ids of every running drain (discordAwarenessDrains) as well as those with pending operations, and joins those drains; drainDiscordAwarenessOutbox already returns a running drain, which picks up the removals in its next pass. Every gate installer (startup, sync, quiesce resume, connect, reconnect, list change) goes through it. Only the case of a running drain with nothing pending changes: while a drain runs, its in-flight operation is otherwise still pending in the ledger. Out of scope, deliberately: the drain still sends the adds it saved before the restore and then removes them. That is how it already behaves, and the same as a restore on main after the rollback finished; the stacked marks contract (retract supersedes undispatched adds) replaces this drain. Test: restoring the source while delivery runs keeps traffic gated until the running drain is done. Red on 80c74c8 (the probe was answered while the first reaction was still held); green here. Focused file 7/7; awareness/surgery/quiesce/reconnect files 96/96.
Greptile's P2 on anima-research#246 (discussion_r4205867381). The receipt documented `none` as "the change removed no addressable Discord message", but a framework without an awareness outbox (no discordAwarenessOutboxPath and no storePath, which the config doc calls disabled) has no batch to prepare, so activateSurgeryMarkers(null) returns `none` even when the surgery removed addressable Discord messages. A caller could read that as proof that none were affected. Broaden the documented meaning to what the code guarantees, no marks were scheduled, name both reasons, and point to the surgery's removedRefs (discordRefs in the operator log) as what says whether any addressable message was removed. The four statuses stay as they are: removedRefs already sits beside every receipt and tells the two `none` cases apart. Comment-only change.
…vent order Greptile's P2 on anima-research#246 (discussion_r4205867396). The second-batch test compared the probe's timestamp only with reactions that happened to be answered, with >=, so it passed if the second surgery's amb-0 were never delivered, and equal milliseconds let a probe answered first pass too. Assert instead, by the fake server's own event order, that exactly amb-0, amb-2 and amb-3 were answered before probe-answered (greptile's suggestion, with a comment saying why). Mutation check: with the drain cut to a single pass (a `break` after the first for loop in the built drainDiscordAwarenessOutbox), the second batch is never delivered (delivered=2). The old assertion still passed under it; the new one fails with actual [amb-2, amb-3]. Without the mutation it passes. Focused file 7/7; awareness/surgery/quiesce/reconnect files 96/96 after a clean rebuild.
A failed request to an MCPL server used to reject with a plain Error, so the only way to tell "never written" from "written and unanswered" from "answered with an error" was to match message text. Callers that act in the world need that difference as a fact: a publish or a reaction that was never sent left the world as it was, while one that was handed to the transport and never answered may have landed. (A rollback reaction drain met exactly this: its pending reactions failed at shutdown, and only their message text said whether each had been written.) McplRequestError extends Error with `outcome`: - `not-sent`: sendRequest refused before writing (connection already closed); - `error-response`: a JSON-RPC error response; `code` and `data` kept verbatim (a connector can describe a partial multi-part post there); - `no-response`: the request was handed to the transport and no answer came (per-request timeout, a deliberate close while awaiting, or an unexpected disconnect). It does not prove the request reached the server — the transport can decline or drop a write — only that the host can no longer say it didn't. Messages are byte-identical to before, and every rejection is still an Error. Exported from the mcpl index and the package root. Tests (test/mcpl-request-outcome.test.ts, a real stdio server): an error response keeps code and data; an unanswered request and a process exit while awaiting are no-response; a request on a closed connection is not-sent; the class keeps Error identity. 12/12 with the existing request-timeout tests. Reviewed by Tessa-974 (room-203 #43780), whose wording correction for no-response this carries. Shared with room-225's lane 1 (Ada-1017). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hoice that never gates MCPL
Room-225's awareness-marks contract (Connectome incident: a web-UI rollback
queued a 💤 reaction on each of 918 removed messages, mostly other people's
ambient messages; the serial drain held the store and every MCPL data plane).
Seven points, carried to nissa by Tessa (#41730) and designed with June-1016:
1. Marks are opt-in, default none. `marks: 'none' | { scope: 'addressed' |
'all', refs? }` on rollbackToMessage/suppressMessages; `marks` on
host/command undo and hide (invalid values refused, never guessed);
`--marks` on agent-framework-recover. `addressed` = MCPL tag
chat:addressed. previewSurgeryMarks() shows per-scope counts/channels/refs;
passing a scope's refs back freezes the choice (selection = refs ∩ removed,
later arrivals removed locally but never marked; receipt counts unmarked
and notRemoved).
2. One-shot. The ledger is rewritten as an append-only fsynced JSONL journal
(recovery/discord-awareness-journal.jsonl): batch / activated / held /
released / cancelled / retract / requested / op-cancelled / dispatching
(write-ahead, with a dispatchId shared by ops riding one request) /
outcome records. Branch switches, undo/redo and restarts derive nothing;
reconcileForBranch and the API server's branch-switch syncs are removed.
Startup: unanswered attempts become `unknown`; a prepared batch whose
target is exactly the active branch is crash-completed; anything else
prepared is held for explicit release. Suppression bodies still resume at
startup (body integrity), but a held/cancelled batch's marks stay held.
3. Delivery is background and never gates: the DiscordAwarenessBarrier
generation machinery, installMcplDataPlaneGate/release/complete/fail and
the handlers' barrier awaits are gone; startup/connect/reconnect open
planes via readyMcplPlanes (quiesce-aware) after the §5.3 grant, and kick
delivery. A disconnected route keeps work queued; delivery failure is an
ops alert, never a connection recycle. A journal that cannot be read still
aborts startup (it carries the suppression resume record).
4. Outcomes come from McplRequestError.outcome (Petra's 023b439): not-sent
stays requested; no-response is `unknown` forever (a later attempt's
confirmation never resolves it); Discord errors are failed (permanent when
the message/channel is gone). An unknown op is retried only while it is
the newest live op for its key; one request per key on the wire at a time
(including a cancelled op still awaiting its outcome).
5. Operator controls (framework methods, host/command `marks` verb, CLI
--awareness): cancel stops all further sends/retries of a batch (prepared
batches included; never removes); retract queues removal across batches by
(route, channel, message, emoji), supersedes adds never sent, skips keys
where nothing ever left the host, and discloses unresolved add requests;
release queues a held batch. All operator-logged.
6. Multi-record appends are transactions (txn ids + commit record), so a torn
append applies nothing; a torn final line is truncated (or a complete but
unterminated record terminated) before the next append.
7. The v2 JSON ledger is imported once (deterministic ids, sha256
import-complete record): applied -> confirmed, permanent-failure -> failed,
pending work held; the file is renamed .migrated-v2 (retried if needed).
Receipts: SurgeryMarkerFacts (scope, unmarked, notRemoved) on every
markers result; marks chosen without any journal path are `not-scheduled`.
Offline recovery collects only addressing metadata + tags (never content).
Tests: discord-awareness-outbox (18: selection, write-ahead, startup
recovery, cancel/retract/release, retry-vs-unknown, txn/torn tail, v2
import, drain classification, suppression resume, host/command undo/marks/
hide), surgery-marks-receipt (10, real store + fake Discord MCPL server:
default local, addressed, frozen refs, non-gating, not-scheduled/unresolved,
cancel/retract with a timed-out add, no-journal), discord-awareness-delivery
(5: startup not gated, mandatory deadline with requestTimeoutMs 0, route
down then up, corrupt journal, branch switch inert), offline-recovery-branch
(6). mcpl-awareness-barrier and its fixture are removed (behaviour gone).
…nd the journal README: the offline-recovery section now says recovery is local by default and explains --marks addressed|all, --dry-run counts and the journal path; the old ledger/projection/global-generation paragraph is replaced by --awareness controls; a new "Live surgery and Discord awareness marks" section covers marks/preview/frozen refs, the receipt statuses (including not-scheduled when no journal exists), background non-gating delivery and the deadline/emoji config, one-shot semantics with unknown outcomes and held batches, the operator controls, host/command undo/hide marks and the marks verb, and the one-time v2 import. Changelog: breaking (default none + who must act/migration/unchanged; the DiscordAwarenessOutbox API), changed (one-shot; delivery no longer gates MCPL; corrupt journal still stops startup), added (preview + controls on every surface). anima-research#246's fixed fragment drops "behind the same MCPL data-plane gate as before", which this branch makes untrue.
…ed or returned June (#43747) pointed out that message-only logging is not bounded logging: an MCPL tool result's error text or a filesystem error has no length limit. On this lane it is also persisted: every failed or unknown outcome's error lands in the awareness journal, which is re-read on each operation. boundDiscordAwarenessText (500 chars: head + tail with the omitted length, never splitting a surrogate pair) is applied in recordOutcome before journaling; in the drain after permanence is classified on the full text and before the log line; and on the receipt/opsAlert errors of activation/retire failures, hide-mark preparation failures and background-delivery failures. Test: a 1 MB mixed-width string with an emoji near the cut stays <= 500 chars with no lone surrogate, and the journal holds only the bounded copy.
…ix June's review findings Storage (June #44070): the awareness journal moves from an fsynced JSONL file to typed Chronicle records on the shared RecordJournal (Basil, 2153e3d, merged as a shared base; Hugo's helper approval room-220 #41860). One entry = one group of records that apply together (replaces txn/commit records, the parser and torn-tail repair). The outbox keeps reduced state in memory, checkpoints every 256 entries (constructor seam for tests), reloads after an ambiguous append (RecordJournal unreconciled guard). Constructor is now (store, {legacyPath?, checkpointEvery?}); discordAwarenessOutboxPath names only a pre-journal JSON ledger to import; every framework has a journal, so the "marks chosen but no journal" receipt branch is gone. Crash ordering this fixes: Chronicle buffers branch-state commits until store.sync(), so a JSONL activation could outlive the switch it asserted. Activation, discard and suppression-complete now append with afterCommittedState (sync first) + durable; batch prepare, dispatch write-ahead, startup reconciliation and operator acts are durable; outcomes are not (a lost outcome reads as unknown and is retried). Review findings, each with a regression: - #44236 cancel vs a running drain: the drain no longer iterates a snapshot. claimDispatch(serverId, skip) recomputes due dispatches and writes the chosen one ahead in one synchronous step; recordDispatching narrows a stale dispatch to still-due ops and returns [] when none remain. - #44283/#44349/#44458 legacy uncertainty: v2 import no longer synthesizes attempts or presence. Each entry with attempts becomes legacy-evidence facts (attempts, lastAction, lastStatus, bounded lastError, lastErrorKind pre-write-refusal|discord-answer|other, outcomesUnrecorded = earlier attempts + last unless applied/pre-write/Discord answer). Retract now queues a removal for every selected key whatever history says (idempotent removal covers absence), supersedes due adds, and discloses native unresolved requests (per dispatch) and keysWithLegacyUncertainty; cancel reports legacyOutcomesUnrecorded; the view reports legacy facts. - #44493 release vs unfinished body: only activation (surgery or its resume) certifies a suppression body; release does not. The resume selector takes every non-discarded explicit suppression with an incomplete body on its exact target, whatever its marks state; prepared ones are crash-completed, others get suppression-complete only. - #44517 cancel history: cancel receipt gains unresolvedAttempts across all attempts (a timeout then a confirmed retry, or then a permanent failure). - #44551 offline CLI separates body from marks (settleActivation: activate, else retire, else unresolved; result.markers); barrier on every record that certifies or retires a body; turn-based host/command undo honours marks via an addressing-metadata diff of the agent's context. - #44585 a newer opposite request supersedes due retries (retryable failures and pre-write refusals included) of the old one, symmetric; history kept; the in-flight request still settles first. - #44609 failure paths restore the source first and retire the prepared batch only when the store is confirmed back on it; otherwise it stays as the unfinished body's record and the error says so; a failed retire is reported and alerted, never masking the surgery's error. - #44638 cancel(target) also takes a retract request id (request-cancelled); the view lists retract groups (kind 'retract') so the id survives restart. host/command marks verb param is now `target`. - Kit/Agnes-1009 (room-203 #44126): host/command hide takes reserveStoreForSurgery around its removal and marks (agent-busy otherwise). Tests: discord-awareness-outbox 24, surgery-marks-receipt 14 (real store + fake Discord server: cancel during delivery sends exactly one request; restore-unconfirmed keeps the batch; failed retire reported; prepared -> held -> release -> target-startup resumes the body), delivery 5, offline 7 (body stands when activation fails), operator-surgery 18: 68/68.
… requests' retries; drains stop with their route June #45033 (probe /tmp/june-1016-799-opposite-probe.ts), two lifecycle gaps at 799a16b: - an add on the wire when a retract arrived was not superseded (only `requested` ops were), so its later retryable failure put it back ahead of the remove; - an unknown add superseded by nothing (unknown ops were left alone), so cancelling the retract made it the newest live op again and revived its retries. requestRecords now retires the future retries of every opposite-action op that has not ended (requested, on the wire, or unknown): op-cancelled 'superseded'. Attempts stay as history; unresolved ones are still disclosed (keysWithUnresolvedAdds); the all-ops in-flight fence still makes the key wait for the request on the wire; a confirmed late answer still reads as confirmed. Cancelling the newer request stops it without reviving anything. Shutdown churn (found re-reading stop()): after closeAll, a running drain claimed every remaining op once, writing a durable dispatching record and a not-sent outcome for each (one store sync apiece) while the host stopped. The drain now stops claiming when its connection is gone (work stays queued; reconnect starts a new drain), and stop() awaits running drains after closing MCPL so the in-flight outcome lands before the store closes. Tests: in-flight add -> retract -> retryable failure -> next claim is the remove; unknown add -> retract -> cancel retract -> nothing due; the mirror (remove in flight, new add, retryable failure -> add); all across a restart; a drain whose route goes away writes only the in-flight attempt. Awareness set + record-journal 78/78.
…ed active is not body proof; startup and offline body barriers; reserved turn undo
June #45105 (probe /tmp/june-1016-799-prepared-retract-probe.ts):
prepare -> retract -> activate let activation's adds cancel the later
retract's remove. Activation completes the surgery's earlier choice; it is
not a new one. Every op now carries `authorization`, the journal position
(state.position, one per applied record, checkpointed) of the act it
carries out: a batch's prepare for its activation's adds (activate and
startup crash completion), the retract record for its removals, the
release record for a release's requests. requestRecords takes the
authorization (default: a new act, later than everything): an op with an
opposite op authorized after it, in any state, even cancelled, is recorded
superseded at birth (cancelling the later act never revives it); otherwise
it retires older-authorized opposite ops not yet ended. retract('all') now
covers every key of every batch not discarded (prepared, held, active).
activate()/settleActivation report the adds actually queued. A release
stays a new act (wins over a retract made while held). The body obligation
is untouched: a suppression whose marks were retracted before activation
still records suppressionComplete on activation.
June #45108 (probe /tmp/june-1016-799-legacy-body-probe.ts): importLegacy
emitted `activated` for v2 active batches, which certified a suppression
body the old JSONL/Chronicle window could have lost. It now emits
`legacy-activated` (marks history only); the resume selector picks the
imported explicit suppression on its exact target, resumes intervals
idempotently, and records suppression-complete after the sync.
June #45172:
- recoverAtStartup's crash-completion activations append with
afterCommittedState (sync first; a failed sync appends nothing), as the
store may be caller-supplied and already open.
- offline body failure: restore the source, then retire the batch (discard,
after-sync) once the source is confirmed; if not confirmed, keep it
prepared and say so; a failed retire is reported. The error composes
failure; restore failure; retire clause, with the original as cause.
June room-203 #45217 / Agnes #45253 split: turn-based host/command undo
takes reserveStoreForSurgery around the whole act (before snapshot, the
undoLastTurn loop, after snapshot, marks scheduling), refusing agent-busy
while any agent sharing the store is mid-turn; released in finally.
Tests: authorization order (prepare->retract->activate; overlapping
prepared batches; retract('all') over prepared; cancelled retract does not
revive; later choice wins; release wins over retract-while-held; body
completes with retracted marks; all across restart); startup sync-first and
nothing on failed sync; imported active suppression not certified (outbox)
and resumed on real startup (receipt test, idempotent second startup);
offline failure retire/keep; turn undo refused while another agent is
mid-turn. Awareness set + record-journal: 83/83.
…tive is not body proof README live-surgery section and the three changelog fragments describe d44826b's semantics: between opposite requests for one reaction the later authorization wins (a surgery's adds carry its choice's moment; release is a new act); retract('all') covers prepared/held/active batches and cancelling it never revives what it overrode; host/command turn undo holds the store like hide; an imported active suppression is verified and resumed rather than taken as body proof.
… not its reconciliation state; cancel discloses released removals June #45470 (probe /tmp/june-1016-799-legacy-status-probe.ts, with the actual 64c480b writer): v2 deliveryStatus is the old writer's reconciliation state (setDesired rewrites it from the cached markerPresent without any request), not the last attempt's outcome. An unknown add reconciled onto the source read as applied -> lastAddConfirmed:1, outcomesUnrecorded:0; a confirmed add reconciled onto the source read as pending -> its attempt unrecorded. The 64c480b writer recorded a success by deleting lastError (recordSuccess) and a failure by setting it (recordFailure); nothing else touches it. Evidence now carries lastOutcome derived only from that: confirmed (no error after an attempt), not-sent (pre-write refusal), refused (Discord's definitive answer), unrecorded (anything else). outcomesUnrecorded = earlier attempts + the last when unrecorded. The old status is kept as oldDeliveryStatus, documented as reconciliation state; the view's lastAdd/RemoveConfirmed count lastOutcome confirmed. Held release actions still follow the old writer's pending work (that is what its reconciliation state legitimately says). Same probe: cancel(batch).unresolvedAttempts filtered adds only, so a released removal's unknown attempt went undisclosed while the view showed it. Cancel now counts every request the batch made (adds and released removals). Test: entries exactly as 64c480b leaves them (unknown add then source reconcile; confirmed add then source reconcile) give lastAddConfirmed 1 and outcomesUnrecorded 1; the pending removal is held; release, unknown then confirmed removal, cancel -> unresolvedAttempts 1. Outbox 30/30.
…uthful history June #45522: cancel() and cancelRetract() returned early with zero counters once the group was cancelled, so a confirmation landing after the first cancel read as confirmed:0 on a repeat while the view showed it. The history projection (confirmed; in flight; unknown, including attempts left without an outcome by a previous process) is now computed for every call, whatever each request's cancel state, in one helper (stopRequests); only the stopping records (and `cancelled`, newly stopped requests) depend on the group not being cancelled already, and a repeat writes nothing. Test: batch with one add on the wire and one due -> cancel (cancelled 1, inFlight 1) -> the in-flight add confirms -> repeat cancel (cancelled 0, inFlight 0, confirmed 1, unresolvedAttempts 0, no new journal records); the same for a retract request.
…tablishes and labels the old status as reconciliation state
…ng intent; offline branch creation settles like any body failure June #46104 (probe /tmp/june-1016-511-offline-name-probe.ts): the offline recovery called contextManager.branchAt() outside its body try/catch, after prepare. With branchName naming an existing branch it threw "Branch already exists" and left a prepared target-branch batch; starting that pre-existing branch later crash-completed it and queued an add for a message still in context. - offline: refuse an existing target name before anything is prepared (also in a dry run, which would fail the same way), and create the branch inside the body try, so a creation failure restores/confirms the source and retires the batch (or keeps it, saying so) like a failed switch or redaction. - live rollbackToMessage/suppressMessages: the same pre-check (OperatorActionError 'invalid'), before reserving or preparing; their branch creation was already inside the failure handling. Tests: offline recovery onto an existing branch with marks chosen is refused and journals nothing; an injected branchAt failure retires the batch on a confirmed source; live rollback and suppression onto an existing branch are refused with no batch. Awareness set + record-journal 87/87.
…e is synced, and reports what the journal holds Greptile review of anima-research#250 (P1 "Marks return after failed scheduling", P1 "Hide marks can outlive removal"); both reproduced at dc2928c. scheduleAppliedMarks prepared its batch after the change was already applied (an in-place hide's redaction, a turn undo's switch), with only the after-append durability sync. A kill between the batch append and that sync could keep the batch (appends reach the OS at once) and lose the buffered removal; a hide keeps its branch, so startup activated the batch and marked messages the agent still sees. And when prepare() threw after its record reached the store (a failed post-append sync), the receipt said `not-scheduled` ("nothing was recorded") while the batch sat prepared for the current branch, and startup delivered it. The outbox now owns that protocol: settleApplied() builds the batch for exactly the change's branch, appends it with afterCommittedState (sync before append, the barrier activate/discard already use), and settles it like any prepared batch (activate, else retire, else unresolved). If the write fails, it reads the journal back: a batch that is not there is `not-scheduled`; one that is there is settled and the receipt follows that; if the read-back fails too, `unresolved` with the batch id. A journal that can't be read at all writes nothing (`not-scheduled`). The framework maps every settlement onto the receipt and ops alert through one path (settledMarkerReceipt) shared with rollback/suppress; its wording no longer says "batch retired" for a batch never recorded. The receipt docs (operator-log.ts, README) and describeMarkers say what not-scheduled and unresolved now cover. DiscordAwarenessPrepareInput and DiscordAwarenessSettlement are exported. Tests (test/discord-awareness-outbox.test.ts), through host/command hide over a store whose journal seams can be observed and faulted: - the removal is synced before the batch is appended; - a failed barrier sync before the append: not-scheduled, nothing recorded, startup activates nothing; - a failed sync right after the append: the batch is settled (queued) and the receipt names it; - every sync from the append on fails: unresolved, the receipt names the retained prepared batch, and startup may activate it, as it says; - the read-back after the failed write fails: unresolved, with the id. All five fail at dc2928c on their status/ordering assertions.
…e new grant is established Greptile review of anima-research#250 (P1 "First reconnect loses channel registration"), plus the same-root case Mira-1605 identified in review. A reconnect resets the server's grant (a fresh initialize is a fresh §5.3 policy epoch), but McplServerConnection.attemptReconnect paused only the data plane, and anima-research#250's reconnect listener then called readyControlPlane() before re-running registerMcplServerFeatures. So control traffic from the fresh transport reached admission against the empty grant: a channels/register sent right after initialize (as chat bridges do) was refused with -32002 ("initial policy not yet established") and never replayed, leaving the route's incoming messages unknown channels until the server registered again. Two paths led there: - a stub whose first connect failed and which reconnects while startup is still staging other servers: its control plane was still closed until the listener's new readyControlPlane() opened it (the regression); - any connection that was already open: its control plane was never closed at the boundary (pre-existing; the old barrier code paused data only, too). attemptReconnect now closes both planes with the grant reset, as initial connect holds them, and the framework's reconnect listener no longer opens the control plane early: it re-establishes the grant (policy responses are never event-buffered, so holding control cannot deadlock the exchange) and then opens the planes through readyMcplPlanes, as it already did, also when the exchange fails. Quiesce still pauses only the data plane (pauseDataPlane's doc now says that is its use). Tests: - test/mcpl-reconnect-registration.test.ts (new, with fixture reregistering-mcpl-server.mjs): the real framework, a server that registers its channel on notifications/initialized and exits once on demand; after an ordinary reconnect the fresh process's registration must be answered without error. At dc2928c it is refused -32002; with only the listener change, or only the connection change, it is still refused; both together pass. - test/mcpl-reconnect-events.test.ts: after a reconnect, a control event is held until ready(), then released (fails at dc2928c). Changelog: mcpl-reconnect-registration.fixed.md.
…n the operator log Greptile review of anima-research#250 (P2 "Offline controls skip the log"), with the extension agreed in room-245 (#48091, #48115): the CLI's own recovery. The live surfaces record every operator mutation in <store>/operator-actions.jsonl (who asked, from where, what it did, or the error), and the changelog promises that each awareness control is recorded. agent-framework-recover --awareness cancel|retract|release called the outbox directly and only printed the receipt, so the operator history lost every offline control. The CLI's recovery, which anima-research#250 lets publish marks (--marks), was not recorded either, so nothing said who chose those marks. The CLI now appends, best-effort (OperatorLog.append never throws, and the OS account lookup falls back to {via: 'cli'}): - for cancel/retract/release: kind awareness-<verb>, agent '*', params {target} or {batchId} as the live wrapper logs them, and the receipt or the error; - for a recovery: kind 'recovery', the agent, params (anchor, any suppressions, the marks choice as 'none' or {scope}), and the result (branches, messages removed and suppressed, discordRefs, markers), or the error when it is refused or fails. The requester is {via: 'cli', name: <OS account>}, as the web UI records its basic-auth user. A dry run and a list record nothing. The log path is the host's default under the store. Tests: test/recover-cli-operator-log.test.ts spawns the compiled CLI: list records nothing; cancel and retract record their receipts; a refused release records its error; a dry run records nothing; a recovery records its marks choice and the receipt it printed; a refused recovery (existing branch) records its error. Docs: the CLI help, README's offline recovery section, and the controls changelog fragment.
…he operator log Greptile review of anima-research#250 (P2 "Turn marks aren't logged"). Turn-based host/command undo accepts a marks choice and schedules marks for the messages its turns removed, but its only operator-log entries came from undoLastTurn(), one per turn, with the turn and branches. So nothing recorded who chose to publish (or not to publish) marks, while hide and rollback (message-granular undo) record their choice and receipt. After scheduling, the command now records one entry: kind 'undo-turns', the requester, params {turns: requested, marks: none | {scope}}, and result {undone, markers}. It is written whenever at least one turn was undone, `marks: none` included (agreed with Mira-1605: the local-only choice is worth recording, as hide and rollback do); a command that undid nothing changed nothing and records nothing. The per-turn undo-turn entries are unchanged. Test (test/discord-awareness-outbox.test.ts): a marked turn undo, a local one, and one with nothing left to undo log exactly two entries, each with the receipt the command returned.
…y entry Greptile review of anima-research#250 (P2 "Checkpoints repeatedly copy all history"). Every 256 entries the outbox wrote a checkpoint holding its whole reduced state, and Chronicle records are never removed, so stored copies grew quadratically with the marks ever requested. The reduced state keeps every request and every attempt, because receipts (cancel's and retract's unresolved counts, view()), operations() and authorization order read that history; so each checkpoint copied all of it. Measured with the compiled outbox (fsync stubbed, 20 marks per batch, one confirmed attempt each): - 2,000 marks / 4,200 entries: 10.8 MB with checkpoints, 1.65 MB without; opening took 0.14 s and 0.11 s; - 8,000 marks / 16,800 entries: 149.6 MB vs 6.6 MB; opening took 0.70 s with checkpoints and 1.43 s without; - 20,000 marks: 918.5 MB with checkpoints (the run without them was not completed). Rather than keep a separate summary of that history, the outbox writes no checkpoints and opening replays every entry: CHECKPOINT_EVERY, the checkpointEvery constructor option, snapshotState and the checkpoint branch of append() are gone. A journal an earlier build of this branch checkpointed is still read correctly: RecordJournal.load() starts from its latest checkpoint, and restoreState (kept, with JournalSnapshot) reduces it. RecordJournal itself is unchanged; it is the base this PR shares with anima-research#247. Agreed with Mira-1605 in room-245. Tests (test/discord-awareness-outbox.test.ts), replacing the checkpoint test: - a 300+-entry history writes no checkpoint record and a fresh reader reproduces the writer's view and operations (fails at dc2928c: one checkpoint); - a journal checkpointed by an earlier build (written through RecordJournal) and continued past it reads the same as the writer's full state; a mutant that drops the snapshot's position fails it.
…vers, and what each note claims A review of this change since its approval found texts that said more than the code does, or something else. This commit is that text, plus the one stderr wording a note depends on. The code and test fixes follow in their own commits. **Origin:** - executeToolCall's doc said it "always carries agent origin" and that unnamed callers "fail safe to agent semantics". It now says what holds. The entry is shared with model callers, so it never confers module trust, and an unnamed call is the agent's own because its callers here are models: SubagentModule's ephemerals, code_execution scripts, and puppetToolCall, which names 'puppet'. So a host or an operator must name its origin for a gate to see it. - The registry closure's comment says the same. - normalizeOrigin's doc no longer says "a falsy one must never read as the agent's own" beside a null that does. Absent or null is the agent's own, 'puppet' the operator's, and any other value, a falsy one included, the host's. - The code is unchanged, as intended: the approved scope is honest attribution for well-behaved scripts, not a tamper-proof boundary. **What the gate covers:** hasOperatorChangeGate's doc said an imposed change "applies only through it". It now names what's staged and what refuses, and says that the framework's public body-change methods (undoLastTurn, redo, rollbackToMessage, suppressMessages) are the host's own and aren't gated. **Attempts:** CUT_KINDS's comment said "the cuts and hide" over a set of the cuts alone. It now says why: only a cut's attempt can be unresolved and settled by an operator. A hide is journaled as attempts too, but its effect can be read back, so recovery re-runs it, removing only staged messages still present and unchanged. The added note says the same. **The notes:** - added: without a gate, every path behaves as before except that resume() refuses while a cut a gated run left unresolved or abandoned holds the body; messagesAfter is new for both undos, not "as undo-messages already did"; the origins are the agent, the operator through a puppet, or the host, not a separate "operator". - breaking: MCP branch_create is refused only with switchTo; undo can be staged through the host's gated undo, while redo and branch switching have no gated route and are refused; an agent's own delegation to another agent acts for the host, while a puppet's or the host's keeps its origin; the stderr count is exact from Python 3.12. **Docs:** - getAllUtilities and ModuleContext get their own doc comments back, which had drifted above getAllSubconsciousTools and ModuleMessageOptions. - The two "never a recount" docs name the older-build exception that recordedQueued already counts. - The reset preview's doc says a key with nothing configured returns to its default (the budget, the think-text policy) or keeps its value (tailTokens), as the code does. - activateStaged's doc says an unknown or prepared batch is the caller's error and throws. **The stderr note:** before Python 3.12 the count is of tasks created while the script ran, so the note says that there, and "this script started" from 3.12. tool-call-origin's test accepts either wording, whichever the interpreter reaches. **The check, from a clean worktree of this commit as AF's CI runs it (npm install; node 24.19.0, npm 11.17.0):** `tsc --noEmit` and the build are clean, and `npm test` passes 1535/1535 across 140 suites. That's the same count as at 551c363. An earlier run of this same commit reported 1529 with none failing: six tests in two files (channel-registry-routing, mcpl-tool-lifecycle) were never reported. The rerun reported all 1535. Co-Authored-By: Agnes (Claude Opus 5.5) <noreply@anthropic.com>
…ders The same review's code findings, each fix with a test. The previous commit is its text. **Crash bookkeeping** - Startup recovery skipped any record that had an outcome. So a crash after the outcome but before its operator-log line and completed mark left both to a host retry. Startup now finishes them, marked recovered at startup. - An attested commit recovered at startup was logged like a switch the framework saw. Its log line now carries the attestation (verdict, via, requester), and the console line says it was attested. - An attempt record whose write threw may still have landed, leaving an attempt that read as unresolved after a restart. When it's there, its failure is recorded too, so the restart reads it as not committed. - The drop is journaled before the staged choice is discarded, and it stands if the discard fails. A batch left behind is inert, because a dropped change never applies. - A staged choice whose record may have landed though staging couldn't tell is retired. - Resolve checks that the agent is registered before it journals a verdict that would restore it. A refusal now leaves the attempt unresolved, as it was. **Resolve's receipts** - A committed attestation that the active body contradicts stands, as the operator's verdict. Its receipt, live and offline, now says the active branch isn't the attempt's destination. - A repeated resolve's refusal names the settlement on record (settledState): its verdict, who gave it, how, and when. **Liveness** - A lease waiting behind a direct surgery is granted when the surgery releases. The release used to wake only parked creations, which return early while a lease waits. - A release whose deferred-write flush throws still grants the next waiter and wakes parked creations, and the callback's own result stands. The flush failure is logged, and its writes stay deferred. **Admission** - While a reservation holds the store, a tool_result is deferred like any other write. Its exemption is for a draining turn, and none runs then. - A module's editMessage and removeMessage under a reservation wait for its release (flushDeferredMessageOps), so a body change's evidence can't move underneath it. - A conversation fork enters the store under the creation admission. A waiting lease isn't granted while the fork opens. stop() refuses a fork once it has begun and waits for one already opening. A fork parks only behind a held reservation, never behind a lease that is only waiting: the event loop awaits the fork, and grants that lease. - Branch.delete refuses a branch that an unsettled operator change's attempt names as its source or destination (operatorChangeReadingBranch). Recovery reads those by name. **Readers** - The Discord awareness outbox refuses a checkpoint with no snapshot instead of reading the entries it covers as gone. - The operator/changes reader refuses a drop after attempts and an attempt after a drop. - It refuses a second record of an attempt's number in replay, as it already did in a snapshot. - **Also:** a resolution entry for a hide is refused in replay, as in a snapshot. - The store identity comes from the first identity record that is usable. An unusable oldest record used to mint and append a new identity at every start. **Others** - No external id is mapped to a deferred write's empty id. - The patch check and the reset preview carry updateRuntimeSettings' "cannot prepare a smaller window live" refusal, so the gate never stages what the apply refuses. The reset test now raises the budget, since its strategy has no hot settings. - A replay returns what the first call recorded: not-scheduled for a retired batch, and the recorded count for an active one. - Placement.durable is true only when the caller asked, as its contract says. - The offline operator-change verb opens only an existing store. - A wake that isn't the running exec's own work is refused for that reason, as tool calls are. - A staged choice that can't be activated because this process has no outbox is reported as not scheduled, with an ops alert. - A resolve refused on an unreadable journal is logged, and the usage text lists resolve among the recorded acts. - IsDrainingStream's dead clause is gone. - **Notes:** the breaking note says that branch.delete refuses a branch an unsettled change reads. The stop() note says what stop() waits for and what it cancels. **Tests** - operator-change-gate: - bookkeeping finished at the next start; - a contradicted attestation and a repeated resolve; - the unknown-agent refusal left unresolved; - a landed attempt whose write threw; - the startup log entry's attestation; - a repeated offline resolve; - a drop whose discard fails, and a begun change that can't be dropped; - a staged choice retired after an ambiguous write; - a descent refused at staging; - branch.delete; - the store identity; - and twelve reader refusals, guard by guard. - discord-awareness-outbox: the null-snapshot checkpoint, and the replays. - safe-boundary-lease: a lease behind a surgery, a throwing release flush, a deferred tool_result, and held edits and removals. - module-targeted-delivery: durable as asked, and the external id. - conversation-routing: a fork behind a held lease, and one refused at stop(). - recover-cli-operator-log: a missing store, and a refused resolve logged. - The stale wake's wording and the missing-outbox alert have no test of their own. **The check, from a clean worktree of this commit as AF's CI runs it (npm install; node 24.19.0, npm 11.17.0):** - **The suite:** `tsc --noEmit` and the build are clean. `npm test` reported 1552 of 1552 passing, across 140 suites, with all 22 new tests among them. Five tests went unreported, with none failing: those at the end of channel-registry-routing.test.ts, a file this commit doesn't touch. The same happened in a run of the text-only commit before it. Run alone at this commit three times, that file passes 24/24 each time. - **Red first:** this commit's six test files, built and run on the previous commit's sources, fail 25 tests. They are all 22 new ones, plus three extended ones: the offline committed verdict's attestation, the retired batch's replay, and the repeated offline resolve. Co-Authored-By: Agnes (Claude Opus 5.5) <noreply@anthropic.com>
…k re-run that membrane's error event ended without its error
The same review's test findings, plus one code fix that a test exposed. The two commits before this one are its text and its code.
**What a faithful fake found.** The setup-failure fake threw synchronously from streamYielding. Membrane never does that: it returns the stream at once and reports a provider's refusal on it, as an `error` event. The fake now fails as membrane does. Two tests then failed, against real defects in the unstick re-run:
- **A re-run that the error event ended** was settled "the re-run ended without a response", not with the provider's error. The error branch now settles it with the error, as the setup path's catch already did.
- **A re-run that a retry or a provider hold carries on** from that branch was settled failed by the ending stream's teardown. The retry had started its own stream, and the hold kept the request, but the teardown looked only at queued requests. It now asks unstickRunCarried, as the setup path does. That counts a successor's turn, so the ending stream's own token is passed to keep its own turn from counting.
- **Both routes stay tested.** The re-run test runs once with the failure on the stream ('event') and once before it ('throw'), since a compile can still throw there.
**Tests that now say what they claim**
- The undo whose awareness delivery fails is asserted to report that failure on its own, as an ops alert. The receipts the finding named come from activation, not delivery. The undo's own receipt says queued either way.
- A resolve with no reason is refused, and leaves the attempt unresolved.
- The provider-hold test checks that it waited out the hold, and that the host was asked again at its end and released it.
- A request whose signal was already aborted is refused, with nothing granted or queued.
- A creation parked behind a waiting lease proceeds once that lease is withdrawn.
- A stale lease is refused while another lease is held, not only when none is.
- A bounded text whose head cut and tail cut each fall inside a surrogate pair keeps neither half.
- The duplicate re-run caller is checked to have joined the first caller's waiter, not inferred from a 50 ms race.
**Timing, for a loaded machine**
- The subconscious delivery waits up to 10 s, not 250 ms.
- The leftover task waits for the next script to let it go, not 0.3 s, so it's always still running when its own script ends.
- The re-run helper waits up to 10 s, not 3.
**The check, from a clean worktree of this commit as AF's CI runs it (npm install; node 24.19.0, npm 11.17.0):**
- **The suite:** `tsc --noEmit` and the build are clean. `npm test` reported 1556 of 1556 passing, across 140 suites, with the four new tests among them. The same five tests at the end of channel-registry-routing.test.ts went unreported, with none failing, as at the commit before.
- **Red first:** this commit's five test files, on the previous commit's sources, fail exactly the two that the error event's defects break: the 'event' re-run and the retry-and-hold re-run. The other new and extended tests pin what already held, and their removals show they bite.
- **Removals:** each of eight, taken out on its own, fails its test:
- the error branch's settle (the 'event' variant fails, the 'throw' one still passes);
- the teardown's successor check;
- the pre-abort guard;
- the withdrawal's wake;
- heldLeaseTokens' identity check;
- the head trim and the tail trim;
- the delivery's ops alert (this one run by hand in the working tree, where the scripted removal didn't build).
Co-Authored-By: Agnes (Claude Opus 5.5) <noreply@anthropic.com>
…d clears, whatever leases wait Ada-1017's review of the previous three commits found that the conversation fork's admission parked and woke on different conditions. It parked while a store hold was set, but it woke through the ephemeral creations' wake (notifyBoundaryCleared), which stays asleep while any lease waits. So a fork could park behind a hold, and then stay parked after the hold cleared, if a lease had arrived meanwhile. The event loop would be blocked in that fork's creation, and the loop's scheduler pass is one of the places a waiting lease is granted. Today nothing becomes busy across a hold, so the release's own grant gets there first, but that's an invariant the fork's rule shouldn't rest on. - **Their own wake:** conversation creations park on their own waiter list. It's woken whenever a hold clears (the reservation's release) and when admission closes (stop()), whatever leases wait. A woken fork takes its creation ticket, so a waiting lease waits for it, or it parks again behind a newer hold. Park and wake now test the same thing. - **The teardown's check:** an ending stream's teardown asks whether another turn carries an unstick re-run on, by comparing the agent's turn token with its own. It now passes its token as given, even when undefined, so a stream that somehow had no token can't count its own trigger and leave the re-run unsettled. Every re-run comes through a turn with a token today; this keeps it so if that changes. - **A doc:** flushDeferredMessageOps says why it runs after the release's deferred-write flush. A coalesced replacement held across the hold then lands its new message before the old one is removed, which ends as it would have with nothing held. **Tests** (conversation-routing): - **New:** a hold, a fork parked behind it, a lease that arrives, and an agent busy when the hold clears, so the lease is still waiting then. The fork proceeds, and the lease is granted after it. - **The fork tests from before** (behind a held lease, and refused at stop()) asserted "no fork yet" after a fixed 50 ms sleep. Under load the channel event might not have been processed by then, and the assertion would pass for that reason. They now wait, with a bound, until a creation is parked behind the hold, then assert. Ada-1017's review found this too. **The check, from a clean worktree of this commit's tree as AF's CI runs it (npm install; node 24.19.0, npm 11.17.0):** - **The suite:** `tsc --noEmit` and the build are clean. `npm test` reported 1557 of 1557 passing, across 140 suites, with the new test among them. The same five tests at the end of channel-registry-routing.test.ts went unreported, with none failing, as at the commits before. - **The removal:** with the fork parking on the ephemeral creations' wake again, the new test fails. Its fork is seen parked, then never proceeds. Co-Authored-By: Agnes (Claude Opus 5.5) <noreply@anthropic.com>
|
@slimepriestess, since your approval of
Each commit message has its clean check: npm install, tsc, build and npm test, as CI runs them. The full suite at the head passes, and the description has a new section with the details. — Agnes-1009 |
|
To be precise about "the full suite passes": at the head, 1557 of 1557 reported tests pass. The five tests at the end of — Agnes-1009 |
…re a marks scope's refs, and answer while no framework is bound A marks scope must now carry its previewed refs. agent-framework evaluates a scope without refs when the surgery applies, which would mark messages that arrived after the preview; every surgery here is previewed, so the wire treats that form as malformed. MarksChoiceWire's refs is required. The host's own session and branch check on rollback and suppress is gone, with the expectedSessionId and expectedBranchId fields, and so is the session bound on a retry's quiesce. The binding is the framework's check of the preview's context (its store identity and active branch name), made under its own reservation, atomically with the change, and every session has its own store and identity. What the host's ids added beyond that was a branch name reused after its branch was deleted, or a store copied with its identity, which doesn't pay for a second binding rule with other semantics. The SPA's stale marking of an open dialog is separate: it depends on the welcome arriving before the operator confirms, so it isn't part of the binding. A retry's quiesce stays bound by the preview's store identity. The preview still reports the session and branch it was computed on, for the dialog's own comparison. A framework without live surgery, or without preview-bound surgery, is now refused with code 'unsupported' before the binding is looked at; the missing-method refusal carries the code too. While no framework is bound (a session switch, between the old framework's stop and the new one's bind), the WebSocket dispatcher answered every request with a generic 'host not ready' error, so a dialog waiting on its surgery's result stayed pending until a reload. The surgery, preview and journal requests now reach their handlers in that state and are answered in their own frames: a surgery and a journal action with code 'stale', a preview and a listing with the reason. The branch panel lists the awareness journal again with every welcome while it is open, so a reconnect, a restart or a switch back to the same session replaces the listing and its framework instance. A listing that failed shows its error and no longer says no marks are recorded. Text: the doc's opening no longer says the pinned agent-framework provides live surgery; 'stale' covers an unbound confirmation and no bound framework, and the framework's other codes are named; comments say the framework compares the branch name; the instance id's reason is a restart or a switch back to the same session, since in a switch's own interval no framework is bound; the changelog names anima-research/agent-framework#255 and says the retry's quiesce is bound to the previewed store. Tests: the protocol refuses a scope without refs, accepts exactly 20,000 refs and refuses a rollback preview without its message. The host tests wait for the validator's error frames instead of sleeping, check that a receipt of 'none' broadcasts no journal, that the framework's own check refuses another store or branch with the preview's context in hand, that an observer with the ops scope can't act on the journal, preview or roll back, that a retry's quiesce is bound by the store alone, and that every surgery, preview and journal request is answered while no framework is bound. Checked from a clean worktree at this tree (77b5998), on Linux with node 24.21.0 and bun 1.4.2: the root typecheck is clean; the web typecheck reports only the App.tsx stopLoadOlder error that predates this change; the strict-lockfile web build passes; bun test: 1227 pass, 0 fail across 122 files. 14 removals, each of a guard this commit adds or newly pins, each fail a test.
| // Woken when the hold clears, whether or not a lease waits by then | ||
| // (unlike the ephemeral creations' wake, notifyBoundaryCleared): a | ||
| // fork that waited for a waiting lease would wait for the loop it's | ||
| // blocking. Woken, it takes its ticket, or parks behind a newer hold. | ||
| await new Promise<void>((resolve) => (this.conversationCreationWaiters ??= []).push(resolve)); |
There was a problem hiding this comment.
New conversations stall leased scripts
With conversations enabled, a message that needs a new fork waits here for the held lease to release. That wait is inside the current processNextEvent() call, so the framework cannot take another queued event.
If the lease callback is awaiting puppetToolCall(..., 'code_execution', ..., { lease }), the script’s inner tool calls need that same queue to finish. The queue waits for the lease, and the lease waits for the script, until a timeout breaks the wait. Park or requeue the incoming message without stopping the dispatcher from serving tool calls and results.
Knowledge Base Used: Code execution tools
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 7128-7132
Comment:
**New conversations stall leased scripts**
With `conversations` enabled, a message that needs a new fork waits here for the held lease to release. That wait is inside the current `processNextEvent()` call, so the framework cannot take another queued event.
If the lease callback is awaiting `puppetToolCall(..., 'code_execution', ..., { lease })`, the script’s inner tool calls need that same queue to finish. The queue waits for the lease, and the lease waits for the script, until a timeout breaks the wait. Park or requeue the incoming message without stopping the dispatcher from serving tool calls and results.
**Knowledge Base Used:** [Code execution tools](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/code-execution-tools.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| this.running = false; | ||
| this.providerAdmissionClosed = true; | ||
| this.closeStoreAdmission(); | ||
| // Whoever holds the store reservation (a lease's callback, a direct | ||
| // surgery's awaited work) finishes before anything it may use is torn | ||
| // down. Admission is closed, so no other holder can follow it. | ||
| const reservation = this.surgeryHold; | ||
| if (reservation) await reservation.released; |
There was a problem hiding this comment.
Shutdown strands leased scripts
stop() now ends the queue loop before waiting for the granted lease callback. A callback awaiting a puppeted code_execution script can still need queued inner tool calls or results. Once running is false, those events stop being served, so the callback cannot finish normally. Shutdown then waits for a Python or script timeout, which can take several minutes by default.
Keep the queue draining for admitted callbacks while refusing new turns, then end the loop after the reservation releases.
Knowledge Base Used:
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 2436-2443
Comment:
**Shutdown strands leased scripts**
`stop()` now ends the queue loop before waiting for the granted lease callback. A callback awaiting a puppeted `code_execution` script can still need queued inner tool calls or results. Once `running` is false, those events stop being served, so the callback cannot finish normally. Shutdown then waits for a Python or script timeout, which can take several minutes by default.
Keep the queue draining for admitted callbacks while refusing new turns, then end the loop after the reservation releases.
**Knowledge Base Used:**
- [Agent lifecycle and activation](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/agent-lifecycle.md)
- [Code execution tools](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/code-execution-tools.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
What this is
This is the agent-framework side of gating operator-imposed body changes. A host can stage an imposed change, decide when it applies, and know afterwards exactly what happened, including after a crash.
It's the framework producer only. The host contract it serves lives in connectome-host: a resident is told before its body changes, and can answer ready or hold. That contract will be offered separately, along with further framework capabilities it needs, which are still under review and not in this PR.
The problem
Today an operator's undo, hide or unstick, or a puppet's or module's change to an agent's own settings or tool presentation, applies immediately:
A crash between creating an undo's branch and switching to it, for instance, leaves no evidence of whether the body changed. Awareness marks for the removed messages can then be published for a change that never applied.
What it adds
runAtSafeBoundary(intent, fn)holds every agent's turn token across a host callback, and waits out admitted ephemeral creations. A body change and the host's own bookkeeping then happen with no turn running.ModuleContext.addMessagecan target one agent, returns a receipt, and is optionally durable.Module.getSubconsciousToolsoffers tools on the subconscious surface.FrameworkConfig.operatorChangeGateconfigured, imposed changes (agent-settings,tool-presentation,undo-turns,undo-messages,hide,unstick) are resolved absolutely at staging, bound to the store's identity, and handed to the gate instead of applying. The host applies them later through one route,applyResolvedOperatorChangeunder a held lease, which revalidates each against what was staged.operator/changes, a recovery journal for body changes.resolveOperatorChangeunder the lease) or with the host stopped (agent-framework-recover --operator-change list|resolve). Either way the settlement is recorded as the operator's attestation, never as something the framework observed. A hide's attempt is re-run instead, because its effect can be read back: the re-run removes only staged messages that are still present and unchanged.OperatorJournalUnreadableError,OperatorChangeRecoveryError). Each record is validated by what its kind's recovery consumes.operator/unstick): each step is idempotent, and an attempt launched without a recorded outcome reads as interrupted.RecordJournal.load()reportscheckpointed, so a checkpoint with a null snapshot isn't mistaken for no checkpoint.Tests
New suites:
operator-change-gate(100 tests),safe-boundary-lease,tool-call-origin,module-targeted-delivery,subconscious-module-tools. Additions to:record-journal,discord-awareness-outbox,operator-surgery,puppet-tool-call. Each recovery rule was mutation-checked as it was added.Full suite at
5d1c5d8(node --test --test-concurrency=2 --test-timeout=120000 dist/test/*.test.js): 1499 tests, 1497 passing. The two failures also fail on #250's head485a3c7on the same machine, and a full run of485a3c7beside this branch, at the same moment and with the same settings, had them too:#196 finding 2 / vector 34c: a 30 s wall-clock budget. Here 300 coalesced messages took 34–50 s, on485a3c7too, even run alone.R17: its 40 ms backoff counts a fourth recovery wake under load before the assertion reads three.R16, a third test in the same file, timed out in two earlier full runs of the merged head and passed in this one. Under load, its firstrunUntilIdle()can outlast its 50 ms recovery backoff. The recovery wake then starts a turn whose mock response the test hasn't pushed yet, so the turn waits until the test times out. Under identical artificial CPU load it hung in 3 of 12 runs on both485a3c7and this branch. It passes alone on both.Review
Agnes-1009 implemented it and wrote this description. Ada-1017 wrote the staged-intent outbox part and reviewed the recovery design. Two independent reviews:
Both approved
5d1c5d8.🤖 Written by Agnes-1009; implemented by Agnes-1009 and Ada-1017, and independently reviewed by Kit-1008 and June-1016 (house corvids). Comments on this PR from the house are signed by whoever writes them.
Review feedback: Greptile's findings (
5d1c5d8..2206c97)Greptile's review of
485a3c7...5d1c5d8raised seven findings. All seven were valid, and all are addressed in nine commits above5d1c5d8: one per finding, plus two follow-ups to the first that came from our own review. Each review thread has a reply naming its commits and tests.e64e6e6,5de55ca,5b9b058).resume()refuses,forceor not, while the active body is the destination of a cut whose commitment is unresolved or known not to have happened. When the framework leaves an abandoned destination active, traffic is held until the source is restored. The newrestoreOperatorChangeSourcedoes that live. A restoration counts only once the source's context has initialized.f03d223). A re-run whose turn ends before its stream starts is settled and released, unless a retry, requeue or provider hold carries it on.8e89124). A staged presentation edit records the entry it leaves, so revalidation refuses it if a reset would now show different wording.2206c97).stop()closes admission before its first await: turns, provider calls, leases, direct surgeries, and ephemeral creations and runs. It then lets the store's current users finish before tearing anything down.f167efd). The preview names the default pace that a paced descent installs.f4716ab). The unstick journal reader refuses records, entries and reduced ledgers that its readers can't interpret.532146b).The new tests are in
operator-change-gate,safe-boundary-lease,agent-runtime-settingsandcomponent-defaults.Full suite at
2206c97, with the same command: 1524 tests, 1521 passing. Two of the three failures are the load-sensitive tests named above,#196 finding 2 / vector 34c(300 coalesced messages took 47.5 s) andR17. The third isconversation-routing.test.js. In itsfork channel_publishtest, a stubbed channel tool call resolves afterstop()has closed the queue, and pushing its result rejects ("Queue is closed") once the test has ended. That file fails the same way at5d1c5d8on this machine.A limit, stated in the code. These recovery rules cover the framework's own tracked operations. A host that selects branches itself (today's host
/checkout,/redoand/restoredo) must not reselect the destination of a retained unresolved or abandoned attempt: that reapplies its hold, and at the next start its source restoration. Tracked activation for deliberate selection belongs to the further capabilities offered separately.🤖 This section: written and implemented by Petra-1779, and independently reviewed by Theo-1778 (house corvids).
Review feedback: slimepriestess's findings (
2206c97..97ae95a)slimepriestess's review of
485a3c7..2206c97(pullrequestreview-5462207876) probed call origin, the boundary the gate depends on. It found six ways an imposed change was labelled as the agent's own and applied with the gate never asked (P1, P2, P3, P4, P4b and P6), one operator path outside the gate, and a design question. Each is addressed in its own commit (P4 and P4b share one); the reply on the PR maps findings to commits and tests.b46db52). The agent's own origin passes on only to a delegated call for that same agent. A call for another agent acts for the host.5e9e010). Only the framework's own dispatch gives a tool-call event's call its origin. Any other acts for the host unless it names the operator.58139d5). They're notified as the host's, never as the call that emitted the trace.code_executiontasks that outlive their exec (P4/P4b,e5cf57a). Each tool call reports the exec whose work made it, bound in a Python contextvar. Only the running exec's own work is served, as whoever started it. A call from a task an earlier script left running is refused, with a note in that script's stderr.1f6b78c). One normalizer reads every caller-supplied origin. Anything other than absent or'puppet'is the host's.f8a818c). While a gate is configured, it refusesundo,redo,branch.switchandbranch.createwithswitchTo, whole and saying why, since this PR has no way for it to stage them. The host companion, offered separately, is to turn them into staging.97ae95a). The absolute cut stays, which the notice-and-answer contract requires. Undo-turns now reportsmessagesRemovedbeside the change'smessagesAfter, as undo-messages already did.A second limit. While a gate is configured, the framework's own operator surfaces either stage an imposed change or refuse it. The host's own direct calls (
undoLastTurn,redo, a store branch switch) aren't gated: they remain the host's responsibility.The new tests are in
tool-call-originandoperator-change-gate. Full suite at97ae95a(Node 24.21.0, on the house's build VPS):npm testreported 1524/1524. Under--test-force-exitthe runner dropped 11 tests from two files this batch doesn't touch (history-module-realstore,channel-registry-routing), and both pass whole when rerun (28/28, 24/24), so all 1535 pass. Hilda-1882's independent run dropped 5, all fromchannel-registry-routing, so the drops are the runner's and vary by run.🤖 This section: written and implemented by Alfred-1883, and independently reviewed by Hilda-1882 (house corvids).
Approval notes (
97ae95a)slimepriestess re-reviewed
97ae95aand approved it, verifying both panels on the repository's own harness (full dist suite 1531/0) and rerunning the review's probe file. The review left two notes for the record.code_executiontool call reports is self-reported by an interpreter that scripts share with the runtime:py-runner.tsspawns it as__main__, and scripts run underevalwith full builtins and persistent globals across execs and principals. So script code that goes looking can reachsys.modules['__main__']and forge or read the binding (read from source, not probed). In the reviewer's words, cite this PR as honest attribution for well-behaved scripts, not a tamper-proof boundary. A tamper-proof one would need separate interpreters per principal, or the host deriving exec ownership from its own dispatch rather than the frame's field.255-operator-change-gate.added.md,255-operator-change-gate.breaking.md(API server and MCP clients, script authors, module and host authors, hosts) and255-stop-admission.fixed.md. Until now it carried only feat(awareness): make Discord awareness marks an explicit, one-shot choice that never gates MCPL #250's. The breaking fragment tells script authors that a task a script leaves running past its exec now has its tool calls refused, as the review asked.🤖 This section and the fragments: written by Agnes-1009, and independently reviewed by Ada-1017 (house corvids). The staged-marks line describes Ada's own work, which Ada checked for accuracy.
The house's review round (
551c363..e37235d)Since
97ae95a's approval, the house reviewed485a3c7..551c363again in depth. It raised 63 issues: 58 fixed, 5 intended as built, and 1 not worth changing. Four commits answer it, each with its clean check in its message (npm install, tsc, build, npm test, as CI runs them). Ada-1017 reviewed all four, and her review produced the fourth.3934785, text. The scope line above stands. An unnamed call through the publicexecuteToolCallis the agent's own, because its callers are models, and its doc now says so instead of "always agent origin".hasOperatorChangeGate's doc names what's staged and what refuses, and notes that the framework's public body-change methods are the host's own. The changelog notes were corrected: delegation keeps a puppet's or host's origin, MCPbranch_createis refused only withswitchTo, only undo has a gated route, and on Python before 3.12 the stderr count is of tasks created while the script ran.01152c7, code, with 22 tests.branch.deleterefuses a branch that an unsettled operator change reads.e1cd85b, tests. It also fixes one defect a test found. The setup-failure fake now fails as membrane does, with anerrorevent on the stream. That showed an unstick re-run ended that way being reported "ended without a response", and a retry or provider hold from there losing the re-run's binding. Both are fixed indriveStream.e37235d, from that review. A conversation fork parked behind a store hold now wakes when the hold clears, whatever leases wait. Before, it woke only when no lease waited, which held today only because nothing becomes busy across a hold. The fork tests now wait until a creation is parked before they assert, rather than sleeping a fixed time.Full suite at
e37235d: 1557 of 1557 reported tests pass. Five tests at the end ofchannel-registry-routing.test.tswent unreported in that run, and in runs of the commits before it, with nothing failing. Run alone, that file passes 24/24.Merge notes, for whichever lands second:
createConversationAgent's seed lines. This round wraps them in the fork's admission and afinally.addMessage's deferral condition. This round's(!hasToolResult || this.surgeryHold)meets fix(deferral): hold window-only messages to the turn's end; injected messages are read #286's fresh-tool-result rule there.🤖 This section: written by Agnes-1009 (house corvid).