* fix(managed-agent): stop database amplification on session hot paths
Four hot paths in the Managed Agent Runtime Broker amplified database
work far beyond request volume, two of them while holding row locks:
- materializeNextBatch rewrote the whole items_json snapshot after every
<=200-event batch under the session row lock. The snapshot is now
rewritten at creation, on a terminal event, every
SNAPSHOT_REFRESH_EVENTS (1000) covered events, or on catch-up once the
previous snapshot is SNAPSHOT_REFRESH_MILLIS (5s) old; a session whose
snapshot lags its drained progress is re-selected by
findMaterializationTargets once the snapshot ages out, so an idle
session still converges instead of staying stale forever.
- SSE fan-out ran a read-grant SQL check before nearly every event per
subscriber. Streams now recheck the grant at most once per
events.read-grant-recheck-interval (default 5s; PT0S restores
per-event checks); session.deleted still terminates immediately.
- listPublicSessions/listWebShellSessions ran 2-4 extra queries per row.
Pages are now assembled from a constant number of grouped batch
queries that project only the turn summary columns, with the approval
mode riding the page's own SELECT *, plus one workspace-close batch
read when the page holds bound sessions; single-session views share
the same assembly, and the singular store reads delegate to the batch
twins so each selection rule has one spelling.
- Publication authorization rescanned the session journal back to the
last activation.changed per revision under the publication lock on
every publish/seal/prefix/finish/verifyDispatch. Migration V34 carries
the current activation on the journal head, maintained by commit()
inside the parse pass every commit already runs; authorization reads
the head row it already locks, reserve/renew read the tool.intent at
its own revision, and pre-migration heads get one legacy scan plus a
backfill. Because a rolling fleet can briefly run a binary that does
not maintain the columns, trusting them is gated behind
tool-publication.journal-head-authorization (default off) until the
fleet drains.
Related: artifact downloads re-verified content access per 64 KiB chunk;
the re-verification is now throttled to
artifacts.read-revalidation-interval (default 5s), which also defers the
stage-1 DELETING observation to the window's end (stage-2 retirement
still aborts per chunk through the read lease). Migration V35 adds an
event-type index so latest-turn lookups read index ranges instead of a
session's event history.
Query budgets per endpoint are pinned by Issue13181QueryBudgetTest (with
the artifact revalidation window pinned in
ManagedArtifactReadIntegrationTest), the windowed/frozen-clock semantics
are pinned by ManagedEventStreamServiceTest, and the fencing behavior
across the head columns, the legacy scan, and the rolling-deploy gate is
pinned by ToolPublicationStoreTest.
Fixes #13181
* fix(managed-agent): pin the head-path proof in the fencing tests
Round-3 adversarial follow-ups (no Criticals):
- expiredActivationFencesThroughTheHead now proves the fences came from
the head columns: renew reads exactly the intent's revision (3
statements), and verifyDispatch performs zero locked journal reads.
- renewReadsTheIntentAtItsOwnRevision additionally pins zero locked
journal reads, so a lock-mode upgrade on the intent read cannot slip
past the statement-count pin.
- Drop the unreachable null-check after queryForObject in
requireLegacyActivation (the query throws on a missing row, and
record_bytes is NOT NULL).
* fix(managed-agent): pin the per-row retention flags on bound pages
Round-4 adversarial follow-up (no Criticals): the 20-row bound-page loops
asserted titles and actions but never the sessionArchive/Unarchive/Delete
flags, so a regression computing retention once per page would have passed.
One bound session now carries a completed CLOSE row and both surfaces
assert sessionDelete per row.
* fix(managed-agent): keep the text-order snapshot test deterministic
CI (Runtime Broker and Managed Agent MariaDB) caught
preservesTextOrderAcrossToolsAndReasoningInSnapshots racing the 10ms
materializer: a scheduler tick can create the snapshot mid-sequence, and a
non-terminal catch-up drain inside the 5s floor then legally leaves it
behind. The trailing event is now terminal so the explicit drain always
rewrites; the content-ordering assertion is unchanged.
* fix(managed-agent): keep singleAppendsContinueTheTextPartBeforeThem deterministic
Same scheduler race as the text-order test fixed in b0d357ae9c, caught by
the MariaDB CI job on the following run: a 10ms-tick snapshot created
mid-sequence plus a non-terminal catch-up inside the 5s floor legally lags.
The trailing reasoning delta is now terminal so the explicit drain always
rewrites. The contentPartId assertions are unchanged.
* fix(managed-agent): close out the second review round on #13181
Two correctness findings and the accepted suggestions from the second
review round of the query-amplification fix:
- ManagedExtensionRecords.millisLenient rejects a JSON number or
numeric string wider than a long before materializing the BigInteger,
so an exponent-form expiresAt no longer costs a giant allocation.
- The journal head's activation columns carry activation_head_revision,
the journal revision they reflect; every commit re-stamps it, both
authorization gates require stamp equality, and the backfill
re-stamps, so a rolling fleet still running pre-V34 writers detects
the skew and rescans instead of certifying stale columns.
- requireEvidence answers a fenced activation from the locked head
before touching the journal: a fenced renew now pays zero journal
statements, pinned in the budget test.
- Deferred snapshot rewrites are recorded with a snapshot_stale_since
marker (V36) that findMaterializationTargets re-selects once aged, so
a drained trickle converges instead of idling stale forever.
- transcript tails past the snapshot are bounded by the caller's limit,
keeping the newest events and reporting the truncation for paging.
- The latest-environment-event batch reads one row per session via a
MAX(sequence_id) derived table over the (session, turn) pairs.
- Workspace close state has a single spelling (the singular predicate
delegates to the new batch query), and the approval mode rides the
page's SELECT * onto SessionRecord instead of a per-row probe.
- The publication journal fixture is now shared between
ToolPublicationStoreTest and Issue13181QueryBudgetTest as
PublicationJournalFixture, ending the drift between the two copies.
* fix(managed-agent): close the audit round on the R2 fixes
Undirected plus adversarial audits of the R2 commit found one Critical
and three Minor issues:
- A commit carrying no activation change re-stamped the carried-forward
head columns with the new journal revision, laundering a skew left by
a pre-V34 writer into a fresh-looking head. The stamp now advances
only when the preserved columns were current at the previous
revision, keeping the skew detectable until the rescan heals it;
pinned by noChangeCommitDoesNotRestampASkewedHead, and the
always-advance mutant goes red.
- The millisLenient width pre-check overflowed int on extreme
exponent-form strings; the comparison now widens to long, pinned by
the 1e2147483647 arm.
- sameEpochReleasePreventsReserveAndRenew is parameterized over the
journal-head-authorization flag, pinning the release fence on the
shipped legacy scan and the head fast path alike; the suite default
stays false, matching production.
- The application.yml mirror test also asserts the flattened keys
exist, so a renamed or dropped key cannot false-pass on the Java
defaults.
- The design doc (both languages) records that a transcript backward
walk can repeat the first page's inlined control events at the page
seam and that clients deduplicate on the event sequence.
* fix(managed-agent): close the second audit round on the R2 fixes
The round-2 undirected and adversarial audits both found the same Major:
the millisLenient pre-check guarded only the negative-scale direction,
so a 12-byte "1e-100000000" expiresAt still forced toBigIntegerExact to
expand 10^100000000 before dividing (measured ~56s of CPU). The scale
is now bounded too (at most 19 decimal places, which a representable
long never needs), and a test arm pins the input class.
Two test-coverage Minors from the same round:
- transcriptTailIsBoundedByTheCallerLimit now pins the load-bearing
"newest kept" property with exact sequences (the tail ends at the
session's last event; olderCursor names the oldest returned one) —
the oldest-kept mutant goes red.
- deferredSnapshotConvergesOnTheAgedOutReselection now ages only the
deferral marker for the selection arm and pins that the tick's scan
references snapshot_stale_since without touching
managed_agent_snapshot, so a regression to per-row snapshot probing
fails twice over.
* test(managed-agent): correct the publish positive-control comment's lock attribution
* test(managed-agent): pin the creator-submit capability's batched page reads
* docs(managed-agent): state the conditional creator/grant batches in the page budgets
* test(managed-agent): exercise the can_create grant term across two workspaces
* fix(managed-agent): close out the third review round on #13181
Four Criticals and the accepted Suggestions from the third review:
- Restore the uncapped cursor-less transcript tail (merge-base
behaviour): capping it at the caller's limit falsified the published
webShellTranscript contract and broke the prefix invariant the stream
resume relies on (a lagging snapshot's middle band was served by
neither page nor stream), and the new backward walk re-served
already-projected delta rows past the seam. The contract sentence is
now pinned in ManagedAgentApiContractTest, and the budget suite pins
the full tail (every event past the snapshot, hasMore=false, no
cursor) instead of the cap.
- The commit-side activation capture applies the same sessionKey/v
scope check both read paths enforce, so a foreign-scoped
activation.changed is rejected at commit instead of being promoted
into the durable head columns a later flag flip would trust.
- The head path's contiguity count carries the legacy walk's
byte-length tripwire, and the single-revision locate is pinned
against holes and overlapping ranges; both intent reads reject
duplicate lines at one sequence.
- Pins added: the phase arm of the commit width guard, the deferral
marker surviving a fresh empty tick, the per-event attributes read in
the materialization budget (plus a whole-batch statement total), the
idle-stream revocation closing at the next wake for both stream
loops, the Range path's in-window exposure at the shipped 5s default,
the configuration-to-store flag wiring, and the four-column
last-activation-wins rotation.
- Housekeeping: the dead args field dropped from the extracted
fixture's consumer, the provably dead width guard dropped from
backfillActivation (its callers always pass contract-validated
values), the yml-mirror test now neutralizes the ambient
QWEN_MANAGED_AGENT_* environment, the properties test keeps the
flattened-key assertions, and the event fixture gained a scope/version
overload.
- Docs: the admission design's recheck sentence now states the windowed
behaviour and the real closure bound (window plus one poll interval)
in both languages; §2 qualifies path 4's relief as opt-in until the
fleet runs the V35 code, with the operator flip tracked as #13295; §6
states the locate's row cost honestly (constant statement count,
distance-proportional rows, strictly lighter than the legacy walk);
§8 composes the bound-page budgets; §9 records the chain proof's
narrowed parse scope; the README's revalidation row states the real
in-window exposure and its env-var table names all three new knobs.
* fix(managed-agent): align the commit-side scope check with the read paths
Audit of the R3 wave found one Minor: the new commit-side check compared
the sessionKey field by field, so a key carrying extra fields passed the
commit but reads as foreign to both read paths' closed-key comparison.
The check now builds the closed three-field key and deep-equals it,
exactly the read-side semantics; the commit-rejection test gains the
extra-fielded arm. Also narrows the duplicate-intent test's comment to
the revisions a path actually reads (a stray out-of-range claim is
invisible to the head path's locate and unreachable through the commit
validation).
* fix(managed-agent): close out the fourth review round on #13181
The Critical plus the accepted Suggestions from the fourth review:
- readIntent now answers a damaged journal with the session store's
corruption fault (500 managed_session_journal_corrupt) instead of a
client request fault (400): the locate, the contiguity count, and the
verified-page checks all throw journalCorrupt, widened to
package-private for the shared spelling. The fence tests assert the
code and the 5xx status; the legacy path answered 500 already.
- requireLegacyActivation applies the closed-key scope check before
promoting a scanned activation into the head columns, so a
pre-existing journal carrying a foreign-scoped activation.changed is
refused instead of laundered into trusted state.
- The commit-side scope check now covers every enveloped
managed_session_event_v1 line except domain.committed ones (whose
requireEnvelope keeps its pinned messages) — a misscoped or
unknown-version event line is refused at commit instead of reaching
the journal; bare event-subtype lines with no envelope stay inert and
tolerated as before.
- tailEvents pages at SNAPSHOT_REFRESH_EVENTS (the gate's lag bound),
so a maximally lagging snapshot's transcript tail is one event read
instead of ten; the full-tail contract pins hold with the wider page.
- The empty materialization tick is pinned whole (4 statements), and
the Range revocation test uses the shared readingPolicy helper.
The nine deferred observations from the review are recorded in the PR
thread.
* docs(managed-agent): state the widened commit-side scope check in section 9
* fix(managed-agent): close the audit round on the R4 fixes
The adversarial audit of the R4 wave found two Suggestions:
- readIntent answered a binding naming a never-committed intent
sequence with the 500 corruption fault under the head path, where the
legacy walk answers 400. The locate now splits the cases: an
ambiguous range or a hole inside the committed span is corruption
(500), while a sequence beyond the committed span is the requester's
fault (400 "Committed publication evidence is missing"), matching the
legacy walk. Pinned by aNeverCommittedIntentSequenceIsAClientFault
under both flag settings (red without the split, verified).
- The commit-side scope check's domain.committed carve-out also exempt
unknown-domain lines that requireEnvelope never sees; those now get
the closed-key check too (pinned by unknownDomainLinesAreRejectedAtCommit,
red under the kind-based carve-out, verified).
Plus the tail-read test now exercises the multi-page arm: a tail longer
than one 1000-event page is served completely across two reads.
---------
Co-authored-by: wenshao <[email protected]>
19 KiB
Managed Agent Query Amplification Fix
1. Status and problem
Proposed; tracks GitHub issue #13181 (audit of main at a7deb01bcb).
Four hot paths in the Managed Agent Runtime Broker amplify database work far beyond request volume, two of them while holding row locks:
ManagedAgentStore.materializeNextBatchrewrites the wholemanaged_agent_snapshot.items_jsonafter every ≤200-event batch, inside therequireSessionForUpdaterow lock that event ingestion also needs. Write volume grows quadratically with session length.ManagedEventStreamServicerunsrequireReadGrant→ManagedWorkspaceRegistry.canRead(SQL) before nearly every event, per subscriber.ManagedAgentService.listPublicSessions/listWebShellSessionsrun 2-3 per-row queries (findActiveTurn/findSnapshotCoveredSequence/findLatestTurn/findLatestEnvironmentEvent/approvalMode) on top of the page query.ToolPublicationStore.producerBindingLockedrescans the session journal backwards to the lastactivation.changed(oneSELECT ... FOR UPDATEper revision) on every publish/seal/prefix/finish, while holding the publication and tenant locks.
Related: ManagedArtifactService.content re-runs a multi-table permission
check per 64 KiB chunk, and seal/finish re-read and re-hash the whole stream
on the HTTP request thread.
2. Scope and invariants
In scope: the four numbered paths plus the artifact download check. The
public API shapes, the event/journal record formats, and fail-closed
authorization semantics do not change. The accepted relaxations, both
explicitly sanctioned by the issue, are bounded staleness: permission
decisions may be reused within a revalidation window (5 seconds by default,
operator-configurable with no enforced upper bound; PT0S restores per-event
checks), and the snapshot may lag the projection during bursts but converges
once ingestion pauses (bounded by the same 5-second snapshot cadence).
Out of scope: splitting ManagedAgentStore, and moving the seal/finish
byte-rehash off the request thread. The rehash is an integrity commitment
over mutable object storage; making it asynchronous changes the seal API
contract (clients would have to poll operationStatus). The database side of
that path is fixed here but opt-in at runtime: its heartbeat
re-authorization becomes O(1) via Section 6 once an operator enables
journal-head-authorization after the fleet fully runs the V36 code (the
rollout is Section 9; the flip itself is tracked by the follow-up issue
#13295); until then the heartbeat pays the legacy journal scan, unchanged
from before. The contract change is tracked by the follow-up issue #13242.
3. Snapshot rewrite gating
materializeNextBatch keeps per-event projection into the item tables and
the consumer-progress update exactly as today, but rewrites the snapshot only
when one of the following holds:
- no snapshot row exists yet (the insert path, unchanged);
- the batch carries a terminal event — a Turn always ends rewritten, so reads converge at Turn boundaries;
- the projection advanced at least
SNAPSHOT_REFRESH_EVENTS(1000) events since the snapshot'scovered_sequence; - the batch caught up with
session.last_sequence(the session row is lockedFOR UPDATEfor the whole batch, so this comparison is stable) AND at leastSNAPSHOT_REFRESH_MILLIS(5000) elapsed since the last snapshot write — the trickle case (a batch smaller than one scheduler tick'sEVENT_LIMIT) must not rewrite per tick.
A drained batch that rule 4 defers leaves the snapshot behind the projection
while the consumer progress already covers it, so the deferral is recorded on
the progress row: the batch's progress update sets snapshot_stale_since to
the snapshot's updated_at (migration V38), and
findMaterializationTargets re-selects a session whose marker is at least
SNAPSHOT_REFRESH_MILLIS old; the re-selected (empty) tick converges the
snapshot and clears the marker. Without the reselection an idle caught-up
session would never be revisited and the snapshot would stay stale forever;
the marker keeps the tick's scan free of a per-row snapshot lookup.
All snapshot readers (listPublicItems, transcript, SSE resync frames,
advanceReplayFloor) already key off the snapshot's own covered_sequence,
so a staler snapshot is self-consistent; transcript additionally tails
events past the snapshot (the cursor-less page serves every event after the
snapshot, as the published contract promises, so the stream can resume at
the reported watermark with no band unserved), so a WebShell stream's event
view does not lose freshness — its items array is the snapshot's and lags
by the same bound Section 9 states for listPublicItems.
4. SSE read-grant recheck window
Each stream caches its read grant and rechecks at most once per
qwen.managed-agent.events.read-grant-recheck-interval (new
ManagedAgentProperties.Events duration, default PT5S, PT0S restores
per-event checks). The initial requireReadableSession on subscription is
unchanged. A revoked subscriber may receive events for up to one window; a
session.deleted event still terminates the stream immediately, and a failed
recheck completes the stream as today. The recheck runs when the stream loop
iterates, so an idle stream's closure additionally waits for its next loop
wake — at most one events.poll-interval (5s by default) past the window.
Per-subscriber cost drops from up to two canRead queries per event to one
per window.
5. Session list batch assembly
The page query stays as is; per-row lookups move into grouped batch queries
added to AgentStateStore / ManagedAgentStore:
findActiveTurns(tenant, sessionIds)— one query, first row per session afterORDER BY created_at DESC(ties remain unspecified, as today).findSnapshotCoveredSequences(tenant, sessionIds)— oneINquery.findLatestTurns(tenant, sessionIds)— one query joining the turn rows to each session's latestturn.acceptedevent (MAX(sequence_id)derived table), preserving the single-sessionfindLatestTurnsemantics including the turn-row existence join. MigrationV37addsmanaged_agent_event (tenant_id, session_id, event_type, sequence_id)so it reads index ranges instead of a session's whole event history.findLatestEnvironmentEvents(tenant, turns)— one query selecting each session's latest environment event on its latest Turn in SQL (aMAX(sequence_id)derived table filtered to the (session, turn) pairs), so exactly one row per session is read.completedWorkspaceCloses(tenant, sessionIds)— oneINquery overmanaged_agent_operation, fetched only when the page holds workspace-bound sessions, feeding the archive/unarchive/delete capability flags without a per-row read.
Both turn reads project only TURN_SUMMARY_COLUMNS (session_id, turn_id, status, created_at, completed_at, error_code) — exactly what the public and
WebShell views expose — so a page never Jackson-parses or retains a prompt
graph per row. The approval mode needs no query of its own: every
sessionMapper read is a SELECT * over managed_agent_session, so the
mapper now maps approval_mode onto SessionRecord and hasActions reads
it from the row the page already fetched.
A page of unbound sessions costs at most 3 queries on either surface, and a
page of workspace-bound sessions costs one more for the close-state batch;
a WebShell page carrying submit-shaped sessions adds one creator-marker
batch, and one grant batch when it also holds creator-owned ones — all
independent of page size. The single-session views
(publicSession, webShellSession) delegate to the same assembly with
singleton inputs, so there is one code path. The single-session store reads
(findActiveTurn / findLatestTurn / findLatestEnvironmentEvent,
findSnapshotCoveredSequence) had no production callers left and are
removed, so each selection rule has exactly one spelling; the close-state
predicate likewise has one spelling, the singular hasCompletedWorkspaceClose
delegating to the batch twin.
6. Publication authorization O(1)
Migration V36 adds five nullable columns to
qwen_managed_session_journal_head: activation_id, activation_phase,
activation_event_epoch, activation_expires_at, and
activation_head_revision. ManagedSessionStore.commit collects the last
in-range activation.changed payload during the extension-record parse pass
that every commit already runs (no extra decode of the record bytes) and
writes its fields into the head in the same single head UPDATE that bumps
journal_revision; the stamp activation_head_revision = journal_revision
is written on every activation-carrying commit, and on any other commit
only when the preserved columns were current at the previous revision — a
commit that keeps columns whose stamp already lags leaves the stamp
lagging, so a skew left by a pre-V36 writer is never re-stamped into
looking fresh. The head row therefore carries the journal's current
activation state under the same lock discipline, and the stamp says which
journal revision the columns reflect: a commit from a binary that does not
maintain the columns still bumps journal_revision, so its head fails the
stamp equality check and authorization rescans the journal — the rolling
window is self-healing, since the scan's backfill rewrites the columns and
re-stamps them. expiresAt may be absent from the payload (timeOrNull); a NULL
column then fails the freshness check exactly like the journal scan reading
the value as absent does. All three readers of the payload's expiresAt (the
commit extraction and the two backfill scans) share one lenient helper — an
integral number or an integral numeric string of at most 19 integer digits
and at most 19 decimal places, else absent, with width and scale pre-checks
before any BigInteger materialization so an exponent-form string costs
nothing in either direction (1e+N needs the giant integer, 1e-N expands
10^N before dividing) — so the scan and the head columns can never disagree
about representability.
While a rolling fleet can still run a pre-V36 binary — which commits without
maintaining the columns — the head is not yet trustworthy, so
qwen.managed-agent.tool-publication.journal-head-authorization (default
false) keeps authorization on the journal scan. Once every writer runs the
V36 schema's code, an operator flips the flag and:
ToolPublicationStore.producerBindingLocked checks the head columns it
already read FOR UPDATE — phase is active, the id and event epoch match
the binding, activation_expires_at is in the future, and the stamp matches
the head's journal_revision — instead of the backward journal scan. When
the columns are NULL or the stamp lags (journals written before the
migration, journals that never committed an activation change, a payload
wider than the columns, which the commit blanks rather than rejecting, or
residue from a pre-V36 commit), it runs the legacy scan once and backfills
the head from the found event — skipping the write when the columns already
hold exactly those values — so every session becomes O(1) after its first
post-migration authorization or its next activation change. verifyDispatch
and the seal/prefix/finish heartbeats all flow through
producerBindingLocked, so they all become O(1).
ToolPublicationStore.requireEvidence (reserve/renew) takes the activation
state from the already locked head via the extended PublicationWriter
record — before reading anything else, so a fenced activation pays no journal
statement at all — and reads the tool.intent at its own revision directly:
the binding carries intentSequence, so one indexed range read over
last_sequence (migration V39) resolves the revision, one verified page
fetches it, and one indexed count (bounded by the same byte-length tripwire
the legacy walk applied per revision) proves the chain from that revision
up to the locked head is gap-free. The statement count is constant — four
journal statements regardless of journal depth — while the locate's row
cost grows with the distance between the intent and the head, exactly as
the legacy walk's did (it paid the same distance in full locked row reads,
so the head path is strictly lighter, not asymptotically constant).
Legacy rows keep the previous combined scan, backfilling the head
on success.
7. Artifact download revalidation throttle
ManagedArtifactService.content keeps its per-call read-timeout guard but
re-runs requireContentAccess (session row + workspace grant + policy) at
most once per
qwen.managed-agent.artifacts.read-revalidation-interval (new duration,
default PT5S). The initial check before streaming starts is unchanged, so
revocation latency is bounded by one window instead of being re-evaluated per
64 KiB chunk.
The window throttles the whole of requireContentAccess, which includes the
session-lifecycle gate: a session that enters stage-1 DELETING mid-download
is likewise observed only at the next window boundary, not at the next chunk
(stage-2 retirement still aborts per chunk through the read lease). This
deferral is accepted for the same bounded-staleness reason as the grant
recheck; the read-timeout guard still caps any download at
read-timeout. ReadLease.check() deliberately does not take over the
lifecycle check: it is shared by the retention and producer paths that must
keep reading the rows of a session being deleted.
8. Validation and acceptance
Issue13181QueryBudgetTest'sQueryLedger(aDataSourceproxy counting prepared statements) pins per-endpoint query budgets: a 20-session page oflistPublicSessions≤ 3 queries (4 for workspace-bound rows, adding the close-state batch) andlistWebShellSessions≤ 3 (a bound page whose submit-shaped sessions include creator-owned ones costs 6 — page, latest turns, environment events, close-state, creator-marker, grant; 5 when the page carries no Turns and the environment-event batch is skipped), with the batch turn reads asserted to project the summary column list and the admission-order selection rules exercised through the page (a two-Turn session admitted out ofcreated_atorder with inverted environment-event sequences).materializeNextBatchrewrites the snapshot at most once per 1000 covered events during a burst, at most once perSNAPSHOT_REFRESH_MILLISon a drained trickle, always on a terminal event, and a deferred snapshot is pinned to converge on the aged-out reselection.verifyDispatch/publishafter an activation commit readqwen_managed_session_journal_txzero times (seal/prefix/finish share the sameproducerBindingLockedpath), and a fenced renew pays no journal statement at all.renewreads the intent at its own revision, a constant 4 journal statements regardless of filler depth (the range read, the chain-contiguity count, and the verified page). The artifact revalidation window is pinned separately by policy-call counts inManagedArtifactReadIntegrationTest, including the deferred stage-1DELETINGobservation.ManagedEventStreamServiceTestkeeps the revocation test at a zero window and gains windowed tests countingcanReadinvocations across events: the window covering a delivery, a revocation landing when it lapses, a successful recheck re-anchoring the window, and the shipped 5-second default exercised through the null-sentinel path (the value itself pinned inManagedAgentPropertiesTest).- Existing suites must pass unchanged otherwise:
ToolPublicationStoreTest(activation fencing now exercises the head columns throughsessions.commit, and the intra-record ordering of both backward journal scans),ManagedAgentApiIntegrationTest,ManagedAgentMySqlIT,RuntimeBrokerFlywaySchemaTest.ManagedArtifactReadIntegrationTestgained the window tests and a shared setup extraction for them.ManagedAgentServerIntegrationTestis the deliberate exception: the materializer's 10ms tick races its explicit drains, so its transcript catch-up wait budget rose to 15s (the deferred snapshot converges within oneSNAPSHOT_REFRESH_MILLIS), and two snapshot-content tests (preservesTextOrderAcrossToolsAndReasoningInSnapshots,singleAppendsContinueTheTextPartBeforeThem) mark their trailing fixture event terminal so the explicit drain always rewrites — both assertions are unchanged. - Acceptance: the pinned budgets hold, no test outside the deliberately updated revocation semantics changes its expectations, and the legacy fallback still authorizes a pre-migration journal (covered by a test that nulls the new columns).
9. Risks and follow-up
- Bounded-staleness relaxations (Sections 4 and 7) are deliberate; both
intervals default to 5 seconds and are operator-configurable durations with
no enforced upper bound (
PT0Srestores the strict per-event behaviour), so a deployment may widen the accepted staleness by configuration. - A snapshot that lags during bursts delays
listPublicItemsfreshness by up to 1000 covered events; reads converge at Turn boundaries and, for a drained trickle, withinSNAPSHOT_REFRESH_MILLISvia the reselection rule. - The legacy scan fallback in Section 6 keeps the old cost for pre-migration journals until their first authorization or activation change; this is intentional to avoid a data migration over journal bytes.
- Rolling deployment: a pre-V36 binary commits without maintaining the head
activation columns, so the columns can go stale while old binaries still
write. The
activation_head_revisionstamp records which journal revision the columns reflect, so such a head fails the stamp check and authorization rescans the journal and re-backfills — a premature flag flip self-heals per session instead of certifying stale state. Thejournal-head-authorizationflag (default off) still ships as the deliberate operator switch; enable it once the fleet fully runs the V36 code, knowing residual skew is detected and repaired rather than trusted. - The head path's chain proof (Section 6) is a count with the legacy walk's
byte-length tripwire, not a per-line parse of the intermediate revisions:
a line carrying a foreign scope in a journal written before this change
would be caught by the legacy scan but not re-read after the head is
backfilled. Commits now reject any misscoped or unknown-version event
line (an
activation.changedline is the only one whose payload the head persists), so the residual is confined to journals already written by a misbehaving writer — and such a writer could equally have written correctly scoped forgeries. - Follow-up: move the seal/finish stream rehash off the request thread
(requires an asynchronous seal contract — issue #13242), and consider
splitting
ManagedAgentStoreas noted in the issue.