Files
qwen-code/docs/developers
Shaojin Wen 02b8b0108c feat(web-shell): show a Channel event timeline (#16106)
* feat(web-shell): show a Channel event timeline

Channels reported what they did only through stderr, the publication audit,
and the inbound task store, none of which the daemon could read. An operator
wondering why a GitHub comment got no reply had to read the daemon log.

Every Channel now appends one bounded journal per instance: the inbound
messages it accepted, the ones it skipped and the machine reason for each
skip, publication outcomes, inbound task transitions, and poll results. The
journal holds routing decisions and identifiers only, never message text; it
rotates at 1 MiB to a single backup, and a write failure is swallowed into one
stderr line so diagnostics cannot break message handling.

The daemon serves the journal through GET /workspace/channels/events and its
workspace-qualified form, with limit, type, channel, and since filters, a
32-file and 2 MiB read bound, and the existing trust and client checks. The
channel parameter repeats once per instance name because a Channel name may
legally contain a comma.

The Web Shell gains a Channel events page and sidebar entry that render the
cross-Channel timeline for the resolved workspace, filtered by Channel and
event type and refreshed every five seconds, gated by channel_management.

Docs: the design in English and Chinese, the serve protocol, and the Channel
user guide. Channel tests isolate QWEN_HOME so journals never land in a real
home directory.

* fix(web-shell): scope Channel events to the selected workspace

Review found four correctness gaps in the event timeline.

The page read the provider's daemon-wide primary cwd, so a Channel started in
a registered secondary workspace could never be inspected. It now offers the
same workspace selection as the management page and passes the selected cwd
to both hooks; changing the workspace resets the Channel filter.

The poll loop captured the adapter's detail before the poll ran, so the first
success had no counters and every later one carried the previous poll's
counters against the current timestamp. The field is now cleared before the
poll and consumed after it settles, on both the success and failure paths.

The 32-file read cap was applied before the Channel filter, which made an
explicitly requested, quieter Channel unreachable once busier peers had newer
files. Candidate files are now narrowed by the requested identities first.

Channel identities were truncated to 80 code points while the management API
accepts 255 UTF-8 bytes, so longer legal names never matched their own filter
and names sharing a prefix became indistinguishable. The identity now keeps
its full legal length; only the UI shortens it for display.

Each fix has a regression test that was mutation-checked against the previous
behavior. Design (both languages) and the Channel user guide now describe the
workspace selection, the poll-detail timing, the filter-before-cap ordering,
and the identity limit.

* fix(web-shell): mask Channel events cached for another query

The workspace selector changes the query without remounting the hook, and the
underlying resource keeps its previous data while a request is in flight or
after it fails. Selecting a second workspace therefore showed the first
workspace's rows under the new selector until the new request succeeded, and
kept them when it failed — including their Channel names in the filter list.

Each snapshot is now tagged with the query that produced it (workspace,
Channel, types, limit) and mismatched data is masked, the same way the Channel
management hook already does. Two hook-level regressions drive the real hook
and resource with a pending and a rejected second-workspace request; both fail
without the tag.

* fix(web-shell): keep Channel event reads off the daemon's critical path

A verification round measured the journal read at the documented cap: one
page poll took ~860 ms of synchronous I/O, an unrelated /health request waited
~790 ms behind it, and four concurrent reads serialized to 3.4 s. The route
runs on the daemon that also serves sessions, so a diagnostic page must not be
able to stall it.

The read is now asynchronous and chunked (64 KiB awaits) under tighter bounds:
256 KiB per journal file, 4 MiB per request, 32 files, freshest first. On the
same 32-file / 32 MiB fixture the read is ~175 ms, a bystander /health stays at
6 ms p50 against a 2 ms baseline, and four concurrent reads finish in ~630 ms.

Two reporting gaps go with it. Anything the bounds clip — dropped files or an
exhausted byte budget — now sets `truncated`, so the page cannot show a
timeline that silently omits whole Channels. And a scope directory that cannot
be read propagates instead of returning an empty list, so the route answers
5xx rather than claiming the workspace's Channels did nothing; a missing
directory still means "nothing written yet".

The page also stops polling while its tab is hidden and reloads when it
becomes visible, so a forgotten tab no longer reads journals every 5 s.

Tests pin the two behaviors the wire depended on but the suite did not:
reading events that survive only in the rotated file, and clamping a limit
above the maximum. Both new mutations are killed, as are the truncation,
unreadable-directory, and hidden-tab guards; a bystander-timer test pins that
the read yields to the event loop.

* fix(web-shell): report a clipped journal tail as truncated

The per-file read window introduced with the asynchronous read is 256 KiB,
but a journal grows to 1 MiB before it rotates. `readTail` skipped the head of
a taller file without telling anyone, so a single busy Channel lost its own
older events while the response still claimed `truncated: false` — the exact
completeness contract the previous commit set out to fix. A 1 MiB journal of
1020 records answered 255 events with `truncated: false` at `limit=500`.

`readTail` now reports whether it started mid-file and the reader folds that
into `truncated`. The request budget is also spent in whole tail windows
instead of a final partial one, so its flag keeps the precise meaning "whole
journals were left out".

Three tests close the gaps this exposed: a journal taller than the tail window
must report truncation even when the returned rows fit the limit; the budget
fixture now uses twenty full-window journals whose combined rows stay under
the record limit, so only the budget can explain the flag; and a journal file
that cannot be read must fail loudly, like the directory case already does.
All four previously surviving mutants — tail flag, budget flag, budget debit,
swallowed file-read errors — are now killed.

* fix(web-shell): bound Channel event read retention

The events read parsed and retained every matching record across all
journals before applying `limit`, so an all-Channels poll on a busy
workspace held far more than the rows it returns. Trim the accumulator to
the newest `limit` once it passes four times that. A dropped record is
older than every kept one and the sort is stable, so the answer does not
change: a 60-scenario differential run against the previous behaviour
produced byte-identical output. Trimming counts as hitting the record
limit, so it sets `truncated` too.

Closes the remaining non-blocking review items as well. The provider
workspace action declares the full query type it already forwards, so the
interface no longer suggests that path cannot filter by Channel. The new
UI strings use the established 频道 label rather than introducing a second
word for Channel beside the existing sidebar entry; the design docs keep
渠道, which is what every other zh-CN design uses. The docs now state the
page's own request size (200) apart from the route default (100), and the
`truncated` comments describe every reason it can be set instead of only
the record limit. The NUL query-key separator is documented as
collision-free: instance names reject control characters, journal names
are sanitized, and a path cannot contain NUL.

* fix(channels): read a journal that fits the budget and keep aligned records

Reserving a full 256 KiB window per journal left a small journal unread
when the remainder was under one window, even when it covered that
journal many times over — coverage lost in exchange for a flag that was
already truthful. Reserve what the journal actually needs instead, using
the size the enumeration already stats, so the budget flag still means
"whole journals were left out" and a 200-byte journal is no longer skipped
beside seventeen full ones.

A tail window that landed exactly on a line boundary also dropped one
complete record, because the first line was discarded whenever the window
started mid-file. Read one leading byte and drop that line only when the
byte is not a newline. Folding the byte into the first chunk keeps the read
to one awaited call per chunk, which is what lets the daemon serve other
requests between them — now pinned by a test that compares the event-loop
turns a tall read takes against a short one over the same six journals. A
synchronous chunk read fails it.

Also records why an unparseable instant cannot satisfy `since` while an
unfiltered read still returns it, and pins both halves.

* fix(channels): collapse repeated reason-filter skips into a counted row

A real end-to-end run — the actual GithubAdapter over HTTP against a local
GitHub-compatible API, a real agent turn, the page in a browser — exposed an
amplification the seeded-journal runs could not. The poll cursor does not
advance while an inbound task is recoverable, so a task that failed and is
still retryable makes the provider return the same notifications on every
poll, and filtered notifications are skipped before any dedupe. Each poll
therefore re-journaled the whole batch: with a 2 s poll and 300 filtered
notifications the journal rotated and pushed the accepted/posted rows out of
the page's 200-row window.

Journal at most 20 reason_filter rows per poll and collapse the remainder into
one row carrying detail.filtered, which the page renders as a count in both
languages. The budget is per poll, so a genuinely new batch is still described
in full. Measured against the real adapter: 15 polls served 4,500 notifications
and wrote 315 rows instead of 4,500, without rotating. Against a stub that
honours both `since` and GitHub's read-state semantics, the redelivery stops
once the batch is marked read and a new batch gets a fresh budget.

The design docs record the mechanism precisely, including that an in-flight
turn cannot cause it because the poll loop awaits pollOnce.

* fix(web-shell): keep one Channel event polling loop across visibility restores

Restoring tab visibility while a journal request was still pending started a
second run. Clearing the timer handle cannot cancel an in-flight run, so both
runs settled, both called schedule(), and only the last timer handle was kept —
the orphan kept polling forever. Ordinary tab switches on a slow response
therefore multiplied journal reads permanently: one overlapping hide/show cycle
left two requests per interval, three cycles left four.

Guard the cycle with an in-flight flag so a restore during a pending request
lets that request finish and reschedule instead of starting a second loop. Both
read paths are time-bounded (the SDK's 30 s fetch default and the action
timeout), so the guard cannot stall polling. The regression test holds every
request pending, overlaps three hide/show cycles, and asserts one request per
interval afterwards; removing the guard reproduces the reported four calls.
2026-11-03 13:42:18 +08:00
..