## Summary
- stop the Channels agent loop when a tool handler reports an
already-terminal provider delivery
- freeze managed renderer fanout while canonical ingestion records
`RUN_ERROR`
- immediately observe Slack native-stream queue failures while
preserving them for `finish()`
- treat uncertain managed file-delivery errors as terminal delivery
outcomes
## Root cause
The Core run loop converted every tool-handler exception into a
model-visible tool result. After `ChannelProviderDeliveryError` closed
the effect path, the model could continue and emit text, causing Slack
native rendering to call `slack.stream.start` against a closed delivery.
The native stream also retained that rejection in an unobserved internal
promise until `finish()`, leaving a Node unhandled-rejection window.
## Validation
- `pnpm nx run-many -t test check-types -p
@copilotkit/channels-core,@copilotkit/channels-slack,@copilotkit/channels-intelligence
--skip-nx-cache`
- `pnpm nx run-many -t build publint attw -p
@copilotkit/channels-core,@copilotkit/channels-slack,@copilotkit/channels-intelligence
--skip-nx-cache`
- `pnpm nx run-many -t test check-types build publint attw -p
@copilotkit/channels --skip-nx-cache`
- pre-commit affected-package matrix: 17 projects / 24 tasks
Two related Inspector-telemetry tickets: **OSS-566** and **OSS-568**.
## OSS-566 — explicit "Inspector opened" event
There was no event recording that the panel was opened. Opens could only
be inferred from in-panel activity (~1,655/90d, a floor) or from
`banner_clicked` cta=`body` (~511), which misses the common
floating-button path entirely.
Adds `oss.inspector.opened` with:
| property | values |
|---|---|
| `open_source` | `floating_button` \| `announcement_preview` |
| `has_unseen_announcement` | whether an announcement was on screen at
open time |
| `license_status` / `runtime_mode` / `runtime_url_type` | same
segmentation the threads events already carry |
| `package_name` / `package_version` / `inspector_distinct_id` | version
segmentation |
**Restoring a persisted-open panel deliberately does not count.**
Restore assigns `isOpen` directly instead of routing through
`openInspector()`, so page reloads — and every `next dev` hot reload —
stay out of the number.
## OSS-568 — banner surface + first-class dismissal
1. **`surface` on `banner_viewed`** — `collapsed_preview` (bubble on the
collapsed widget) vs `expanded_card` (card inside the opened panel),
stamped at fire time. Dedup is now per `(banner, surface)` instead of
per banner, so opening the panel records the card impression as its own
signal.
2. **`oss.inspector.banner_dismissed`** — emitted **in addition to**
`banner_clicked { cta: "dismiss" }`, not replacing it, so dashboards
reading the `cta` value keep working. Carries `surface` too, separating
"swatted the bubble away" from "dismissed the card after opening".
Both new events clear the sink's `oss.inspector.` prefix gate, so **no
telemetry-sink deploy is needed**.
## Testing
- **`packages/web-inspector` full suite — 112 passed (4 files)**, run
locally in the worktree:
```
✓ dev/css-raw-import.spec.ts (1 test) 1ms
✓ src/__tests__/telemetry-egress-guard.spec.ts (3 tests) 2ms
✓ src/lib/__tests__/telemetry.test.ts (28 tests) 8ms
✓ src/__tests__/web-inspector.spec.ts (80 tests) 890ms
Test Files 4 passed (4)
Tests 112 passed (112)
```
- **New coverage**: payload shape for `opened` / `banner_dismissed`,
incl. an allow-list assertion that no content/PII key can be added
accidentally; collapsed→expanded surface sequence on open; per-surface
dedup; open attribution for both sources; no event for an already-open
panel; no event for a restored-open panel; nothing emitted when the
runtime reports `telemetryDisabled`; an open still recorded while the
runtime is disconnected.
- **`tsc --noEmit`** on `@copilotkit/web-inspector`: clean (after
building `core` + `shared` dist in the worktree).
- **`tsdown` build**: succeeds; the test-only egress-guard helper is
**not** present in `dist/`.
- **`oxfmt --check`**: clean. **`oxlint`**: 9 warnings, all
pre-existing.
- `@copilotkit/runtime` (1,760) and `@copilotkit/shared` (199) also
green — both are back on main's own test files in this PR.
## A test-only egress guard rides along
`vitest.setup.ts` installs a fetch guard that swallows requests to the
telemetry sink. This is **not** CI plumbing — it is a prerequisite for
the new events. These tests run in jsdom, where a real `fetch` exists,
and inspector telemetry is fire-and-forget, so any test that drives a
banner / threads / open path without stubbing fetch POSTs a real
`oss.inspector.*` event to the live sink, from developer machines as
well as CI. The announcement-dismissal tests were already doing this;
the new `opened` / `banner_dismissed` tests hit the same send path. No
environment variable can prevent it, because the inspector's opt-out
arrives in the runtime's `/info` response and these tests never boot a
runtime.
## Not in scope
Suppressing telemetry from CI jobs that boot real apps (**OSS-565**) was
explored on this branch and removed. It needs a mechanism that does not
depend on the `/info` handshake — the env → `/info` → core chain is
asynchronous, so an early interaction beats it. That ticket stays open
and unaddressed here.
Closes OSS-566, OSS-568.
Resolves [OSS-641](https://linear.app/copilotkit/issue/OSS-641). Mike's
report: *"You have to `await channels.ready()` for it to connect to the
Realtime Gateway. Seems like there's some clunkiness to creating the
runtime and getting it connected."* He then picked the fix: *"I think it
should autostart in the long running wrappers."*
## What changes
**`createCopilotNodeListener` and `createCopilotExpressHandler` start
activation at creation.** A declared Channel connects because it was
declared; `channels.ready()` becomes await-and-observe rather than the
call you must remember. Failure-mode asymmetry is the argument:
forgetting `ready()` today gives you a process that serves HTTP, looks
healthy, and is silently disconnected with **zero output**, while
auto-start's worst case is an activation error in the logs.
**`createCopilotRuntimeHandler` and `createCopilotHonoHandler` stay
lazy.** The generic Fetch handler is the serverless/edge entry point —
isolates freeze and recycle per request, so separate cold starts would
mint competing listeners for the same Channel (the reason activation was
deferred in `fbf35ac59` in the first place). Hono keeps that behavior
because it is our Next.js App Router surface in practice: every route
handler in `examples/showcases/*` (banking, mcp-apps,
generative-ui-playground, oracle-agent-memory) plus the vue/nuxt demo
builds one at module scope. Its TSDoc now states why, loudly, so nobody
"finishes the job" later.
`activateChannels: false` remains the clean opt-out that opens no
socket.
## Consequence for host code: the shutdown boundary moves earlier
Signal handlers must now be registered **before the listener is
created**, not merely before `ready()`. Otherwise a Ctrl-C during the
connect window hits Node's default handler and leaks a live gateway
session. `examples/slack`, `examples/teams`, and the docs snippets are
restructured to wire teardown before the listener exists (a
`stopChannels`/`teardown` binding assigned in the same tick as
creation). **Worth calling out in the changelog** — it is the general
hazard for any user code that registers shutdown after mounting.
## Failure semantics
Fire-and-forget by necessity, since a factory is synchronous. Set-level
failures log at `error`; per-Channel failures keep their existing `warn`
breadcrumbs; an up-front misconfiguration (duplicate/missing Channel
names) now surfaces as a logged error at creation rather than a throw
out of the factory — the factory still never throws. `ready()` stays
idempotent and one-shot, so a host that *does* await it observes this
activation's outcome, including its rejection, rather than triggering a
second one.
## READMEs
Every `channels-*/README.md` quickstart built the *generic* handler and
needed `await handler.channels.ready()` — for a socket-mode Slack bot, a
request handler you construct and never serve, which is likely closer to
what actually felt clunky. All seven now use the Node listener, so they
inherit auto-start and agree with the docs-site quickstarts. No new
public surface: a bot-only `startChannels(runtime)` host was the
alternative and is deliberately not taken here.
## Testing
- **`packages/runtime` unit suite: 1815 passed / 128 files** (`npx
vitest run`), including 9 tests in `endpoints-channels.test.ts`
covering: auto-start on node + express; Hono still lazy;
`activateChannels: false` opens no socket; a failed auto-start logs
instead of leaving an unhandled rejection (asserted via an
`unhandledRejection` listener) and the reason survives to a later
`ready()`; a duplicate-name misconfig logs without throwing; and two
wrappers over one runtime activate once (the per-runtime manager cache
is load-bearing now that *construction* activates).
- **`examples/slack`: 63 passed / 12 files; `examples/teams`: 2 passed /
1 file** (`npm test` in each).
- **Typecheck clean:** `examples/slack` and `examples/teams` (`tsc
--noEmit`), plus a full `@copilotkit/runtime` tsdown build.
- **Lint/format clean:** `oxlint` reports 0 findings in every changed
file (the 21 warnings in that run are pre-existing, all in untouched
example render/tool files), `oxfmt --check` passes on all 9 changed
source files.
- **Docs:** verified no stale lifecycle claims remain (`opens no
connection` / `ready() is required` / `control surface` guards) across
`docs/channels/**` and the Slack + Teams platform guides.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Strengthen the shared-instance-factory test to assert the user-visible defect
rather than object identity. Two overlapping turns through
`agent: (id) => shared` both read the one shared `messages` array, so each run
is prompted with the other user's question too. Against main's source the test
now reports exactly that:
expected [ 'first+second', 'first+second' ] to deeply equal [ 'first', 'second' ]
The symptom is asserted before the mechanism so a regression names the defect
instead of posing an object-identity puzzle.
This also settles what the original "only the first mention gets a reply" report
was: overlapping mentions dropped by the old `onLockConflict: drop` default plus
the managed per-thread exclusive gate, both already fixed in #6256. Both turns
do reply here; what was left was cross-contaminated context, not a dead turn.
Remove `ChannelAgentConcurrencyError`, unthrown since #6256. Its doc claimed it
was kept so older importers would not break, but it was never re-exported from
`channels-intelligence`'s entrypoint — `git log -S` over index.ts confirms it was
never reachable from outside the package, whose only export is `.`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`createChannel` resolved an agent per turn through `agentFactory`, which
cloned the singleton config but returned a factory's result raw. A factory
is free to hand back the same object every call — `agent: (threadId) =>
shared` — which is easy to write by accident and is what a singleton
becomes when someone needs the `threadId`.
Turn concurrency defaults to `"parallel"`, and only the managed adapter
serializes same-thread deliveries, so on a directly connected adapter two
turns in one conversation can run at once. On one shared instance they
corrupt each other: `messages` is a single array both runs append into, so
each run's new-message diff picks up the other's, and `isRunning` /
`activeRunDetach$` / `activeRunCompletionPromise` are single-slot fields
the second run overwrites while the first is still streaming. Managed
delivery instead serializes on object identity, head-of-line blocking two
different conversations that share one instance.
Clone for both shapes so the object a turn runs on is never one the caller
still holds. A fresh factory is unaffected beyond an unused instance.
Because cloning is now mandatory everywhere, add a guard for the failure it
introduces: `AbstractAgent.prototype.clone()` copies a fixed field list, so
a subclass declaring its own state gets it back as `undefined` with no
error — the base method always exists and returns a correctly-typed
instance. Comparing own enumerable keys catches that and names the dropped
fields. Own functions are exempt: assigning a method on the instance is how
spies and instrumentation wrap an agent, and losing that wrapper leaves the
prototype method intact.
Also reset `isRunning` and `abortController` on the clone as hygiene — not a
fix for a dead turn, since `runAgent` assigns a fresh controller before each
run and the run loop passes none.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Creating a Node listener or an Express handler now STARTS activation of the
runtime's declared managed Channels, so `channels.ready()` becomes
await-and-observe instead of the thing you must remember to call. A declared
Channel connects because it was declared.
The failure mode this removes: forget `ready()` and you get a process that
serves HTTP, looks healthy, and is silently disconnected with zero output.
Auto-start's worst case is an activation error in the logs.
The generic Fetch handler stays LAZY — it is the serverless/edge entry point,
where isolates freeze and recycle per request and separate cold starts would
mint competing listeners for the same Channel. `createCopilotHonoHandler` stays
lazy for the same reason: it is our Next.js App Router surface in practice
(every `examples/showcases/*` route handler builds one at module scope), and its
TSDoc now says so loudly. `activateChannels: false` remains the opt-out that
opens no socket.
Consequence for host code: the shutdown-handler boundary moves earlier. Signal
handlers must be registered before the listener is CREATED, not merely before
`ready()` — otherwise a Ctrl-C during the connect window hits Node's default
handler and leaks a live gateway session. The slack and teams examples and the
docs snippets are restructured accordingly.
Also migrates the seven channel-package README quickstarts off the generic
handler (a request handler a socket-mode bot constructs and never serves) onto
the Node listener, so they inherit auto-start and agree with the docs site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The PR claims wrapping the transport (rather than replacing the event
transform) preserves the stock AbortError -> RUN_ERROR conversion. That was
read off the @ag-ui/client bundle, not tested. Now it is: a mid-stream
AbortError surfaces as RUN_ERROR{code:'abort'} and the run resolves.
Completes the previous commit, whose wiring was left out of it by mistake.
createChannel applies sanitizeAgentEventStream at the agentFactory seam, with
sanitizeAgentEvents: false to opt out; HttpAgent is re-exported from
@copilotkit/channels so the examples need no @ag-ui/client dependency; the
Slack + Teams examples and READMEs now wire a plain HttpAgent; and
SanitizingHttpAgent is deprecated (unchanged) in both adapter packages.
Also swaps a stray pair of raw control bytes in the protobuf test fixture for
escapes, so git sees the test file as text.
@ag-ui/langgraph emits a TOOL_CALL_START whose parentMessageId is null --
notably the tool call that triggers an interrupt. The AG-UI schema declares
that field optional but never nullable, so HttpAgent's transform re-validates
the streamed event, Zod rejects it, and one rejected event aborts the whole
run, breaking human-in-the-loop.
Until that is fixed upstream (OSS-691), Channels tolerate it by default:
createChannel coerces the field on the wire, so no call site needs a special
agent class. Opt out with sanitizeAgentEvents: false.
Applied at the transport rather than by replacing the event transform, which
keeps protobuf content-type negotiation, the graceful AbortError -> RUN_ERROR
conversion, and strict validation of every other field -- all of which
SanitizingHttpAgent gives up. It also survives the per-run clone() of a
singleton agent, which an own-property run() override would not.
SanitizingHttpAgent is deprecated but unchanged, so existing code keeps
working. The examples and READMEs now wire up a plain HttpAgent.
## What
PR #6244 taught the Slack renderer to split a long reply across
continuation messages instead of silently truncating it. Its tuning was
hardcoded. Three of those constants are genuinely caller-dependent; this
exposes them through one `replyContinuation` option on both the direct
and managed surfaces.
```ts
// direct
slack({ replyContinuation: { maxMessages: 5 } });
// managed
createChannel({
name: "support",
replyContinuation: {
messageByteLimit: 11_000,
maxMessages: 20,
truncationMarker: "\n\n_…réponse tronquée._",
},
});
```
## Which constants, and why only these
| exposed | why it is a caller's decision |
| --- | --- |
| `messageByteLimit` | Slack's cumulative per-message ceiling is
**undocumented**. 11k is inferred from a single production datapoint
(11,607 bytes observed accepted) and deliberately conservative. If the
real ceiling differs by plan or workspace, an operator needs a knob, not
a release. |
| `maxMessages` | How many messages one reply may occupy is a product
decision, not a platform fact. 20 was chosen to bound a runaway (500k
chars → 46 messages); a support bot and an internal ops bot want
different answers. |
| `truncationMarker` | Hardcoded **English** copy posted into the
customer's channel. The one constant with no correct default. |
Deliberately **not** exposed, because they are correctness rather than
preference:
- `APPEND_CHAR_LIMIT` — a documented Slack per-call limit. A provider
fact; exposing it only invites `msg_too_long`.
- `MIN_MESSAGE_PROGRESS_BYTES` — loop-safety invariant. Exposing it lets
a caller reintroduce the unbounded-message bug #6244 fixed.
- `MAX_FENCE_LANG_CHARS`, `FINISH_DRAIN_ATTEMPTS` — internal heuristics.
If either is wrong that is a bug to fix, not a knob.
## Shape
Grouped under one nested option rather than three flat fields.
`maxMessages` sitting bare on a Channel reads ambiguously (thread
history?), and the group keeps the next continuation knob from adding
another top-level field. The trade-off is that it diverges from
`showToolStatus`'s flat precedent — happy to flatten if reviewers prefer
consistency over disambiguation.
The shared `ReplyContinuationOptions` type lives in `channels-core`, the
common ancestor of all four packages that touch it.
## Plumbing
Both surfaces follow `showToolStatus` exactly:
- **Direct:** `slack({ replyContinuation })` → `adapter.ts` →
`event-renderer.ts` → `NativeMessageStream`, covering both the renderer
path and `adapter.stream()`'s own stream.
- **Managed:** `createChannel({ replyContinuation })` → `Channel` →
`ChannelActivationConfig` → `channel-manager` →
`realtime-gateway-launcher` → `DeliveryAdapter` → the renderer's
`nativeStreaming` block.
**No gateway or Intelligence change is required.** Managed Slack renders
in the SDK process over a gateway live session and only emits
`slack.stream.*` effects; the Elixir `provider_executor` is a dumb
effect applier that owns no message boundaries. Render config therefore
never has to cross into Intelligence.
One non-obvious touchpoint: `channel-manager.ts` keeps a **hand-written
structural mirror** of the launcher's options, so the field has to be
declared there as well or the managed path type-drifts silently.
## Testing
```
channels-core 171 passed
channels-slack 318 passed
channels-intelligence 67 passed
runtime 1809 passed, 3 failed
```
The 3 runtime failures are pre-existing Gemini `AIMessage` filtering
tests, unrelated to this change — confirmed by re-running them with
these changes stashed on `main`. `check-types` passes for all four
packages.
New coverage:
- the marker override at the leaf (`native-stream`);
- the renderer's pass-through — this one fails without the change, since
the 11k/20 defaults would keep that reply in a single message;
- the managed chain end to end via `createChannel` → activation config →
launcher opts, plus the negative case that an unset option adds no
property anywhere.
## Follow-ups (tracked on OSS-689, not in scope here)
- Confirm the byte-vs-char question with a manual >12k
mostly-CJK/Cyrillic reply against a real workspace. It decides whether
`messageByteLimit`'s default is right; the option makes it adjustable
either way.
- Under the managed path's `minIntervalMs: 0` cadence a table row can
still be cut mid-row, so a continuation's re-emitted header is followed
by a malformed row.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Overlapping turns on the same conversation now run concurrently by default
so multi-user Slack threads get parallel replies. Singleton agents are
isolated via clone() per run; store.concurrency serial/drop remain opt-in.
`ChannelsIntelligenceModule` re-declared the launcher's options by hand, so
adding `replyContinuation` to the real launcher type-checked clean here while
the managed path silently ignored it — the mirror had to be edited too or the
option was dropped on the floor. That is a trap for every future launcher
option, not just this one.
The mirror existed for a stated CJS/ESM reason, so I checked whether it still
applies rather than assuming. It does not, for a type:
- `import type` is fully erased. The emitted CJS gains no `require` of
`@copilotkit/channels-intelligence`; the only references in the build output
remain the pre-existing non-literal specifier constant and the package.json
dependency entry.
- `ChannelsIntelligenceModule` is not part of the emitted `.d.cts`/`.d.mts`
surface (it appears only in sourcemaps), so no CJS consumer resolves the
ESM-only package — which matters because that package's export map has an
`import` condition and no `require`.
The constraint is real for the *value* import, which is why the dynamic
specifier stays non-literal. The comment now draws that distinction explicitly
so the next reader does not re-mirror it.
Net: 42 lines of duplicated type removed, and the managed path can no longer
drift from the launcher it calls.
PR #6244 taught the Slack renderer to split a long reply across continuation
messages, but its tuning was hardcoded. Three of those constants are genuinely
caller-dependent and are now configurable through a single `replyContinuation`
option; the rest stay internal on purpose.
Exposed:
- `messageByteLimit` — Slack's cumulative per-message ceiling is undocumented.
11k is inferred from one production datapoint and deliberately conservative;
operators need a knob if the real ceiling differs rather than a release.
- `maxMessages` — how many messages one reply may occupy is a product decision,
not a platform fact. A support bot and an internal ops bot want different
answers.
- `truncationMarker` — hardcoded English copy posted into the customer's
channel. The one constant with no correct default.
Deliberately NOT exposed, because they are correctness rather than preference:
`APPEND_CHAR_LIMIT` (a documented Slack per-call limit),
`MIN_MESSAGE_PROGRESS_BYTES` (loop-safety invariant — exposing it lets a caller
reintroduce the unbounded-message bug #6244 fixed), `MAX_FENCE_LANG_CHARS`, and
`FINISH_DRAIN_ATTEMPTS`.
Grouped under one nested option rather than three flat fields: `maxMessages` on
a Channel reads ambiguously on its own (thread history?), and the group keeps
the next continuation knob from adding another top-level field.
Both surfaces are wired, following `showToolStatus` exactly:
- direct: `slack({ replyContinuation })` → adapter → event-renderer → stream,
covering both the renderer path and `adapter.stream()`.
- managed: `createChannel({ replyContinuation })` → `Channel` →
`ChannelActivationConfig` → channel-manager → launcher → `DeliveryAdapter` →
the renderer's `nativeStreaming` block.
No gateway or Intelligence change is needed. Managed Slack renders in the SDK
process over a gateway live session and only emits `slack.stream.*` effects, so
render config never has to cross into Intelligence — the Elixir provider
executor is a dumb effect applier that owns no message boundaries.
`channel-manager.ts` carries a hand-written structural mirror of the launcher
signature, so the new field is declared there too or the managed path silently
type-drifts.
Tests: the marker override at the leaf, the renderer's pass-through (fails
without it — the defaults would keep that reply in one message), and the managed
chain end to end via `createChannel` → activation config → launcher opts, plus
the negative case that an unset option adds no properties anywhere.
## Problem
Managed Channel deliveries can request a canonical thread lock with the
inner agent ID, or `default` when that ID is unset. Intelligence owns
the thread under the declared Channel name, so the lock fails with
`THREAD_AGENT_MISMATCH`.
## Why
The SDK treated the agent object identity as the managed Channel
identity. Those values are independent: `createChannel({ name })`
declares the managed thread owner, while an agent ID is optional and may
differ.
## Fix
Pass the declared Channel name into the managed delivery adapter and use
it for canonical runs. Add a public `thread.runAgent()` regression test
that proves an agent with a different ID still runs under the Channel
name.
Validated with the Channels Intelligence test suite, type check, and
build; the runtime Channel manager tests, type check, and build; repo
lint; and the affected-package pre-commit checks.
Coverage on native-stream.ts was 87.5% stmts / 80.45% branches; now 92.7% /
88.5%. The gaps were concentrated in code this branch added, and two of them
were places I had claimed coverage that did not exist.
Genuinely uncovered logic, now tested:
- `renderContextCloser`'s inline-code and emphasis closers, and the
`hasFenceCodeContent` mirror added in the previous commit — the closer is
appended at every boundary and none of its three branches were exercised.
- `tableHeaderToReopen`'s "no longer inside the rows" guard, so a boundary landing
after a table has ended does not inject a stray header.
- The 2-byte UTF-8 width class (Cyrillic). CJK covered 3-byte and emoji 4-byte;
the class between them was untested.
- The `callEnd < byteEnd` branch. The test that claimed it never reached it: with
a 40k budget and 30k of ASCII the whole reply fits, so the first branch won and
the per-call path was never entered. Retargeted with multi-byte text.
- Rollover `stopStream` failure, non-strict (proceeds) and strict-with-a-failed
closer (reports the closer, not the consequence).
- Truncation marker failure, non-strict (logged) and strict (reported).
- A legacy tail fallback that itself fails.
- Chunk behaviour around a rollover: delivered to the current message, degraded
when the continuation cannot be opened, and skipped entirely when a strict text
failure rejects the shared flush queue — the last of which corrected my
assumption that `flushChunk` still runs in that case. It does not: `.then()` on
a rejected promise skips its callback.
Four guards are unreachable through the public API and are now commented as
deliberately defensive rather than left looking like missing tests: the
`roomBytes <= 0` and "not even one code point fits" rollover checks (every
filling branch rolls over in the same iteration), `appendSlice`'s empty-delta
stall guard (both boundary helpers return an index strictly greater than
`curPosted`), and `truncate`'s own re-entry check (`appendPending` short-circuits
first). Kept as loop-safety invariants.
The residue is pre-existing or upstream: `append()`'s legacy forwarding, the
zero-interval `scheduleFlush` path, `flushChunk`'s start-failure degradation, and
`flushTextInline`'s strict rethrow.