docs(design): correct the daemon PR state transitions design (#16487)

Fold the unposted findings of #16246's final docs-only review, and
what the audits of those fixes turned up, into both language versions:

- v0.26.8 (2026-11-05) ships the watch:* keys of #16258, so they follow
  the same deprecation window as prWatchEvents.
- The view identity check normalises both URLs as pullRequestKey does
  (any path after the number and the port ignored) and compares host
  and number only.
- Latency is stated as nominal (one interval, or ceil(n/30) for a view
  set of n), with the cases that make it longer.
- The shared template contract moves key and template validation,
  chain resolution (event vocabulary and comment lane as parameters)
  and rendering from variable values into channel-base; %sender% is
  GitHub, %repo%/%url% keep the winner's bound URL spelling.
- Every watch-file change reads and writes within one hold of the
  per-PR lock; a daemon claims the watch only when its refresh timer
  runs and its uid owns the watch directory and files, so writes stay
  temp-and-rename; a non-owner writes nothing.
- The alias guarantees, the untrusted-primary exposure and the
  validation plan are updated to match; the Chinese twin carries the
  technical templates verbatim.
This commit is contained in:
Shaojin Wen authored and GitHub committed 2026-11-07 02:38:45 +08:00
1 parent 301b61ddcc
commit acea5b1335
2 files changed
+67 -65

No files matched your search

@@ -18,7 +18,7 @@ The second poller exists only where an operator configured a GitHub channel with
- Sidecar schema (`packages/core/src/services/session-pr-service.ts`, `SessionPr`): `number`, `url`, `createdAt`, `state?`, `source?`, `role?`, `issues?`. No head SHA, CI rollup or mergeability is persisted, so a refresh cannot see a transition.
- The refresh's GitHub path fetches `gh pr list --state all --limit 500` in **slim** mode (`number,url,headRefName,state`) because the CI rollup field makes large `--state all` queries hit GitHub GraphQL server timeouts (504). `GH_PR_LIST_FIELDS` (non-slim) already carries `statusCheckRollup`, `isDraft`, `reviewDecision`, `updatedAt`, but not `headRefOid` or `mergeable`; the rollup reducer, `aggregateChecks`, is module-private.
- The daemon already injects prompts into sessions it does not own the keyboard of: the channel bound relay (`POST /session/:id/prompt` with the `qwen.channel.prompt` / `qwen.daemon.channelBoundRelay` markers and an `idempotencyKey`), and it restores dormant sessions through the `/session/:id/resume` path. Neither is an in-process call today: the relay runs in the channel worker process and reaches the daemon over HTTP, and the prompt admission — the idempotency ring (`promptIdempotencyBySession`), the worker-credential classification, the bound-relay and display-text markers, and the skip of the PR-binding backfill for channel prompts — lives inline in the route handler (`packages/cli/src/serve/routes/session.ts`). `qwen.channel.prompt` is honoured only along a trusted chain: the route accepts it only from a caller holding the channel worker credential, the bridge (`packages/acp-bridge/src/session-control-plane.ts`) re-arms it only from trusted context (`context.channelPrompt`), and the session child (`packages/cli/src/acp-integration/acpAgent.ts`) re-arms it only for a trusted private parent; `Session.ts` reads it to exempt the turn from the loop-detected rejection. The bridge keeps a display text for a turn only in channel-owned sessions (`getChannelPromptDisplayText`), and the bound relay sends none, so a relayed event appears in the bound session's transcript as sent.
- The channel watch's user-facing semantics are documented in `docs/users/features/channels/github.md` and were merged on 2026-11-03 (#16117) and shipped in v0.26.7 (2026-11-04), whose release notes name the feature; `prWatchEvents` is therefore a released, operator-configurable setting. Since #16258 (merged 2026-11-04, so far only in nightly builds), a channel with `boundSessionActions` routes watch events by binding role — `review`, `verify`, `fix` or untyped, one winner per role, each with an operator instruction template selected by `watch:<type>` keys; without the map it keeps the single most-recent winner.
- The channel watch's user-facing semantics are documented in `docs/users/features/channels/github.md` and were merged on 2026-11-03 (#16117) and shipped in v0.26.7 (2026-11-04), whose release notes name the feature; `prWatchEvents` is therefore a released, operator-configurable setting. Since #16258 (merged 2026-11-04 and shipped in v0.26.8 on 2026-11-05, whose release notes name the templates and the channel editor that edits them), a channel with `boundSessionActions` routes watch events by binding role — `review`, `verify`, `fix` or untyped, one winner per role, each with an operator instruction template selected by `watch:<type>` keys; without the map it keeps the single most-recent winner.
## Goals
@@ -40,9 +40,9 @@ The second poller exists only where an operator configured a GitHub channel with
The refresh keeps its slim `--state all` listing for `state` and adds one `gh pr view <url> --json url,title,headRefOid,mergeable,state,statusCheckRollup` per bound PR of the workspace's sessions as the bindings scan enumerates them, except merged PRs and closed PRs the sweep's slim listing returned; hidden-catalog sessions and sidecars whose transcript is archived are therefore not viewed for, and a binding that exists only in the bridge's memory is viewed once its sidecar is written. Merged is terminal by the PR's newest observation (its watch file's `state`, §2, or the top-level `state` when no file exists yet). Closed is not, because a closed PR can be reopened: the listing observes a closed PR it returns, and a closed PR it does not return — every PR of the untrusted primary, which has no listing; a binding to another repository, which is never in this repository's listing; a PR beyond the listing's 500-PR limit — stays in the view rotation behind the open PRs, so a reopen is always seen.
Views go by the binding's URL, never by number inside the checkout: `gh` resolves a bare number against the workspace's own repository, and a binding can name another one — the sweep already treats such bindings as foreign: `updateSessionPrStates` writes a listed state only to an entry whose canonical URL matches, and `isForeign` keeps them out of the closing-issue lookup and lets a merged one converge without it. A view is used only when the PR it returns has the binding's host and number, compared as the refresh's `pullRequestKey` does, so the bind paths' tolerated spellings with `www.`, `/files`, `.diff` or `.patch` match the canonical URL `gh` returns. A different `owner/repo` with the same host and number means the repository was renamed or transferred, which GitHub resolves from the old name: the view is accepted, with a log line once per PR. A different host or number is logged and discarded. The watch file stays keyed by the binding's own identity — `pullRequestKey` of the bound URL — so a rename changes nothing for an existing binding: its file, binders, `lastBoundAt` and recipients are found exactly as before. A PR bound under both its old and its new repository name is watched once per name, and each of its transitions is delivered once per name; that is a stated boundary. Views go only to GitHub hosts: `github.com`, the hosts the daemon's `gh` configuration already lists, and the web host of each GitHub channel this daemon hosts, compared lowercased with `www.` stripped as `pullRequestKey` does. A binding on any other host is skipped with a log line before any `gh` call, so a binding URL — or a workspace whose `origin` is not GitHub at all — cannot make `gh` present the daemon's enterprise credential (`GH_ENTERPRISE_TOKEN`) to an arbitrary host. Views run with the swept workspace's environment (`runtime.env.effectiveEnv`), as its listing does, so a workspace's own `GH_TOKEN` or `GH_CONFIG_DIR` applies to its PRs; the untrusted primary's views run with the daemon's own environment. Their working directory is the daemon-private watch directory (§2, mode 0700), never a shared one such as `os.tmpdir()`, because `gh` resolution (`resolveGhBinary`) resolves relative or empty `PATH` entries against the child's working directory.
Views go by the binding's URL, never by number inside the checkout: `gh` resolves a bare number against the workspace's own repository, and a binding can name another one — the sweep already treats such bindings as foreign: `updateSessionPrStates` writes a listed state only to an entry whose canonical URL matches, and `isForeign` keeps them out of the closing-issue lookup and lets a merged one converge without it. A view is used only when the PR it returns has the binding's host and number, both URLs normalised as the refresh's `pullRequestKey` normalises them (host lowercased with `www.` stripped and any port dropped; any path after the number, such as `/files`, and a `.diff` or `.patch` suffix ignored), so the bind paths' tolerated spellings match the canonical URL `gh` returns; unlike `pullRequestKey`'s own `host/owner/repo` key, `owner/repo` is deliberately not compared. A different `owner/repo` with the same host and number means the repository was renamed or transferred, which GitHub resolves from the old name: the view is accepted, with a log line once per PR. A different host or number is logged and discarded. The watch file stays keyed by the binding's own identity — `pullRequestKey` of the bound URL — so a rename changes nothing for an existing binding: its file, binders, `lastBoundAt` and recipients are found exactly as before. A PR bound under both its old and its new repository name is watched once per name, and each of its transitions is delivered once per name; that is a stated boundary. Views go only to GitHub hosts: `github.com`, the hosts the daemon's `gh` configuration already lists, and the web host of each GitHub channel this daemon hosts, compared lowercased with `www.` stripped as `pullRequestKey` does. A binding on any other host is skipped with a log line before any `gh` call, so a binding URL — or a workspace whose `origin` is not GitHub at all — cannot make `gh` present the daemon's enterprise credential (`GH_ENTERPRISE_TOKEN`) to an arbitrary host. Views run with the swept workspace's environment (`runtime.env.effectiveEnv`), as its listing does, so a workspace's own `GH_TOKEN` or `GH_CONFIG_DIR` applies to its PRs; the untrusted primary's views run with the daemon's own environment. Their working directory is the daemon-private watch directory (§2, mode 0700), never a shared one such as `os.tmpdir()`, because `gh` resolution (`resolveGhBinary`) resolves relative or empty `PATH` entries against the child's working directory.
Views are capped at 30 per workspace sweep with a rotating window of the same shape as the Aone path's `sweepStart`; the GitHub path reports its own consumed count and the timer advances a GitHub offset per workspace (today only the Aone path reports one). The window wraps inside a sweep exactly as the Aone one does: two consecutive sweeps over 40 open bound PRs observe all 40 — the second window starts at the 31st PR, wraps to the head of the set and repeats 20 of the first sweep's views — and a workspace with more than 30 observes every one within ⌈n/30⌉ sweeps when no deadline truncates a window. The repeated views stay inside the cap and keep those PRs fresher, which is why the window is not made non-wrapping.
Views are capped at 30 per workspace sweep with a rotating window of the same shape as the Aone path's `sweepStart`; the GitHub path reports its own consumed count and the timer advances a GitHub offset per workspace (today only the Aone path reports one). The window wraps inside a sweep exactly as the Aone one does: two consecutive sweeps over 40 open bound PRs observe all 40 — the second window starts at the 31st PR, wraps to the head of the set and repeats 20 of the first sweep's views — and a workspace with more than 30 observes every one within ⌈n/30⌉ sweeps when no deadline truncates a window and the set does not change in between. The repeated views stay inside the cap and keep those PRs fresher, which is why the window is not made non-wrapping.
The views run under one tick-level deadline rather than the Aone loop's per-workspace `AONE_SWEEP_VIEW_BUDGET_MS`. The timer walks workspaces sequentially behind one `running` guard, so W workspaces each spending a 60-second budget plus one in-flight 10-second view would stretch a tick to 70·W seconds — past the 5-minute interval from W = 5 — and the guard would then skip the next tick for every workspace. The timer therefore computes one deadline when a tick starts, half the refresh interval later, and hands it to every workspace sweep of that tick. Past it, a sweep starts no further views (one in flight may still run out its `GH_TIMEOUT_MS`); dormant-session restores never run on the tick at all (§4); the rotation advances by the views actually started; and the order in which a tick walks workspaces rotates per tick, so a persistent overrun starves no workspace. A failed view or delivery for one PR is isolated, so the rest of the sweep continues. The deadline bounds only the work this design adds: the slim listing and closing-issue lookups keep their own per-call timeouts and the Aone loop keeps its per-workspace budget, so several slow workspaces at a short interval can still overrun a tick, as they can today.
@@ -54,11 +54,11 @@ The timer sweeps trusted workspaces, as today, plus the primary workspace when i
### 2. Watch state per PR
The watch state belongs to the PR, not to any session that follows it, so it lives once per PR in a daemon-owned watch file — `<global qwen dir>/serve/pr-watch/<first 40 hex of sha256 of host/owner/repo#number>.json`, the binding's own identity (§1),, the directory created with mode 0700 — rather than in the session sidecars, which keep their `state` and `issues` for the sidebar unchanged. One file per PR, not one copy per binder, means there is exactly one baseline, one queue and one delivered record for each PR, however many sessions bind it and whichever of their workspaces is reachable, trusted or retired this tick: no replica can lag behind, so nothing ever has to be adopted, merged or re-detected from a stale copy. One daemon owns the watch directory, created with mode 0700, because several daemons may share one global qwen dir and even one workspace (`docs/design/2026-09-02-relaxed-standalone-daemon-ownership.md`). Ownership is an owner record, `pr-watch/owner.pid`, written and checked exactly as the channel service pidfile is (`packages/cli/src/commands/channel/pidfile.ts`, generalised to a caller-chosen path): an exclusive create (`wx`) under the module's lock, carrying the owner's pid, process start token and PID namespace. A record whose process is provably gone on this boot and in this PID namespace is swept and replaced; a record this machine cannot verify — written before a reboot, by another machine or by another PID namespace sharing the home — is kept and blocks the claim until it is deleted by hand, with a log line naming the file at every attempt, as the channel service does today; and on a platform without a process start token (macOS) a record whose pid was reused counts as live, as it does for the channel service. A daemon whose effective event set (§7) is non-empty attempts the claim at startup and on every tick, so ownership passes to a surviving daemon once the owner exits; a daemon with an empty set never claims, and when several configured daemons run, the first to claim governs and the others log that its set applies. A daemon that does not hold the record runs no PR watch — it views, drains and deletes nothing — while its state refresh stays as today, so a session bound only in workspaces the owner does not register gets no PR-state events. Daemons share sessions when they resolve the same runtime base, which `QWEN_RUNTIME_DIR` or a workspace's `advanced.runtimeOutputDir` can place outside the global qwen dir (`resolveSessionRuntimeBaseDir` in `packages/cli/src/serve/run-qwen-serve.ts`); two daemons with different global qwen dirs over one runtime base would each own a watch and each deliver, so that configuration is not supported for the PR watch, and at most one such daemon sets `sessionPrWatch`. On shutdown the owner stops its PR watch — the timer, the drains, and every restore and admission in flight — before it removes its record, so a successor never overlaps it; PR A reorders the serve lifecycle's close (`packages/cli/src/serve/serve-app-lifecycle.ts`) to do so. With one writer, the owner's in-process locks are authoritative for every file. Each write is atomic (temporary file and rename) and runs under the owner's in-process lock per PR:
The watch state belongs to the PR, not to any session that follows it, so it lives once per PR in a daemon-owned watch file — `<global qwen dir>/serve/pr-watch/<first 40 hex of sha256 of host/owner/repo#number>.json`, the binding's own identity (§1) — rather than in the session sidecars, which keep their `state` and `issues` for the sidebar unchanged. One file per PR, not one copy per binder, means there is exactly one baseline, one queue and one delivered record for each PR, however many sessions bind it and whichever of their workspaces is reachable, trusted or retired this tick: no replica can lag behind, so nothing ever has to be adopted, merged or re-detected from a stale copy. One daemon owns the watch directory, created with mode 0700, because several daemons may share one global qwen dir and even one workspace (`docs/design/2026-09-02-relaxed-standalone-daemon-ownership.md`). Ownership is an owner record, `pr-watch/owner.pid`, written and checked exactly as the channel service pidfile is (`packages/cli/src/commands/channel/pidfile.ts`, generalised to a caller-chosen path): an exclusive create (`wx`) under the module's lock, carrying the owner's pid, process start token and PID namespace. A record whose process is provably gone on this boot and in this PID namespace is swept and replaced; a record this machine cannot verify — written before a reboot, by another machine or by another PID namespace sharing the home — is kept and blocks the claim until it is deleted by hand, with a log line naming the file at every attempt, as the channel service does today; and on a platform without a process start token (macOS) a record whose pid was reused counts as live, as it does for the channel service. A daemon whose effective event set (§7) is non-empty and whose refresh timer runs attempts the claim when the timer starts and on every tick, so ownership passes to a surviving daemon once the owner exits; a daemon with an empty set or no refresh timer (an interval of `0`, or the `hosted-harness` profile, which starts none) never claims, and when several configured daemons run, the first to claim governs and the others log that its set applies. A daemon that does not hold the record runs no PR watch — it views, writes, drains and deletes nothing, the slim listing's `state` write to a watch file included — while its state refresh stays as today, so a session bound only in workspaces the owner does not register gets no PR-state events. Daemons share sessions when they resolve the same runtime base, which `QWEN_RUNTIME_DIR` or a workspace's `advanced.runtimeOutputDir` can place outside the global qwen dir (`resolveSessionRuntimeBaseDir` in `packages/cli/src/serve/run-qwen-serve.ts`); two daemons with different global qwen dirs over one runtime base would each own a watch and each deliver, so that configuration is not supported for the PR watch, and at most one such daemon sets `sessionPrWatch`. On shutdown the owner stops its PR watch — the timer, the drains, and every restore and admission in flight — before it removes its record, so a successor never overlaps it; PR A reorders the serve lifecycle's close (`packages/cli/src/serve/serve-app-lifecycle.ts`) to do so. With one writer, the owner's in-process locks are authoritative for every file. Every change to a watch file — detection, the slim listing's `state` write, the `lastBoundAt` refresh, the deletion and each drain step (§5) — reads the file and writes or deletes it within one hold of the owner's in-process lock for that PR, a `PathMutexRegistry` (`packages/cli/src/serve/fs/path-mutex-registry.ts`) keyed by the file, so no write can put back content read before another write; the store's read and write functions take no lock of their own, because `runExclusive` is not re-entrant. On POSIX a daemon claims the watch only when its effective uid owns the watch directory and every watch file in it; one running as another user, root included, or finding a watch file another uid owns logs that, naming the directory or file, and does not claim, as for a record it cannot verify. Since a non-owner writes nothing, every watch file is then created and replaced by the owner's uid, which keeps unreachable the in-place path core's `atomicWriteFile` (`packages/core/src/utils/atomicFileWrite.ts`) takes for a file another uid owns, where a write would fail or tear the file; a watch file another uid places there after the claim, which only a process outside the PR watch can do, is treated as a read error (below): its PR is skipped with a log line naming the file. Each write is therefore a temp file beside the target renamed over it, so an interrupted or failed write leaves the previous file intact:
```ts
interface PrWatchState {
/** Identity as `pullRequestKey` derives it. */
/** The binding's identity: host and `owner/repo` split from `pullRequestKey`'s `host/owner/repo` key, and the number. */
pr: { host: string; repo: string; number: number };
/** When a scan last listed a binder of the PR or a retained row last named it (§4): the deletion clock, kept across restarts. */
lastBoundAt?: string;
@@ -87,7 +87,7 @@ interface PrWatchState {
A PR without a file — its first observation, every bound PR right after this change ships, or a PR bound again after its file was deleted — is initialised silently from the next observation: nothing fires, even if its CI is already failing. A file created by the slim listing holds only `state` and `observedAt`; it is initialised the same way, so the first view after a reopen fills `headSha`, `checks` and `mergeable` without firing `head_change`, `ci_failure` or `merge_conflict`. Once a file has a `headSha`, a `mergeable` it has never known counts as non-conflicting, so a first `conflicting` after the first views returned `UNKNOWN` fires `merge_conflict`, as the channel's `undefined → dirty` does. A file that does not exist, or whose content does not parse or validate, is treated as absent — the PR initialises silently, with a log line for an invalid one — and is never a reason to touch a sidecar. Any other read error (`EACCES`, `EMFILE`, `EIO`) fails closed, as the adapter's `loadPrWatchState` does today: the PR is skipped for that tick — no detection, no drain step, no deletion and no recording write, an admitted key keeping its in-memory mark — and retried on the next one, so an intact file is never overwritten after a failed read.
The owner deletes a file when a complete scan (no `partial`, §4) finds no binder of the PR, and otherwise once 7 days have passed since its `lastBoundAt` — the horizon of the pending age limit (§5), so this drops no entry the age limit would keep; its undelivered entries are dropped with a log line, since no session follows the PR any more. A PR bound again after that initialises silently, as the channel re-baselines a PR it stopped watching. A PR bound again before its file is deleted resumes from its last observation, so a transition in the gap reaches the new binder once. The number of files is bounded by the PRs bound in the last 7 days.
The owner deletes a file when a complete scan (no `partial`, §4) finds no binder of the PR, and otherwise once 7 days have passed since its `lastBoundAt` — the horizon of the pending age limit (§5), so this drops no entry the age limit would keep; its undelivered entries are dropped with a log line, since no session follows the PR any more. A PR bound again after that initialises silently, as the channel re-baselines a PR it stopped watching. A PR bound again before its file is deleted resumes from its last observation, so a transition in the gap reaches the new binder once. A temp file left by a crash mid-write is removed when a daemon next takes ownership, so the number of files is bounded by the PRs bound in the last 7 days.
### 3. Transition rules
@@ -106,7 +106,7 @@ Each transition's key is `pr-state:<owner/repo>#<number>:<type>:<headSha or prev
- **Detection.** A sweep compares a fresh view — or the slim listing's state for a merged or closed PR — with the PR's watch file and writes the new observation, the updated `ciFired` and any detected transition to it in one atomic write under the PR's lock. An observation is stamped with the time it started, and one that started before the file's `observedAt` is discarded — neither compared nor written — so a slow view can never roll the baseline back. Because the file is the only baseline, observation can stay per workspace while detection and delivery are PR-scoped: a binder whose workspace is unreachable, untrusted or retired this tick holds no copy that could lag behind or detect a transition a second time, and when it becomes reachable again it simply becomes a recipient candidate.
- **Target.** One recipient per PR across the whole daemon — one per role group with role-aware actions, below — never one per workspace. The recipient is resolved lazily, at delivery time, and only for keys not yet admitted (§5): among the eligible sessions bound to the PR in the workspaces the bindings scan covers (trusted workspaces and the primary), the most recently active one (transcript mtime, refined by the bridge's live activity), the channel's legacy rule, dispatched through that session's owning runtime. On a partial scan the drain keeps, for each session the scan does not list, its row from the last scan that did list it — complete or partial, as the adapter rebuilds its retained bindings from every snapshot — bounded as the adapter's retention is (`MAX_PARTIAL_RETAINED`, oldest dropped first), and drops a retained row once a complete scan omits the session, so a newer binder that is briefly unreachable still competes by recency — the adapter retains unlisted sessions for the same reason, so that a partial answer listing only an older session cannot displace the newer one — and when such a retained session wins, the entry stays pending until it can be reached. This applies within each role group with role-aware actions. A recipient archived or outranked between detection and drain loses nothing: the drain resolves the next one. Archived sessions, channel-owned sessions (`sourceType: 'channel'`) and machine-owned hidden-catalog sessions (`isHiddenCatalogSource`: `agent-host`, `agent`) are never targets, and a workspace that is no longer registered, no longer covered by the scan (untrusted and not primary), or whose runtime generation is retired (`assertGenerationOpen()`) is skipped with a log line, re-resolved immediately before dispatch. Two registered workspaces for one repository, each with a session following the same PR, therefore produce one delivery, not two, with or without a crash in between.
- **Role-aware actions.** With `sessionPrWatch.actions` set, delivery follows the channel's role-aware mode (#16258) restricted to the four transitions. Keys are `<type>`, `<role>:<type>`, `<role>:default` and `default`, with `<role>` one of `review`, `verify`, `fix`; values are instruction templates with the channel's variables except the sender (`%repo%`, `%pr%`, `%url%`, `%title%`, `%event%`, `%role%`), substituted values sanitised and capped at 200 code points with a visible marker. Each role group — review, verify, fix and untyped, by the binding's `role` in the sidecar — resolves `<role>:<type>` → `<role>:default` → `<type>` → `default`, untyped bindings resolve only the bare chain, and no match skips that group for the transition. A group is matched when its chain resolves a template; a matched group with no eligible binder on a complete scan is skipped for the transition, and on a partial scan it waits (Scan completeness). Each matched group has its own winner (the most recently active binder with that role), its own admission under the group-suffixed key, and a prompt carrying the rendered instruction in the channel's labelled trusted-instruction block; at most four deliveries per transition. Each group's admission records the group in the entry's `served` list and the group-suffixed key in `delivered`, in one write, and a drain skips a served group, so a group is never served twice while another group's winner is still dormant; the entry leaves `pending` once every matched group is served. Without `actions`, delivery is the single winner and the generic prompt.
- **Role-aware actions.** With `sessionPrWatch.actions` set, delivery follows the channel's role-aware mode (#16258) restricted to the four transitions. Keys are `<type>`, `<role>:<type>`, `<role>:default` and `default`, with `<role>` one of `review`, `verify`, `fix`; values are instruction templates, non-empty, trimmed and at most 2,000 code points as in the channel, with the channel's variables (`%repo%`, `%pr%`, `%url%`, `%title%`, `%event%`, `%sender%`, `%role%`): `%repo%` is `owner/repo` as the group winner's bound URL spells it at delivery time, keeping its case as the channel's does, `%pr%` is its number, `%url%` is that URL's origin with `www.` stripped and its port kept, followed by `/<owner/repo>/pull/<number>` (the watch file's key, lowercased and without a port, is not used for display), `%title%` comes from the last view, and `%sender%` is `GitHub`, as for the channel's watch events; `%title%` and `%sender%` are sanitised and capped at 200 code points with a visible marker, as in the channel. Each role group — review, verify, fix and untyped, by the binding's `role` in the sidecar — resolves `<role>:<type>` → `<role>:default` → `<type>` → `default`, untyped bindings resolve only the bare chain, and no match skips that group for the transition. A group is matched when its chain resolves a template; a matched group with no eligible binder on a complete scan is skipped for the transition, and on a partial scan it waits (Scan completeness). Each matched group has its own winner (the most recently active binder with that role), its own admission under the group-suffixed key, and a prompt carrying the rendered instruction in the channel's labelled trusted-instruction block, under the channel's untrusted-data notice that extends to the GitHub-derived values substituted into it; at most four deliveries per transition. Each group's admission records the group in the entry's `served` list and the group-suffixed key in `delivered`, in one write, and a drain skips a served group, so a group is never served twice while another group's winner is still dormant; the entry leaves `pending` once every matched group is served. Without `actions`, delivery is the single winner and the generic prompt. The key and template validation, the chain resolution and the rendering are not reimplemented: PR A moves the channel's own code for them out of `GithubAdapter.ts` into `@qwen-code/channel-base`, with the event vocabulary and the comment-lane class as parameters and the renderer taking the variable values instead of a channel envelope — the channel passes `watch:<type>`, its notification reasons and, for comment events, the `comment` lane (so `<role>:comment` and `comment` still catch them), and derives the values from its envelope as today; the daemon passes the four bare transition types, no lane and the values above, so `fix:ci_failure` is a valid key here and a notification reason such as `mention` is not — and the adapter keeps using it for comment events and, until PR B, for watch events, so `%%` renders a literal `%` and an unknown `%foo%` stays literal exactly as in the channel. `%event%` renders the bare transition type (`ci_failure`); under the alias (§7) a template written for the channel, where it rendered `watch:ci_failure`, therefore renders the bare type.
- **Scan completeness.** The sweep calls the scan in-process for its view set and its recipients. On this tree `partial` means something could not be read — a registered workspace with no runtime, a chats directory that cannot be listed, an unreadable sidecar or transcript, a live session list that cannot be fetched — never a page boundary: the scan is one directory listing per workspace (#16241). A workspace the scan cannot enumerate contributes no views and no newly listed recipients this tick — its sessions' retained rows still compete (Target) — and nothing else depends on it, because no watch state lives in its sidecars; `partial` defers every negative decision: deleting a watch file (§2; under partial scans only the 7-day `lastBoundAt` rule applies), skipping a role group for lack of an eligible binder, and dropping a transition because no candidate remains (below) are decided only on a complete scan; on a partial one the entry stays in `pending` for a later drain, within the 16-entry cap and the 7-day age limit (§5). The slim-listing `state` refresh is unaffected.
- **Archive and restore.** Archiving a session changes nothing in the PR's watch file: the session only leaves the recipient candidates, and its sidecar keeps its top-level `state`, still refreshed. A session restored from the archive rejoins them. If another binder kept the PR watched, the file is current and the restored session receives transitions from then on; if nobody followed the PR, its file was deleted (§2) and the PR initialises silently, so what changed while the session was archived is not replayed into it — the restore is a user action, and the refreshed top-level `state` already shows it in the sidebar. A restore within 7 days of the last binder leaving, while scans stay partial, resumes from the last observation and delivers a transition in the gap once.
- **Mechanism.** PR A extracts the prompt admission from the route handler into a function the route and the refresh both call — the idempotency ring, the classification and the backfill skip move with it, the skip keyed on the trusted classification instead of on the request's own marker — and the refresh calls it in-process, together with the code behind the resume route. The refresh rides the channel bound relay's classification end to end instead of adding a grant: it passes `context.channelPrompt`, which the bridge turns into `qwen.channel.prompt` only from trusted context and the session child honours only from a trusted private parent, and no PR-binding backfill runs on the injected text. The turn carries every effect that classification has for the bound relay in the same sessions today (`Session.ts`), and accepts them: it skips the loop-detected rejection, records no branch checkpoint (it is not a rewind point), runs with the repeated-tool-failure guard off, does not count as a user submission turn, cannot have its model response cut short by queued or send-now user input, and its Stop hook is not interruptible. The transition key (group-suffixed with role-aware actions) is the `idempotencyKey`, so a retry to the same session within the daemon's lifetime deduplicates into the original admission. No display text is set: like the channel's bound relay today, the injected prompt appears in the session's transcript as sent, untrusted-data notice included, so the session's user sees exactly what the model received (the bridge keeps display texts only for channel-owned sessions). No new marker is introduced, so no strip list changes; a route test pins that a client body carrying `qwen.channel.prompt` without the worker credential gets no classification.
@@ -121,64 +121,65 @@ Detection and the new observation are persisted by one atomic write of the PR's
### 6. Configuration
A daemon-level setting in the daemon's settings, read at startup from PR B on (§7): `sessionPrWatch: { events: ['ci_failure', 'merge_conflict', 'head_change', 'state_change'], wakeDormant?: boolean, actions?: Record<string, string> }`, with `QWEN_SESSION_PR_WATCH_EVENTS` and `QWEN_SESSION_PR_WATCH_WAKE_DORMANT` as environment overrides (`actions` is settings-only). Default: no events (off); `wakeDormant` defaults to true, and `false` keeps transitions for dormant sessions in `pending` until their next live turn (§4); without `actions`, delivery is the single winner and the generic prompt, and a key outside the vocabulary or an empty template is a validation error, as in the channel. The refresh timer is started in `packages/cli/src/serve/run-qwen-serve.ts` before the app exists, so it reaches the shared admission and restore through late-bound getters, as it already reaches the archive coordinator (`getArchiveCoordinator`). Latency is the refresh interval (5 minutes by default; values below one minute fall back to the default and `0` disables the sweep). During the deprecation window PR B opens (§7), the GitHub channels' `prWatchEvents` and `watch:*` actions are read as aliases when `sessionPrWatch` is unset.
A daemon-level setting in the daemon's settings, read at startup from PR B on (§7): `sessionPrWatch: { events: ['ci_failure', 'merge_conflict', 'head_change', 'state_change'], wakeDormant?: boolean, actions?: Record<string, string> }`, with `QWEN_SESSION_PR_WATCH_EVENTS` and `QWEN_SESSION_PR_WATCH_WAKE_DORMANT` as environment overrides (`actions` is settings-only). Default: no events (off); `wakeDormant` defaults to true, and `false` keeps transitions for dormant sessions in `pending` until their next live turn (§4); without `actions`, delivery is the single winner and the generic prompt, and a key outside the vocabulary, an empty template or one over 2,000 code points is a validation error, as in the channel. The refresh timer is started in `packages/cli/src/serve/run-qwen-serve.ts` before the app exists, so it reaches the shared admission and restore through late-bound getters, as it already reaches the archive coordinator (`getArchiveCoordinator`). Detection latency is nominally one refresh interval (5 minutes by default; values below one minute fall back to the default and `0` disables the sweep) when the workspace's view set — its open bound PRs plus the closed ones the listing does not return (§1) — has at most 30 entries, and ⌈n/30⌉ intervals for n entries while the set does not change. Neither figure is a bound: a PR's observation moves within the tick as the workspace order rotates (§1) and other workspaces' listings and views run before it, and detection takes longer still when the deadline truncates a window, the `running` guard skips a tick (§1), a change in the set shifts the window, a view fails, the scan cannot enumerate the workspace or read the PR's sidecar, a watch-file read error skips the PR (§2), or ownership passes to another daemon (§2); delivery waits further drains when a retained recipient cannot be reached or a role group waits on a partial scan (§4), when dormant recipients wait for one of the four restore slots the daemon runs at once, each held until its restore settles, so that the last of k such recipients waits ⌈k/4⌉ − 1 further drains or more, and when a restore fails temporarily; with `wakeDormant: false` it waits for the session's next live turn. During the deprecation window PR B opens (§7), the GitHub channels' `prWatchEvents` and `watch:*` actions are read as aliases when `sessionPrWatch` is unset.
### 7. Channel migration
The GitHub channel's `deliverToBoundSessions` and its comment delivery are unchanged. Two PRs, and no window in which both pollers run.
**PR A** adds the daemon observation and delivery with their tests — the watch store and its owner record, the drain, the shared admission and restore, the prompt construction — behind an internal switch that stays off: the refresh runs no view and delivers nothing, and the `sessionPrWatch` setting is not read yet. The adapter watch is untouched, so no build, nightly included, ever runs both pollers, and the two never need to split or hand over event types.
**PR A** adds the daemon observation and delivery with their tests — the watch store and its owner record, the drain, the shared admission and restore, the prompt construction — behind an internal switch that stays off: the refresh runs no view and delivers nothing, and the `sessionPrWatch` setting is not read yet. The adapter watch keeps its behaviour — its only change is that it imports the template code PR A moves into `@qwen-code/channel-base` (§4) — so no build, nightly included, ever runs both pollers, and the two never need to split or hand over event types.
**PR B** turns the switch on and adds the `sessionPrWatch` setting (§6), and in the same change removes the adapter watch — the watch pass, its task-store branches, the 30-PR cap and the CI re-check intervals — opening a one-minor-release deprecation window. B no longer watches from the watch state file: at the first connect after the upgrade it reads the file once to log how many undelivered entries it still held, then renames it with a `.retired` suffix so the line is logged once, and it retires every persisted `watch:` inbound task privately with a log line, never through the comment lane's public fallback. `connect()` keeps accepting `prWatchEvents` and the `watch:*` keys of `boundSessionActions`, logs exactly one warning naming `sessionPrWatch`, runs no watch pass, and the Web Shell descriptor marks them deprecated. With `sessionPrWatch` unset, the daemon reads them as aliases, per host: each GitHub channel this daemon hosts — loaded with the trust gating channel hosting already applies, so an untrusted workspace's settings never contribute (`packages/cli/src/serve/channel-workspace-grouping.ts` refuses to host its channels) — contributes its `prWatchEvents` as the event set and its `watch:*` actions (`watch:<type>` → `<type>`, `<role>:watch:<type>` → `<role>:<type>`, with `default` and `<role>:default` kept) for PRs on its web host. A channel whose `boundSessionActions` is non-empty stays role-aware under the alias even when none of its keys applies to PR-state events, so a channel that subscribed no role to watch events keeps receiving none. The alias counts as the effective set of the daemon that hosts the channel — one daemon per global qwen dir, the channel service reservation — for the owner claim (§2); if another daemon already owns the watch with an explicit set, that set governs. PRs on other hosts get nothing from the alias, an explicit `sessionPrWatch` wins, and two channels on one host take the union of their events, each type taking its actions — the role-aware mode and keys — from the first channel by name whose `prWatchEvents` includes that type and whose configuration delivers it (no actions, or a chain that resolves a template for it), with a logged conflict; the alias logs once when it takes effect. The alias views use the daemon's `gh`, not the channel's token, so a PR the channel could read but the daemon's `gh` cannot gets nothing from the alias: the daemon logs a warning naming the PR and the error class after two consecutive failed views of it, once per failure streak, and also warns once at startup when an alias is in effect while the refresh timer is not running. An operator whose channel connected with its own token makes the host's `gh` able to read those repositories before upgrading. An operator who turned the watch on in v0.26.7 therefore keeps receiving its transitions across the release that ships B for every PR the daemon's `gh` can view, at refresh-interval latency instead of the channel's 60 seconds, with one boundary: before B the daemon kept no watch file, so its first sweep after the upgrade initialises every bound PR silently (§2), and a transition between the channel's last poll and that sweep, or one the channel had not delivered when the upgrade landed — before its next poll, still deferred by backpressure, or dead-lettered — is not delivered; B's one-time log line counts such entries.
**PR B** turns the switch on and adds the `sessionPrWatch` setting (§6), and in the same change removes the adapter watch — the watch pass, its task-store branches, the 30-PR cap and the CI re-check intervals — opening a one-minor-release deprecation window. B no longer watches from the watch state file: at the first connect after the upgrade it reads the file once to log how many undelivered entries it still held, then renames it with a `.retired` suffix so the line is logged once, and it retires every persisted `watch:` inbound task privately with a log line, never through the comment lane's public fallback. `connect()` keeps accepting `prWatchEvents` and the `watch:*` keys of `boundSessionActions`, logs exactly one warning naming `sessionPrWatch`, runs no watch pass, and the Web Shell descriptor marks them deprecated. With `sessionPrWatch` unset, the daemon reads them as aliases, per host: each GitHub channel this daemon hosts — loaded with the trust gating channel hosting already applies, so an untrusted workspace's settings never contribute (`packages/cli/src/serve/channel-workspace-grouping.ts` refuses to host its channels) — contributes its `prWatchEvents` as the event set and its `watch:*` actions (`watch:<type>` → `<type>`, `<role>:watch:<type>` → `<role>:<type>`, with `default` and `<role>:default` kept) for PRs on its web host. A channel whose `boundSessionActions` is non-empty stays role-aware under the alias even when none of its keys applies to PR-state events, so a channel that subscribed no role to watch events keeps receiving none. The alias counts as the effective set of the daemon that hosts the channel — one daemon per global qwen dir, the channel service reservation — for the owner claim (§2); if another daemon already owns the watch with an explicit set, that set governs. PRs on other hosts get nothing from the alias, an explicit `sessionPrWatch` wins, and two channels on one host take the union of their events, each type taking its actions — the role-aware mode and keys — from the first channel by name whose `prWatchEvents` includes that type and whose configuration delivers it (no actions, or a chain that resolves a template for it), with a logged conflict; the alias logs once when it takes effect. The alias views use the daemon's `gh`, not the channel's token, so a PR the channel could read but the daemon's `gh` cannot gets nothing from the alias: the daemon logs a warning naming the PR and the error class after two consecutive failed views of it, once per failure streak, and also warns once at startup when an alias is in effect while the refresh timer is not running. An operator whose channel connected with its own token makes the host's `gh` able to read those repositories before upgrading. An operator who turned the watch on in v0.26.7 therefore keeps receiving its transitions across the release that ships B for every PR the daemon's `gh` can view while the daemon that hosts the channel holds the watch (§2) and runs its refresh timer, at the latency of §6 instead of the channel's 60 seconds, with one boundary: before B the daemon kept no watch file, so its first sweep after the upgrade initialises every bound PR silently (§2), and a transition between the channel's last poll and that sweep, or one the channel had not delivered when the upgrade landed — before its next poll, still deferred by backpressure, or dead-lettered — is not delivered; B's one-time log line counts such entries.
The first minor release after B rejects `prWatchEvents` and the `watch:*` keys at `connect()` with the same message and drops the alias. The setting shipped in v0.26.7, and a shipped, release-noted option is not rejected on sight even though it is opt-in behind `deliverToBoundSessions` and adoption is plausibly near zero; the `watch:*` keys (#16258) have so far shipped only in nightly builds and follow the same window. B updates the channel docs and lands after A.
The first minor release after B rejects `prWatchEvents` and the `watch:*` keys at `connect()` with the same message and drops the alias. The setting shipped in v0.26.7, and a shipped, release-noted option is not rejected on sight even though it is opt-in behind `deliverToBoundSessions` and adoption is plausibly near zero; the `watch:*` keys (#16258) shipped in v0.26.8 and follow the same window for the same reason. B updates the channel docs and lands after A.
## Key decisions
1. **The daemon owns PR state.** It already polls it for the sidebar with the host credential; adding three fields and a delivery step is cheaper than a second poller, and it serves every bound session, not only those behind a configured channel.
2. **Per-PR `gh pr view` by URL, not the bulk rollup.** The refresh's slim bulk listing exists because the rollup field times out on large `--state all` queries; single-PR views avoid that, go by URL so a binding to another repository is fetched correctly, and are bounded by the number of open bound PRs (cap 30 per sweep, rotating, under one tick deadline).
2. **Per-PR `gh pr view` by URL, not the bulk rollup.** The refresh's slim bulk listing exists because the rollup field times out on large `--state all` queries; single-PR views avoid that, go by URL so a binding to another repository is fetched correctly, and are bounded by the view set — the open bound PRs plus the closed ones the listing does not return (§1) — at 30 per sweep, rotating, under one tick deadline.
3. **Same selection and transition rules as the channel watch**, role-aware actions included, so operators moving over see the same events, with one deliberate CI-predicate difference (decision 5, open question 2).
4. **One watch file per PR is the snapshot and the queue.** PR state is per PR, so the baseline, the pending transitions and the delivered record live once per PR in a small daemon-owned file instead of in every binder's sidecar. No copy can lag behind, so the design needs no rule for adopting, merging or re-detecting from stale replicas, and the sidecars keep only what the sidebar reads.
5. **One CI definition** (core's rollup), accepting that `cancelled` and `stale` count as failing until the open question below is settled.
6. **One-release deprecation window with aliases** for `prWatchEvents` and the `watch:*` actions: v0.26.7 shipped the setting (#16117, released 2026-11-04) and names it in the release notes, so it is not rejected on sight; adoption is plausibly near zero (hours old, opt-in behind `deliverToBoundSessions`), so one minor release in which the old fields warn and are read as aliases — no configured operator loses events past the first sweep for a PR the daemon's `gh` can view, and the daemon warns for one it cannot — is enough before `connect()` rejects them.
6. **One-release deprecation window with aliases** for `prWatchEvents` and the `watch:*` actions: v0.26.7 shipped the setting (#16117, released 2026-11-04) and v0.26.8 the `watch:*` actions (#16258, released 2026-11-05), each named in its release notes, so neither is rejected on sight; adoption is plausibly near zero (days old, opt-in behind `deliverToBoundSessions`), so one minor release in which the old fields warn and are read as aliases — no configured operator loses events past the first sweep for a PR the daemon's `gh` can view while the daemon hosting the channel holds the watch and runs its refresh timer, and the daemon warns for one it cannot — is enough before `connect()` rejects them.
7. **Off by default** in this change; turning it on by default is a product decision for later.
## Files affected
| Layer | File | Change |
| -------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| core | `packages/core/src/utils/github-prs.ts` | `fetchGitHubPullRequestView(url)` with `url`, `title`, `headRefOid`, `mergeable`, rollup; mapping; export `aggregateChecks` |
| daemon | `packages/cli/src/serve/server/pr-watch-store.ts` (new) | the watch directory and its per-PR files: read with validation (invalid → absent), atomic write under the per-PR lock, deletion by the owner; the owner record and its claim on every tick |
| daemon | `packages/cli/src/commands/channel/pidfile.ts`, `packages/cli/src/serve/serve-app-lifecycle.ts` | the identity-qualified record and exclusive create generalised to a caller-chosen path; the close stops the PR watch before the owner record is removed |
| daemon | `packages/cli/src/serve/server/session-pr-refresh.ts` | per-PR observation by URL over the scan's bindings (cap 30, rotating GitHub offset, tick-level deadline, rotating workspace order, allowed hosts, workspace environment, daemon-private working directory, closed PRs outside the listing, untrusted primary view-only), transition detection against the watch file, file deletion, the drain with in-flight keys, delivery through the shared admission and restore with the registration released and restores off the tick, role-aware actions, the failed-view warning, the internal switch (off in PR A); the aliases (PR B) |
| daemon | `packages/cli/src/serve/routes/session-pr-bindings.ts` | the in-process scan entry point for the refresh |
| daemon | `packages/cli/src/serve/routes/session.ts` | extract the prompt admission (idempotency ring, classification, backfill skip on the trusted classification) and the restore behind `restoreSessionHandler`, with a resume hook for the drain, into functions the route and the refresh share; the unarchive, restore and re-bind paths clear a recipient exclusion; the bridge and the session child already re-arm `qwen.channel.prompt` from trusted context and need no change |
| daemon | `packages/cli/src/serve/run-qwen-serve.ts` | late-bound getters for the shared admission and restore, passed to the timer |
| config (PR B) | settings schema + `packages/cli/src/serve/server.ts` | `sessionPrWatch` (`events`, `wakeDormant`, `actions`) / `QWEN_SESSION_PR_WATCH_EVENTS`, `QWEN_SESSION_PR_WATCH_WAKE_DORMANT` |
| channel (PR B) | `packages/channels/github/src/GithubAdapter.ts`, `index.ts`, Web Shell descriptor | remove the watch pass; read the watch state file once to count undelivered entries, then retire it; retire persisted `watch:` tasks privately; deprecate `prWatchEvents` and the `watch:*` keys (warn, ignore, alias) |
| docs | `docs/users/features/channels/github.md`, a new user page for `sessionPrWatch`, both design docs | document the move, including role-aware actions |
| tests | collocated `.test.ts` for each changed file | see validation |
| Layer | File | Change |
| ------------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| core | `packages/core/src/utils/github-prs.ts` | `fetchGitHubPullRequestView(url)` with `url`, `title`, `headRefOid`, `mergeable`, rollup; mapping; export `aggregateChecks` |
| daemon | `packages/cli/src/serve/server/pr-watch-store.ts` (new) | the watch directory and its per-PR files: read with validation (invalid → absent), temp-file-and-rename write and deletion by the owner, taking no lock of their own because each caller holds the per-PR lock across its read and write; the owner record and its claim on every tick, refused when another uid owns the directory or a watch file in it |
| daemon | `packages/cli/src/commands/channel/pidfile.ts`, `packages/cli/src/serve/serve-app-lifecycle.ts` | the identity-qualified record and exclusive create generalised to a caller-chosen path; the close stops the PR watch before the owner record is removed |
| daemon | `packages/cli/src/serve/server/session-pr-refresh.ts` | per-PR observation by URL over the scan's bindings (cap 30, rotating GitHub offset, tick-level deadline, rotating workspace order, allowed hosts, workspace environment, daemon-private working directory, closed PRs outside the listing, untrusted primary view-only), transition detection against the watch file, file deletion, the drain with in-flight keys, delivery through the shared admission and restore with the registration released and restores off the tick, role-aware actions, the failed-view warning, the internal switch (off in PR A); the aliases (PR B) |
| daemon | `packages/cli/src/serve/routes/session-pr-bindings.ts` | the in-process scan entry point for the refresh |
| daemon | `packages/cli/src/serve/routes/session.ts` | extract the prompt admission (idempotency ring, classification, backfill skip on the trusted classification) and the restore behind `restoreSessionHandler`, with a resume hook for the drain, into functions the route and the refresh share; the unarchive, restore and re-bind paths clear a recipient exclusion; the bridge and the session child already re-arm `qwen.channel.prompt` from trusted context and need no change |
| daemon | `packages/cli/src/serve/run-qwen-serve.ts` | late-bound getters for the shared admission and restore, passed to the timer |
| config (PR B) | settings schema + `packages/cli/src/serve/server.ts` | `sessionPrWatch` (`events`, `wakeDormant`, `actions`) / `QWEN_SESSION_PR_WATCH_EVENTS`, `QWEN_SESSION_PR_WATCH_WAKE_DORMANT` |
| channel-base and channel (PR A) | `packages/channels/base/src` (new module), `packages/channels/github/src/GithubAdapter.ts` | the bound-action template contract (key and template validation with the event vocabulary as a parameter, chain resolution, rendering from variable values) moved out of the adapter, which imports it back with unchanged behaviour and shares it with the daemon |
| channel (PR B) | `packages/channels/github/src/GithubAdapter.ts`, `index.ts`, Web Shell descriptor | remove the watch pass; read the watch state file once to count undelivered entries, then retire it; retire persisted `watch:` tasks privately; deprecate `prWatchEvents` and the `watch:*` keys (warn, ignore, alias) |
| docs | `docs/users/features/channels/github.md`, a new user page for `sessionPrWatch`, both design docs | document the move, including role-aware actions |
| tests | collocated `.test.ts` for each changed file | see validation |
## Constraints and risks
- **Latency** is the refresh interval (5 minutes by default) instead of the channel's 60-second poll. Operators who need faster signal lower `QWEN_SESSION_PR_REFRESH_MINUTES` (values below 1 fall back to the default; `0` disables the sweep) and pay for it in views: each halving of the interval doubles the view volume, so at the one-minute floor one workspace spends up to 1,800 `gh pr view` calls per hour — the channel watch's figure at its cap — and five workspaces 9,000. The tick deadline (§1) shrinks with the interval (30 s at one minute), so the added views cannot stretch a tick past it by more than one view timeout, and restores run off the tick (§4); the listing calls the refresh already makes are unchanged.
- **Latency** is nominally one refresh interval (5 minutes by default), instead of the channel's 60-second poll; for a view set of n entries it is nominally ⌈n/30⌉ intervals. Neither figure is a bound: §6 lists what makes it longer. Operators who need faster signal lower `QWEN_SESSION_PR_REFRESH_MINUTES` (values below 1 fall back to the default; `0` disables the sweep) and pay for it in views: each halving of the interval doubles the view volume, so at the one-minute floor one workspace spends up to 1,800 `gh pr view` calls per hour — the channel watch's figure at its cap — and five workspaces 9,000. The tick deadline (§1) shrinks with the interval (30 s at one minute), so the added views cannot stretch a tick past it by more than one view timeout, and restores run off the tick (§4); the listing calls the refresh already makes are unchanged.
- **Credential.** Requires `gh` authenticated on the daemon host, which the refresh and the PR sidebar already require. Workspaces without it keep today's behaviour (no state refresh, no events); from PR B on, a PR the daemon's `gh` cannot read receives no PR-state events, with a warning (§7).
- **State files.** One small JSON file per PR bound in the last 7 days, in a private directory owned by one daemon per global qwen dir (§2); the sidecars are unchanged. A daemon that does not hold the owner record delivers no PR-state events, and a record this machine cannot verify blocks the watch until it is deleted by hand, as for the channel service (§2).
- **State files.** One small JSON file per PR bound in the last 7 days, in a private directory owned by one daemon per global qwen dir (§2); the sidecars are unchanged. A daemon that does not hold the owner record delivers no PR-state events, and a record this machine cannot verify blocks the watch until it is deleted by hand, as for the channel service, and a daemon running as a user other than the owner of the watch directory or of a watch file in it never claims it (§2).
- **API budget.** At most 30 `gh pr view` calls per workspace per sweep: 360·W per hour for W swept workspaces at the default interval (1,800 per hour at W = 5, the channel watch's figure for one channel at its cap), on top of the existing listing, under the tick-level view deadline of half the refresh interval (§1). Views are deduplicated per tick by PR across workspaces (§1), so a PR bound in several workspaces is fetched once per tick.
- **Untrusted content into an interactive session.** The event prompt carries the PR title and check names as data, marked untrusted and passed through `sanitizePromptText` under the channel's 6,000-code-point cap (§4), the same contract as the channel's bound prompt; the session's approval mode governs its actions.
- **Untrusted content into an interactive session.** The event prompt carries the PR title and check names as data, marked untrusted and passed through `sanitizePromptText` under the channel's 6,000-code-point cap (§4), the same contract as the channel's bound prompt; the session's approval mode governs its actions. A binding in the untrusted primary is viewed with the daemon's own credential (§1) — on every allowed host with `sessionPrWatch`, on the channel's web host under the alias (§7) — so the title and check names of a PR it names in any repository that credential can read reach that session as untrusted data. That is wider than the channel's watch today, which reads such bindings only on its own web host and with the token the operator gave the channel (`parsePrBindingUrl` in `GithubAdapter.ts`), and under the alias it arrives with the upgrade rather than by opting in. The design accepts it; an operator who does not want it leaves `sessionPrWatch` unset and removes the deprecated `prWatchEvents`, which ends the alias.
- **Admission classification.** A daemon-origin turn that is not an interactive prompt must stay unforgeable from clients. The design adds no new grant and no new marker: it rides the channel prompt's trusted chain — the route's worker credential, the bridge's trusted context, the child's trusted-parent check (§4).
## Validation plan
- Unit: view mapping (`url`, `title`, `headRefOid`, `mergeable` states, rollup) and the identity check (a view that returns another host or number is discarded; a binding spelled with `www.` or `/files` matches; a view of a renamed or transferred repository is accepted and keeps updating the binding's own file); the allowed hosts (a binding on another host, or a workspace whose `origin` is not GitHub, causes no `gh` call; `www.github.com` and `github.com` compare equal; `github.com` is allowed with no channel configured); the view environment (a workspace's own `GH_TOKEN` applies to its PRs) and working directory (the daemon-private watch directory); closed PRs (a closed PR the listing did not return is viewed, and its reopen detected); transition rules, including once-per-head `ci_failure` across a force-push revisit (`A failing → B failing → A failing` fires twice, not three times), the per-head reset (a new head whose first observation is failing fires; a PR already failing at its first observation fires nothing on that sweep or the next), edge-triggered conflict, a first `conflicting` after `UNKNOWN` views firing, `UNKNOWN` after `CONFLICTING` preserving the stored value and emitting no transition, a slim-only file filling its other fields silently at the first view after a reopen, reopen → close; the watch file (atomic write; an observation that started before the file's `observedAt` is discarded; an invalid file treated as absent with every sidecar untouched; any other read error skips the PR for the tick and leaves the file and its pending entries intact; deletion after a complete scan without a binder and 7 days after `lastBoundAt`, the clock surviving a restart, with undelivered entries dropped and logged; silent initialisation after deletion; a re-bind before deletion resuming from the last observation); selection among several binders, including binders in different workspaces and in an untrusted primary; a binder's workspace unreachable, untrusted or retired at detection and back later (it becomes a candidate, nothing is re-detected); role-aware actions (one winner per group, chain resolution, no match or no eligible binder skips a group, group-suffixed keys, a served group recorded before another group is served, the entry removed only after every matched group is served); the drain (two concurrent drains admit once, including one that read `pending` before the other admitted and recorded the key; a drain meeting an in-flight key leaves the entry; a dormant recipient's admission re-reads the file after its restore; a recording write that fails keeps the key admitted in memory and is retried, never re-admitted; an admitted prompt still queued at a restart is not re-injected; a rejected admission is retried; a key in `delivered` is never admitted again; an entry older than 7 days is dropped); the crash windows (before the write, after the write, after admission and before the recording write — the last repeats the key once); a recipient archived or outranked between detection and drain; archive and restore (a restore while another binder kept the PR watched receives later transitions; a restore after the file was deleted initialises silently); configuration parsing (`events`, `wakeDormant`, the `actions` vocabulary); the over-length key hashing, group suffix included; the shared admission function (a client body carrying `qwen.channel.prompt` without the worker credential gets no classification) and the shared restore (a woken recipient's client registration is released once its prompt settles); `wakeDormant: false` holding the entry until the next live turn; restores off the tick (a hanging restore leaves the tick's length unchanged and its key in flight; at most four in flight); restore failures (a permanent one — 404 or a 409 `session_archived`, `worktree_session_superseded`, `worktree_marker_missing` or `worktree_reset_interrupted` — excludes the session and hands the transition to the next binder in its group; a temporary one — timeout, 503, 429, `restore_in_progress`, `session_busy`, `session_writer_conflict` — keeps the entry for the same recipient; a session archived during its restore and unarchived later rejoins through the unarchive path, or after one hour); a partial scan that cannot enumerate the most recent binder keeps its retained row as the winner and the entry pending; negative decisions on a partial scan (no file deleted, no group skipped and no transition dropped for lack of a binder the scan could not see); the owner record (exclusive create; a dead owner's record on this boot is swept and the next configured daemon claims on its next tick; a record from before a reboot blocks the claim with a log line until deleted; a daemon with an empty set never claims; a non-owner views, drains and deletes nothing; the owner stops its PR watch before removing its record on shutdown); deletion under partial scans (a PR a retained row names keeps its `lastBoundAt` fresh anLine truncated
- Unit: view mapping (`url`, `title`, `headRefOid`, `mergeable` states, rollup) and the identity check (a view that returns another host or number is discarded; a binding spelled with `www.` or `/files` matches; a view of a renamed or transferred repository is accepted and keeps updating the binding's own file); the allowed hosts (a binding on another host, or a workspace whose `origin` is not GitHub, causes no `gh` call; `www.github.com` and `github.com` compare equal; `github.com` is allowed with no channel configured); the view environment (a workspace's own `GH_TOKEN` applies to its PRs) and working directory (the daemon-private watch directory); closed PRs (a closed PR the listing did not return is viewed, and its reopen detected); transition rules, including once-per-head `ci_failure` across a force-push revisit (`A failing → B failing → A failing` fires twice, not three times), the per-head reset (a new head whose first observation is failing fires; a PR already failing at its first observation fires nothing on that sweep or the next), edge-triggered conflict, a first `conflicting` after `UNKNOWN` views firing, `UNKNOWN` after `CONFLICTING` preserving the stored value and emitting no transition, a slim-only file filling its other fields silently at the first view after a reopen, reopen → close; the watch file (atomic write; an observation that started before the file's `observedAt` is discarded; an invalid file treated as absent with every sidecar untouched; any other read error skips the PR for the tick and leaves the file and its pending entries intact; deletion after a complete scan without a binder and 7 days after `lastBoundAt`, the clock surviving a restart, with undelivered entries dropped and logged; silent initialisation after deletion; a re-bind before deletion resuming from the last observation); selection among several binders, including binders in different workspaces and in an untrusted primary; a binder's workspace unreachable, untrusted or retired at detection and back later (it becomes a candidate, nothing is re-detected); role-aware actions (one winner per group, chain resolution, no match or no eligible binder skips a group, group-suffixed keys, a served group recorded before another group is served, the entry removed only after every matched group is served); the shared template contract under the daemon's vocabulary (`fix:ci_failure` accepted, `mention` and `watch:ci_failure` rejected, a template over 2,000 code points rejected) and the rendered values (`%repo%` and `%url%` keeping the winner's bound URL's case and port, `%sender%` rendering `GitHub`, `%event%` the bare type, also for an aliased channel template); the moved code's parity in the adapter (the comment-lane fallback, `%event%` rendering `watch:<type>`, `%url%` from the channel's web origin); the drain (two concurrent drains admit once, including one that read `pending` before the other admitted and recorded the key; a detection, slim-listing `state` or `lastBoundAt` write racing a drain's recording write neither restores a removed `pending` entry nor drops a `delivered` key; a drain meeting an in-flight key leaves the entry; a dormant recipient's admission re-reads the file after its restore; a recording write that fails keeps the key admitted in memory and is retried, never re-admitted; an admitted prompt still queued at a restart is not re-injected; a rejected admission is retried; a key in `delivered` is never admitted again; an entry older than 7 days is dropped; the 16-entry `pending` cap drops the oldest entry with a log line; `ciFired` keeps the newest 16 heads and evicts the oldest); the crash windows (before the write, after the write, after admission and before the recording write — the last repeats the key once); a recipient archived or outranked between detection and drain; archive and restore (a restore while another binder kept the PR watched receives later transitions; a restore after the file was deleted initialises silently); configuration parsing (`events`, `wakeDormant`, the `actions` vocabulary); the over-length key hashing, group suffix included; the shared admission function (a client body carrying `qwen.channel.prompt` without the worker credential gets no classification) and the shared restore (a woken recipient's client registration is released once its prompt settles); `wakeDormant: false` holding the entry until the next live turn; restores off the tick (a hanging restore leaves the tick's length unchanged and its key in flight; at most four in flight); restore failures (a permanent one — 404 or a 409 `session_archived`, `worktree_session_superseded`, `worktree_marker_missing` or `worktree_reset_interrupted` — excludes the session and hands the transition to the next binder in its group; a temporary one — timeout, 503, 429, `restore_in_progress`, `session_busy`, `session_writer_conflict` — keeps the entry for the same recipient; a session archived during its restore and unaLine truncated
- Refresh tests (existing harness with a fake `fetchPullRequests`): events delivered once per transition, dormant session restored, archived and channel-owned sessions skipped, cap and rotation (two consecutive sweeps over 40 PRs observe all 40, the second window wrapping to the head of the set), the tick-level deadline (with five workspaces whose views hang, no view starts after half the interval, the next tick is not skipped, and the workspace order rotates), a foreign binding viewed by URL, trust/retirement re-resolution before dispatch, the untrusted primary swept for views only, and the switch (with it off, as PR A ships, no view runs and nothing is delivered even with every input present); in the adapter's tests, acceptance criterion 3 (one warning, no watch pass, zero PR-state API calls from the channel, persisted `watch:` tasks retired privately, the state file read once and renamed, and delivery through the alias).
- E2E (daemon + stubbed `gh` binary): bind a PR in a session, flip the stub's rollup to failing, observe one injected prompt in that session after the next sweep and none after a daemon restart.
## Acceptance criteria
1. With `sessionPrWatch.events` set, a bound session receives one prompt per transition instance for CI failure, merge conflict, head change and merge/close — one per matched role group with `actions` — with the same recurrence semantics as the channel watch, without any GitHub channel configured, except where this design states otherwise: a first observation and a re-bind before the watch file's deletion (§2), the 16-entry cap and the 7-day age limit (§5), no remaining candidate (§4), a PR the daemon's `gh` cannot read (§7), a PR bound under both an old and a new repository name, delivered once per name (§1), a session another process writes live — a CLI session, or another daemon without the session writer lease (§4), a session served only by a daemon that does not hold the owner record, or while a record this machine cannot verify blocks the claim, and daemons with different global qwen dirs over one runtime base, which the PR watch does not support (§2), an admitted prompt still queued when the daemon restarts (§5), and the restart allowance of criterion 2.
1. With `sessionPrWatch.events` set, a bound session receives one prompt per transition instance for CI failure, merge conflict, head change and merge/close — one per matched role group with `actions` — with the same recurrence semantics as the channel watch, without any GitHub channel configured, except where this design states otherwise: a first observation and a re-bind before the watch file's deletion (§2), the 16-entry cap and the 7-day age limit (§5), no remaining candidate (§4), a PR the daemon's `gh` cannot read (§7), a PR bound under both an old and a new repository name, delivered once per name (§1), a session another process writes live — a CLI session, or another daemon without the session writer lease (§4), a session served only by a daemon that does not hold the owner record, or while a record this machine cannot verify, or a watch directory or watch file another user owns, blocks the claim, and daemons with different global qwen dirs over one runtime base, which the PR watch does not support (§2), an admitted prompt still queued when the daemon restarts (§5), and the restart allowance of criterion 2.
2. Without a restart, a transition is detected at most once and each of its keys admitted at most once, whatever happens to the binders' sessions and workspaces in between — archived, outranked, unreadable, untrusted, retired — and even when two drains run concurrently. Across a restart a transition is delivered at most twice — only a crash between an admission and the write that records it repeats one — and at least once unless the PR's watch file is lost or no longer validates (§2), or its prompt was admitted and still queued behind a running turn when the daemon restarted (§5).
3. The GitHub channel no longer issues any PR-state API call; a configuration with `prWatchEvents` or `watch:*` actions still connects, logs exactly one deprecation warning naming `sessionPrWatch`, runs no watch pass, and — with `sessionPrWatch` unset — receives each transition through the daemon that hosts the channel from the first sweep after the upgrade for every PR that daemon's `gh` can view, routed by its `watch:*` actions, with a warning for one it cannot — unless another daemon with an explicit `sessionPrWatch` owns the watch, whose set then governs (§2, §7); from the first minor release after PR B it is rejected at `connect()` with the same message.
3. The GitHub channel no longer issues any PR-state API call; a configuration with `prWatchEvents` or `watch:*` actions still connects, logs exactly one deprecation warning naming `sessionPrWatch`, runs no watch pass, and — with `sessionPrWatch` unset — receives each transition through the daemon that hosts the channel from the first sweep after the upgrade for every PR that daemon's `gh` can view, routed by its `watch:*` actions, with a warning for one it cannot — unless another daemon with an explicit `sessionPrWatch` owns the watch, whose set then governs, or the hosting daemon otherwise does not hold the watch (§2) or runs no refresh timer, which it warns about at startup (§7); from the first minor release after PR B it is rejected at `connect()` with the same message.
4. With `sessionPrWatch` unset and no alias in effect, the refresh's behaviour and API call volume are unchanged.
5. Two trusted workspaces for one repository, each with a session bound to the same PR, receive one delivery per transition, to the most recently active of those sessions, also when the recipient is archived or outranked between detection and drain or either workspace is unreachable for a while; and a force-push back to a head whose CI failure already fired does not fire `ci_failure` again while that head is within the PR's 16-head history.
6. With `wakeDormant: false`, a transition for a dormant session is retained in `pending` and delivered on its next live turn; no build runs both pollers: before PR B the adapter delivers every PR-state event and the daemon none, and from PR B on the daemon delivers all of them.
@@ -18,7 +18,7 @@ daemon 对每个会话绑定的 PR 状态跟踪了两遍,用两套凭据、两
- sidecar 结构(`packages/core/src/services/session-pr-service.ts` 的 `SessionPr`):`number`、`url`、`createdAt`、`state?`、`source?`、`role?`、`issues?`。不持久化 head SHA、CI rollup 或可合并性,所以刷新看不到任何转换。
- 刷新的 GitHub 路径以 **slim** 模式(`number,url,headRefName,state`)跑 `gh pr list --state all --limit 500`,因为 CI rollup 字段会让大的 `--state all` 查询撞上 GitHub GraphQL 服务端超时(504)。非 slim 的 `GH_PR_LIST_FIELDS` 已含 `statusCheckRollup`、`isDraft`、`reviewDecision`、`updatedAt`,但没有 `headRefOid` 或 `mergeable`;rollup 归约函数 `aggregateChecks` 是模块私有的。
- daemon 已经会向自己不握键盘的会话注入 prompt:channel 的绑定中继(带 `qwen.channel.prompt` / `qwen.daemon.channelBoundRelay` 标记与 `idempotencyKey` 的 `POST /session/:id/prompt`),并经 `/session/:id/resume` 路径恢复休眠会话。今天两者都不是进程内调用:中继运行在 channel worker 进程中,经 HTTP 访问 daemon;prompt 准入——幂等环(`promptIdempotencyBySession`)、worker 凭据分类、绑定中继与显示文本标记、以及对 channel prompt 跳过 PR 绑定回填——都内联在路由处理函数里(`packages/cli/src/serve/routes/session.ts`)。`qwen.channel.prompt` 只沿一条受信链生效:路由只接受持有 channel worker 凭据的调用方带上它,bridge(`packages/acp-bridge/src/session-control-plane.ts`)只从受信 context(`context.channelPrompt`)重新装配它,会话子进程(`packages/cli/src/acp-integration/acpAgent.ts`)只为受信的私有父进程重新装配它;`Session.ts` 读它来让该回合免于循环检测拒绝。bridge 只在 channel 自有会话中为回合保留显示文本(`getChannelPromptDisplayText`),绑定中继也不发送显示文本,因此中继的事件在绑定会话的 transcript 中按原样显示。
- channel 监视面向用户的语义记录在 `docs/users/features/channels/github.md`,于 2026-11-03 合入(#16117)并随 v0.26.7(2026-11-04)发布,发布说明点名了该功能;`prWatchEvents` 因此是已发布、运维者可配置的设置。自 #16258(2026-11-04 合入,目前只在 nightly 构建中)起,配置了 `boundSessionActions` 的 channel 按绑定角色路由监视事件——`review`、`verify`、`fix` 或无类型,每个角色一个赢家,各自带一条由 `watch:<type>` 键选出的运维者指令模板;没有该映射时仍是单一的最近赢家。
- channel 监视面向用户的语义记录在 `docs/users/features/channels/github.md`,于 2026-11-03 合入(#16117)并随 v0.26.7(2026-11-04)发布,发布说明点名了该功能;`prWatchEvents` 因此是已发布、运维者可配置的设置。自 #16258(2026-11-04 合入,随 v0.26.8 于 2026-11-05 发布,发布说明点名了这些模板以及编辑它们的 channel 编辑器)起,配置了 `boundSessionActions` 的 channel 按绑定角色路由监视事件——`review`、`verify`、`fix` 或无类型,每个角色一个赢家,各自带一条由 `watch:<type>` 键选出的运维者指令模板;没有该映射时仍是单一的最近赢家。
## 目标
@@ -40,9 +40,9 @@ daemon 对每个会话绑定的 PR 状态跟踪了两遍,用两套凭据、两
刷新保留用于 `state` 的 slim `--state all` 列表,并对绑定扫描所枚举的该 workspace 会话中的每个绑定 PR 增加一次 `gh pr view <url> --json url,title,headRefOid,mergeable,state,statusCheckRollup`,merged 的 PR 以及本次扫描 slim 列表返回了的 closed PR 除外;因此不会为隐藏目录会话、以及 transcript 已归档的 sidecar 做 view,而只存在于 bridge 内存中的绑定在其 sidecar 写入后才被 view。merged 按该 PR 的最新观察判定为终态(其监视文件的 `state`,§2;尚无文件时看顶层 `state`)。closed 不是终态,因为 closed 的 PR 可以重新打开:列表返回的 closed PR 由列表观察;列表没有返回的 closed PR——不受信 primary 的全部 PR(它没有列表)、指向别的仓库的绑定(永远不在本仓库的列表里)、超出列表 500 个上限的 PR——排在 open PR 之后留在 view 旋转中,因此重新打开总能被看到。
view 按绑定的 URL 执行,绝不在 checkout 内按编号执行:`gh` 会把裸编号解析到该 workspace 自己的仓库,而绑定可以指向别的仓库——扫描已经把这类绑定当作外来绑定处理:`updateSessionPrStates` 只把列表中的状态写进规范 URL 一致的条目,`isForeign` 让它们不参与关闭 issue 查询,并让 merged 的外来绑定不经该查询直接收敛。只有 view 返回的 PR 与绑定的 host 和编号相同时才被采用,比较方式与刷新的 `pullRequestKey` 相同,因此绑定路径容许的带 `www.`、`/files`、`.diff` 或 `.patch` 的写法与 `gh` 返回的规范 URL 匹配。host 与编号相同而 `owner/repo` 不同,表示仓库被改名或转移、GitHub 从旧名解析过去:该 view 被采用,每个 PR 记一次日志。host 或编号不同则记日志并丢弃。监视文件始终以绑定自身的身份——绑定 URL 的 `pullRequestKey`——为键,因此改名对已有绑定没有任何影响:其文件、绑定者、`lastBoundAt` 与收件者都与之前一样找到。同时以仓库旧名与新名被绑定的 PR 按每个名字各监视一次,其每个转换按每个名字各投递一次;这是明确的边界。view 只发往 GitHub host:`github.com`、daemon 的 `gh` 配置中已列出的 host,以及本 daemon 承载的每个 GitHub channel 的 web host,像 `pullRequestKey` 那样转小写并去掉 `www.` 后比较。其它 host 上的绑定在任何 `gh` 调用之前就记一行日志跳过,因此绑定 URL——或 `origin` 根本不是 GitHub 的 workspace——无法让 `gh` 把 daemon 的企业版凭据(`GH_ENTERPRISE_TOKEN`)出示给任意 host。view 使用被扫描 workspace 的环境(`runtime.env.effectiveEnv`),与其列表相同,因此 workspace 自己的 `GH_TOKEN` 或 `GH_CONFIG_DIR` 作用于它的 PR;不受信 primary 的 view 使用 daemon 自己的环境。view 的工作目录是 daemon 私有的监视目录(§2,权限 0700),绝不是 `os.tmpdir()` 这类共享目录,因为 `gh` 的解析(`resolveGhBinary`)会把 `PATH` 中相对或空的条目按子进程的工作目录解析。
view 按绑定的 URL 执行,绝不在 checkout 内按编号执行:`gh` 会把裸编号解析到该 workspace 自己的仓库,而绑定可以指向别的仓库——扫描已经把这类绑定当作外来绑定处理:`updateSessionPrStates` 只把列表中的状态写进规范 URL 一致的条目,`isForeign` 让它们不参与关闭 issue 查询,并让 merged 的外来绑定不经该查询直接收敛。只有 view 返回的 PR 与绑定的 host 和编号相同时才被采用,两个 URL 都按刷新的 `pullRequestKey` 的方式规范化(host 转小写、去掉 `www.` 并舍弃端口;忽略编号之后的任何路径(如 `/files`)以及 `.diff` 或 `.patch` 后缀),因此绑定路径容许的写法与 `gh` 返回的规范 URL 匹配;与 `pullRequestKey` 自身的 `host/owner/repo` 键不同,这里刻意不比较 `owner/repo`。host 与编号相同而 `owner/repo` 不同,表示仓库被改名或转移、GitHub 从旧名解析过去:该 view 被采用,每个 PR 记一次日志。host 或编号不同则记日志并丢弃。监视文件始终以绑定自身的身份——绑定 URL 的 `pullRequestKey`——为键,因此改名对已有绑定没有任何影响:其文件、绑定者、`lastBoundAt` 与收件者都与之前一样找到。同时以仓库旧名与新名被绑定的 PR 按每个名字各监视一次,其每个转换按每个名字各投递一次;这是明确的边界。view 只发往 GitHub host:`github.com`、daemon 的 `gh` 配置中已列出的 host,以及本 daemon 承载的每个 GitHub channel 的 web host,像 `pullRequestKey` 那样转小写并去掉 `www.` 后比较。其它 host 上的绑定在任何 `gh` 调用之前就记一行日志跳过,因此绑定 URL——或 `origin` 根本不是 GitHub 的 workspace——无法让 `gh` 把 daemon 的企业版凭据(`GH_ENTERPRISE_TOKEN`)出示给任意 host。view 使用被扫描 workspace 的环境(`runtime.env.effectiveEnv`),与其列表相同,因此 workspace 自己的 `GH_TOKEN` 或 `GH_CONFIG_DIR` 作用于它的 PR;不受信 primary 的 view 使用 daemon 自己的环境。view 的工作目录是 daemon 私有的监视目录(§2,权限 0700),绝不是 `os.tmpdir()` 这类共享目录,因为 `gh` 的解析(`resolveGhBinary`)会把 `PATH` 中相对或空的条目按子进程的工作目录解析。
每次 workspace 扫描的 view 上限 30 个,使用与 Aone 路径 `sweepStart` 同形的旋转窗口;GitHub 路径报告自己消费的数量,定时器为它按 workspace 推进一个 GitHub 偏移(今天只有 Aone 路径报告)。窗口在一次扫描内与 Aone 的一样会回绕:对 40 个 open 绑定 PR 的两次连续扫描观察到全部 40 个——第二个窗口从第 31 个 PR 开始,回绕到集合头部,重复了第一次扫描的 20 次 view——超过 30 个的 workspace 在没有截止时间截断窗口时,于 ⌈n/30⌉ 次扫描内观察到每一个。重复的 view 仍在上限之内,并让这些 PR 更新鲜,这就是不把窗口改成不回绕的原因。
每次 workspace 扫描的 view 上限 30 个,使用与 Aone 路径 `sweepStart` 同形的旋转窗口;GitHub 路径报告自己消费的数量,定时器为它按 workspace 推进一个 GitHub 偏移(今天只有 Aone 路径报告)。窗口在一次扫描内与 Aone 的一样会回绕:对 40 个 open 绑定 PR 的两次连续扫描观察到全部 40 个——第二个窗口从第 31 个 PR 开始,回绕到集合头部,重复了第一次扫描的 20 次 view——超过 30 个的 workspace 在没有截止时间截断窗口、且集合期间不变时,于 ⌈n/30⌉ 次扫描内观察到每一个。重复的 view 仍在上限之内,并让这些 PR 更新鲜,这就是不把窗口改成不回绕的原因。
view 受一个 tick 级截止时间约束,而不是 Aone 循环按 workspace 的 `AONE_SWEEP_VIEW_BUDGET_MS`。定时器在一个 `running` 保护后面顺序遍历 workspace,W 个 workspace 各花满 60 秒预算再加一次在飞的 10 秒 view,会把一个 tick 拉长到 70·W 秒——W = 5 起就超过 5 分钟间隔——保护随即让所有 workspace 跳过下一个 tick。因此定时器在 tick 开始时算出一个截止时间(半个刷新间隔之后),交给该 tick 的每个 workspace 扫描。过了截止时间,扫描不再发起新的 view(在飞的一次仍可能跑满自己的 `GH_TIMEOUT_MS`);休眠会话的恢复根本不在 tick 上运行(§4);旋转按实际发起的 view 数推进;tick 遍历 workspace 的顺序逐 tick 轮转,持续超时不会饿死任何 workspace。单个 PR 的 view 或投递失败被隔离,扫描其余部分照常进行。截止时间只约束本设计新增的工作:slim 列表与关闭 issue 的查询保留各自的单次调用超时,Aone 循环保留其按 workspace 的预算,因此间隔很短时多个慢 workspace 仍可能让 tick 超时,与今天一样。
@@ -54,11 +54,11 @@ view 受一个 tick 级截止时间约束,而不是 Aone 循环按 workspace
### 2. 按 PR 的监视状态
监视状态属于 PR,而不属于跟随它的某个会话,因此它在每个 PR 一个、由 daemon 持有的监视文件中只存一份——`<global qwen dir>/serve/pr-watch/<host/owner/repo#number 的 sha256 前 40 位十六进制>.json`(即绑定自身的身份,§1),目录以 0700 权限创建——而不放在会话 sidecar 中;sidecar 照旧保留供侧栏使用的 `state` 与 `issues`。每个 PR 一个文件、而不是每个绑定者一份副本,意味着每个 PR 恰好只有一个基线、一个队列和一份已投递记录,无论有多少会话绑定它、本 tick 它们的哪些 workspace 可达、受信或已退役:没有副本会落后,因此永远不需要从陈旧副本采用、合并或重新检测任何东西。监视目录由一个 daemon 持有,以 0700 权限创建,因为多个 daemon 可以共用一个 global qwen dir,甚至同一个 workspace(`docs/design/2026-09-02-relaxed-standalone-daemon-ownership.md`)。所有权是一条 owner 记录 `pr-watch/owner.pid`,其写入与检查方式与 channel 服务 pidfile 完全相同(`packages/cli/src/commands/channel/pidfile.ts`,推广为由调用方指定路径):在该模块的锁下以独占创建(`wx`)写入,携带 owner 的 pid、进程启动令牌与 PID 命名空间。能证明其进程在本次启动、本 PID 命名空间内已不存在的记录会被清扫并替换;本机无法核验的记录——写于重启机器之前、由另一台机器或共用该 home 的另一个 PID 命名空间写入——会被保留,并在被手动删除之前阻止认领,每次尝试都记一行点名该文件的日志,与 channel 服务今天的行为相同;在没有进程启动令牌的平台(macOS)上,pid 被复用的记录视为存活,channel 服务亦然。生效事件集合(§7)非空的 daemon 在启动时以及每个 tick 尝试认领,因此 owner 退出后所有权会转给仍在运行的 daemon;事件集合为空的 daemon 从不认领;运行多个已配置的 daemon 时,先认领者生效,其余记录一条日志说明采用它的集合。没有持有该记录的 daemon 不运行 PR 监视——不 view、不排空、不删除任何东西——其状态刷新与今天相同,因此只绑定在 owner 未注册的 workspace 中的会话收不到 PR 状态事件。daemon 在解析到同一个运行时根目录时共享会话,而 `QWEN_RUNTIME_DIR` 或 workspace 的 `advanced.runtimeOutputDir` 可以把它放到 global qwen dir 之外(`packages/cli/src/serve/run-qwen-serve.ts` 中的 `resolveSessionRuntimeBaseDir`);global qwen dir 不同、却共用一个运行时根目录的两个 daemon 会各自持有一个监视、各自投递,因此 PR 监视不支持这种配置,这样的 daemon 至多一个设置 `sessionPrWatch`。关闭时 owner 先停止其 PR 监视——定时器、排空,以及每个进行中的恢复与准入——再删除其记录,因此后继者绝不会与它重叠;PR A 为此调整 serve 生命周期的关闭顺序(`packages/cli/src/serve/serve-app-lifecycle.ts`)。由于只有一个写入方,owner 的进程内锁对每个文件都是权威的。每次写入都是原子的(临时文件加重命名),并在 owner 的每 PR 一把的进程内锁之下进行:
监视状态属于 PR,而不属于跟随它的某个会话,因此它在每个 PR 一个、由 daemon 持有的监视文件中只存一份——`<global qwen dir>/serve/pr-watch/<first 40 hex of sha256 of host/owner/repo#number>.json`(即绑定自身的身份,§1)——而不放在会话 sidecar 中;sidecar 照旧保留供侧栏使用的 `state` 与 `issues`。每个 PR 一个文件、而不是每个绑定者一份副本,意味着每个 PR 恰好只有一个基线、一个队列和一份已投递记录,无论有多少会话绑定它、本 tick 它们的哪些 workspace 可达、受信或已退役:没有副本会落后,因此永远不需要从陈旧副本采用、合并或重新检测任何东西。监视目录由一个 daemon 持有,以 0700 权限创建,因为多个 daemon 可以共用一个 global qwen dir,甚至同一个 workspace(`docs/design/2026-09-02-relaxed-standalone-daemon-ownership.md`)。所有权是一条 owner 记录 `pr-watch/owner.pid`,其写入与检查方式与 channel 服务 pidfile 完全相同(`packages/cli/src/commands/channel/pidfile.ts`,推广为由调用方指定路径):在该模块的锁下以独占创建(`wx`)写入,携带 owner 的 pid、进程启动令牌与 PID 命名空间。能证明其进程在本次启动、本 PID 命名空间内已不存在的记录会被清扫并替换;本机无法核验的记录——写于重启机器之前、由另一台机器或共用该 home 的另一个 PID 命名空间写入——会被保留,并在被手动删除之前阻止认领,每次尝试都记一行点名该文件的日志,与 channel 服务今天的行为相同;在没有进程启动令牌的平台(macOS)上,pid 被复用的记录视为存活,channel 服务亦然。生效事件集合(§7)非空且刷新定时器在运行的 daemon 在定时器启动时以及每个 tick 尝试认领,因此 owner 退出后所有权会转给仍在运行的 daemon;事件集合为空或没有刷新定时器(间隔为 `0`,或不启动定时器的 `hosted-harness` profile)的 daemon 从不认领;运行多个已配置的 daemon 时,先认领者生效,其余记录一条日志说明采用它的集合。没有持有该记录的 daemon 不运行 PR 监视——不 view、不写入、不排空、不删除任何东西,包括 slim 列表对监视文件的 `state` 写入——其状态刷新与今天相同,因此只绑定在 owner 未注册的 workspace 中的会话收不到 PR 状态事件。daemon 在解析到同一个运行时根目录时共享会话,而 `QWEN_RUNTIME_DIR` 或 workspace 的 `advanced.runtimeOutputDir` 可以把它放到 global qwen dir 之外(`packages/cli/src/serve/run-qwen-serve.ts` 中的 `resolveSessionRuntimeBaseDir`);global qwen dir 不同、却共用一个运行时根目录的两个 daemon 会各自持有一个监视、各自投递,因此 PR 监视不支持这种配置,这样的 daemon 至多一个设置 `sessionPrWatch`。关闭时 owner 先停止其 PR 监视——定时器、排空,以及每个进行中的恢复与准入——再删除其记录,因此后继者绝不会与它重叠;PR A 为此调整 serve 生命周期的关闭顺序(`packages/cli/src/serve/serve-app-lifecycle.ts`)。由于只有一个写入方,owner 的进程内锁对每个文件都是权威的。对监视文件的每一次改动——检测、slim 列表的 `state` 写入、`lastBoundAt` 刷新、删除以及每个排空步骤(§5)——都在 owner 对该 PR 的进程内锁的同一次持有之内读取文件并写入或删除它,该锁即以文件为键的 `PathMutexRegistry`(`packages/cli/src/serve/fs/path-mutex-registry.ts`),因此任何写入都不会写回在另一次写入之前读到的内容;存储的读写函数自身不取锁,因为 `runExclusive` 不可重入。在 POSIX 上,daemon 只有在其有效 uid 拥有监视目录及其中每个监视文件时才认领监视;以其他用户(包括 root)运行、或发现某个监视文件属于另一个 uid 的 daemon 记一行点名该目录或文件的日志且不认领,与无法核验的记录相同。由于非 owner 不写入任何东西,每个监视文件都由 owner 的 uid 创建和替换,这使 core 的 `atomicWriteFile`(`packages/core/src/utils/atomicFileWrite.ts`)对属于另一个 uid 的文件所走的原地写入路径无法到达——在那条路径上写入会失败或写坏文件;认领之后由另一个 uid 放入的监视文件(只有 PR 监视之外的进程才能做到)按读取错误处理(见下):跳过其 PR,并记一行点名该文件的日志。因此每次写入都是在目标旁写临时文件再改名覆盖,被中断或失败的写入会保留原文件不变:
```ts
interface PrWatchState {
/** 按 `pullRequestKey` 推导的身份。 */
/** 绑定的身份:从 `pullRequestKey` 的 `host/owner/repo` 键拆出的 host 与 `owner/repo`,以及编号。 */
pr: { host: string; repo: string; number: number };
/** 扫描最后一次列出该 PR 的绑定者、或保留行最后一次提到它的时间(§4):删除所用的时钟,跨重启保留。 */
lastBoundAt?: string;
@@ -87,7 +87,7 @@ interface PrWatchState {
没有文件的 PR——其首次观察、本次改动发布后的每个绑定 PR,或文件被删除后再次被绑定的 PR——由下一次观察静默初始化:即使其 CI 已经失败,也不触发任何东西。由 slim 列表创建的文件只有 `state` 与 `observedAt`;它同样静默初始化,因此重新打开后的第一次 view 填入 `headSha`、`checks` 与 `mergeable`,不触发 `head_change`、`ci_failure` 或 `merge_conflict`。文件一旦有了 `headSha`,其从未知晓的 `mergeable` 视为非冲突,因此在最初几次 view 返回 `UNKNOWN` 之后首次出现的 `conflicting` 会触发 `merge_conflict`,与 channel 的 `undefined → dirty` 相同。不存在的文件、或内容无法解析或校验不通过的文件,视为不存在——该 PR 静默初始化,无效文件记一行日志——绝不会因此去动任何 sidecar。其它读取错误(`EACCES`、`EMFILE`、`EIO`)一律拒绝继续,与适配器今天的 `loadPrWatchState` 相同:该 PR 本 tick 被跳过——不检测、不执行排空步骤、不删除、不做记录写入,已准入的 key 保留其内存标记——下个 tick 重试,因此完好的文件绝不会在一次读取失败后被覆盖。
owner 在一次完整扫描(无 `partial`,§4)发现某 PR 没有任何绑定者时删除其文件,否则在距其 `lastBoundAt` 满 7 天时删除——与 pending 年龄上限(§5)的期限相同,因此不会丢掉年龄上限本会保留的条目;其中未投递的条目丢弃并记日志,因为已没有会话跟随该 PR。此后再被绑定的 PR 静默初始化,与 channel 对停止监视过的 PR 重新建立基线相同。在文件删除之前再被绑定的 PR 从其最后一次观察继续,因此间隔期内的转换会投递给新的绑定者一次。文件数量以最近 7 天内被绑定过的 PR 为界。
owner 在一次完整扫描(无 `partial`,§4)发现某 PR 没有任何绑定者时删除其文件,否则在距其 `lastBoundAt` 满 7 天时删除——与 pending 年龄上限(§5)的期限相同,因此不会丢掉年龄上限本会保留的条目;其中未投递的条目丢弃并记日志,因为已没有会话跟随该 PR。此后再被绑定的 PR 静默初始化,与 channel 对停止监视过的 PR 重新建立基线相同。在文件删除之前再被绑定的 PR 从其最后一次观察继续,因此间隔期内的转换会投递给新的绑定者一次。写入中途崩溃遗留的临时文件在下一个 daemon 取得所有权时被删除,因此文件数量以最近 7 天内被绑定过的 PR 为界。
### 3. 转换规则
@@ -100,13 +100,13 @@ owner 在一次完整扫描(无 `partial`,§4)发现某 PR 没有任何绑
| `merge_conflict` | `mergeable` 从非冲突或尚未知晓的值变为 `conflicting`(边沿触发;`conflicting → mergeable → conflicting` 会再次触发)。 |
| `state_change` | `state` 离开 `open`(merged 或 closed)。重新打开会让 `state` 回到 `open`,之后再关闭会再次触发。 |
每个转换的 key 为 `pr-state:<owner/repo>#<number>:<type>:<headSha 或 previous→next>:<detectedAt>`;`detectedAt` 让文件删除后检测到的实例彼此不同,而 `ci_failure` 通过 `ciFired` 保持每个 head 一次。`ciFired` 是 daemon 侧与 channel 持久化 `fired` 环等价的东西:有界的逐 head 历史,因此 force-push 回到 `A` 之后的 `A failing → B failing → A failing` 不会为 `A` 再触发一次。上限刻意不同:channel 每个 PR 保留 4 类事件共用的 64 个 key,被挤出后只钉住当前 head;daemon 每个 PR 保留最近 16 个失败过的 head,一个 head 在超过 16 个其它失败 head 之后再被回访会再次触发,与 channel 被挤出后的行为一致。启用角色化动作时(§4),每个角色组的准入使用加了 `:<group>` 后缀的 key。超过 prompt 路由 200 字符 `idempotencyKey` 上限的 key 确定性地哈希为 `pr-state:h:<sha256 前 40 位十六进制>`(channel 的形状),重试仍能去重(很长的 `owner/repo` 配上双 SHA 的 `previous→next` 形式在加后缀之前就可达 275 字符)。
每个转换的 key 为 `pr-state:<owner/repo>#<number>:<type>:<headSha or previous→next>:<detectedAt>`;`detectedAt` 让文件删除后检测到的实例彼此不同,而 `ci_failure` 通过 `ciFired` 保持每个 head 一次。`ciFired` 是 daemon 侧与 channel 持久化 `fired` 环等价的东西:有界的逐 head 历史,因此 force-push 回到 `A` 之后的 `A failing → B failing → A failing` 不会为 `A` 再触发一次。上限刻意不同:channel 每个 PR 保留 4 类事件共用的 64 个 key,被挤出后只钉住当前 head;daemon 每个 PR 保留最近 16 个失败过的 head,一个 head 在超过 16 个其它失败 head 之后再被回访会再次触发,与 channel 被挤出后的行为一致。启用角色化动作时(§4),每个角色组的准入使用加了 `:<group>` 后缀的 key。超过 prompt 路由 200 字符 `idempotencyKey` 上限的 key 确定性地哈希为 `pr-state:h:<first 40 hex of sha256>`(channel 的形状),重试仍能去重(很长的 `owner/repo` 配上双 SHA 的 `previous→next` 形式在加后缀之前就可达 275 字符)。
### 4. 投递
- **检测。** 扫描把新鲜 view——或 merged / closed PR 的 slim 列表状态——与该 PR 的监视文件比较,并在该 PR 的锁下以一次原子写入写入新观察、更新后的 `ciFired` 与检测到的转换。每次观察都带有其开始时间,开始时间早于文件 `observedAt` 的观察直接丢弃——既不比较也不写入——因此慢的 view 绝不会让基线倒退。由于该文件是唯一的基线,观察可以仍按 workspace 进行,而检测与投递以 PR 为范围:本 tick 所在 workspace 不可达、不受信或已退役的绑定者不持有任何可能落后、或会把一个转换检测第二次的副本;它重新可达时只是重新成为收件者候选。
- **目标。** 整个 daemon 范围内每个 PR 只有一个收件会话——启用下文的角色化动作时每个角色组一个——绝不是每个 workspace 一个。收件者在投递时才惰性决定,且只针对尚未准入的 key(§5):在绑定扫描覆盖的 workspace(受信 workspace 与 primary)中绑定该 PR 的合格会话里取最近活跃的那个(transcript mtime,再以 bridge 的 live 活动修正),即 channel 的传统规则,经该会话所属的 runtime 派发。扫描不完整时,排空为本次扫描未列出的每个会话保留最近一次列出它的扫描中的行——无论那次扫描完整与否,与适配器从每次快照重建其保留绑定相同——规模与适配器的保留上限相同(`MAX_PARTIAL_RETAINED`,最旧的先丢弃),一次完整扫描不再列出该会话时丢弃其保留行,因此暂时不可达的较新绑定者仍按最近活跃程度参与竞争——适配器出于同样的原因保留未列出的会话,使只列出较旧会话的不完整答案无法取代较新的那个——这样保留下来的会话胜出时,条目留在 pending 直到它可达。启用角色化动作时,这一规则在每个角色组内适用。检测与排空之间被归档或被别的会话超过的收件者不会丢任何东西:排空解析出下一个。归档会话、channel 自有会话(`sourceType: 'channel'`)与机器自有的隐藏目录会话(`isHiddenCatalogSource`:`agent-host`、`agent`)永远不是目标;不再注册、不再被扫描覆盖(不受信且非 primary)或 runtime 代际已退役(`assertGenerationOpen()`)的 workspace 记一行日志跳过,并在派发前立即重新解析。因此同一仓库注册了两个 workspace、各有一个会话跟随同一个 PR 时,无论中间是否崩溃,都只投递一次而非两次。
- **角色化动作。** 设置 `sessionPrWatch.actions` 后,投递遵循 channel 的角色化模式(#16258),限于四种转换。键为 `<type>`、`<role>:<type>`、`<role>:default` 与 `default`,`<role>` 取 `review`、`verify`、`fix` 之一;值为指令模板,变量与 channel 相同但没有发送者(`%repo%`、`%pr%`、`%url%`、`%title%`、`%event%`、`%role%`),代入值经清洗并以 200 码点为上限、带可见标记。每个角色组——review、verify、fix 与无类型,按 sidecar 中绑定的 `role`——依次解析 `<role>:<type>` → `<role>:default` → `<type>` → `default`,无类型绑定只解析裸链,无匹配则该组跳过这次转换。一个组的链解析出模板时,该组为匹配;匹配的组在完整扫描中没有合格绑定者时跳过这次转换,在不完整的扫描中则等待(扫描完整性)。每个匹配的组有自己的赢家(该角色最近活跃的绑定者)、以加组后缀的 key 单独准入,prompt 在 channel 带标签的受信指令块中携带渲染后的指令;每个转换最多四次投递。每个组准入后,都以一次写入把该组记进条目的 `served`、把加组后缀的 key 记进 `delivered`;排空跳过已服务的组,因此另一组的赢家仍休眠时,已服务的组不会被服务第二次;每个匹配组都已服务后,条目才离开 `pending`。没有 `actions` 时,投递为单一赢家与通用 prompt。
- **角色化动作。** 设置 `sessionPrWatch.actions` 后,投递遵循 channel 的角色化模式(#16258),限于四种转换。键为 `<type>`、`<role>:<type>`、`<role>:default` 与 `default`,`<role>` 取 `review`、`verify`、`fix` 之一;值为指令模板,与 channel 一样须非空、经裁剪且不超过 2,000 码点,变量与 channel 相同(`%repo%`、`%pr%`、`%url%`、`%title%`、`%event%`、`%sender%`、`%role%`):`%repo%` 为投递时该组赢家的绑定 URL 中原样拼写的 `owner/repo`,与 channel 一样保留大小写,`%pr%` 为其编号,`%url%` 为该 URL 的 origin(去掉 `www.`、保留端口)后接 `/<owner/repo>/pull/<number>`(监视文件的键已转小写且不含端口,不用于显示),`%title%` 取自最近一次 view,`%sender%` 为 `GitHub`,与 channel 的监视事件相同;`%title%` 与 `%sender%` 与 channel 一样经清洗并以 200 码点为上限、带可见标记。每个角色组——review、verify、fix 与无类型,按 sidecar 中绑定的 `role`——依次解析 `<role>:<type>` → `<role>:default` → `<type>` → `default`,无类型绑定只解析裸链,无匹配则该组跳过这次转换。一个组的链解析出模板时,该组为匹配;匹配的组在完整扫描中没有合格绑定者时跳过这次转换,在不完整的扫描中则等待(扫描完整性)。每个匹配的组有自己的赢家(该角色最近活跃的绑定者)、以加组后缀的 key 单独准入,prompt 在 channel 带标签的受信指令块中携带渲染后的指令,并沿用 channel 的不可信数据提示——该提示同样覆盖代入指令中的 GitHub 派生值;每个转换最多四次投递。每个组准入后,都以一次写入把该组记进条目的 `served`、把加组后缀的 key 记进 `delivered`;排空跳过已服务的组,因此另一组的赢家仍休眠时,已服务的组不会被服务第二次;每个匹配组都已服务后,条目才离开 `pending`。没有 `actions` 时,投递为单一赢家与通用 prompt。键与模板校验、链解析与渲染都不重新实现:PR A 把 channel 自己的这部分代码从 `GithubAdapter.ts` 移到 `@qwen-code/channel-base`,以事件词表与评论 lane 类别为参数,渲染函数接收变量值而不是 channel 的 envelope——channel 传入 `watch:<type>`、它的通知原因,以及评论事件的 `comment` lane(因此 `<role>:comment` 与 `comment` 仍能匹配它们),并照旧从 envelope 推出变量值;daemon 传入四种裸转换类型、不传 lane,并传入上述变量值,因此这里 `fix:ci_failure` 是合法的键、`mention` 这类通知原因则不是——适配器继续用它处理评论事件,并在 PR B 之前处理监视事件,因此 `%%` 渲染为字面的 `%`、未知的 `%foo%` 原样保留,与 channel 完全一致。`%event%` 渲染为裸转换类型(`ci_failure`);因此在别名下(§7),为 channel 编写、原本渲染为 `watch:ci_failure` 的模板会渲染为裸类型。
- **扫描完整性。** 扫描在进程内被调用,为刷新提供 view 集合与收件者。在当前代码中 `partial` 表示有东西读不到——已注册但没有 runtime 的 workspace、列不出来的 chats 目录、读不了的 sidecar 或 transcript、拿不到的 live 会话列表——绝不是分页边界:扫描是每个 workspace 一次目录列表(#16241)。无法枚举的 workspace 本 tick 不提供 view,也不提供新列出的收件者——其会话的保留行仍参与竞争(目标)——此外再无任何东西依赖它,因为没有监视状态存放在它的 sidecar 中;`partial` 推迟所有否定性的决定:删除监视文件(§2;扫描不完整时只适用 7 天的 `lastBoundAt` 规则)、因没有合格绑定者而跳过某个角色组、因没有剩余候选而丢弃转换(下文)都只在完整扫描时判定;不完整的扫描中条目留在 `pending` 等待之后的排空,受 16 条上限与 7 天年龄上限约束(§5)。slim 列表的 `state` 刷新不受影响。
- **归档与恢复。** 归档会话不改动该 PR 监视文件中的任何东西:该会话只是离开收件者候选,其 sidecar 保留顶层 `state` 并照常被刷新。从归档恢复的会话重新加入候选。若另有绑定者一直在跟随该 PR,文件是新的,恢复的会话此后收到转换;若无人跟随该 PR,其文件已被删除(§2),该 PR 静默初始化,因此会话归档期间发生的变化不会补投给它——恢复是用户操作,刷新过的顶层 `state` 已经在侧栏显示了这些变化。在最后一个绑定者离开后 7 天内、且扫描一直不完整时的恢复,会从最后一次观察继续,把间隔期内的转换投递一次。
- **机制。** PR A 把 prompt 准入从路由处理函数中抽成一个路由与刷新共用的函数——幂等环、分类与回填跳过随之迁移,回填跳过改按受信分类判断,而不是按请求自带的标记——刷新在进程内调用它,以及恢复路由背后的代码。刷新全程沿用 channel 绑定中继的分类,而不是新增一种授权:它传入 `context.channelPrompt`,bridge 只从受信 context 把它变成 `qwen.channel.prompt`,会话子进程只在受信的私有父进程下认可它,注入文本也不做 PR 绑定回填。该回合带有这一分类今天在同样的会话中对绑定中继所具有的全部效果(`Session.ts`),并接受它们:跳过循环检测拒绝,不记录分支检查点(它不是 rewind 点),关闭重复工具失败防护,不计为用户提交回合,排队或立即发送的用户输入无法打断其模型响应,其 Stop hook 不可中断。以转换 key(启用角色化动作时加组后缀)作为 `idempotencyKey`,因此 daemon 生命周期内对同一会话的重试会去重到原始准入。不设置显示文本:与 channel 今天的绑定中继一样,注入的 prompt 在会话 transcript 中按原样显示,包括不可信数据提示,因此会话的用户看到的正是模型收到的内容(bridge 只为 channel 自有会话保留显示文本)。不新增任何标记,因此剥离清单无需改动;一个路由测试钉住:不带 worker 凭据、携带 `qwen.channel.prompt` 的客户端请求体得不到分类。
@@ -121,64 +121,65 @@ owner 在一次完整扫描(无 `partial`,§4)发现某 PR 没有任何绑
### 6. 配置
daemon 设置中的一个 daemon 级设置,自 PR B 起在启动时读取(§7):`sessionPrWatch: { events: ['ci_failure', 'merge_conflict', 'head_change', 'state_change'], wakeDormant?: boolean, actions?: Record<string, string> }`,环境变量 `QWEN_SESSION_PR_WATCH_EVENTS` 与 `QWEN_SESSION_PR_WATCH_WAKE_DORMANT` 可覆盖(`actions` 只能在 settings 中设置)。默认:无事件(关闭);`wakeDormant` 默认为 true,`false` 让休眠会话的转换留在 `pending` 直到其下一次 live 回合(§4);没有 `actions` 时投递为单一赢家与通用 prompt,词表之外的键或空模板与 channel 一样是校验错误。刷新定时器在 app 创建之前就于 `packages/cli/src/serve/run-qwen-serve.ts` 中启动,因此它经延迟绑定的 getter 访问共用的准入与恢复,与它今天访问归档协调器(`getArchiveCoordinator`)的方式相同。延迟即刷新间隔(默认 5 分钟;小于 1 分钟回退到默认值,`0` 关闭扫描)。在 PR B 开启的弃用窗口内(§7),`sessionPrWatch` 未设置时,GitHub channel 的 `prWatchEvents` 与 `watch:*` 动作被读作别名。
daemon 设置中的一个 daemon 级设置,自 PR B 起在启动时读取(§7):`sessionPrWatch: { events: ['ci_failure', 'merge_conflict', 'head_change', 'state_change'], wakeDormant?: boolean, actions?: Record<string, string> }`,环境变量 `QWEN_SESSION_PR_WATCH_EVENTS` 与 `QWEN_SESSION_PR_WATCH_WAKE_DORMANT` 可覆盖(`actions` 只能在 settings 中设置)。默认:无事件(关闭);`wakeDormant` 默认为 true,`false` 让休眠会话的转换留在 `pending` 直到其下一次 live 回合(§4);没有 `actions` 时投递为单一赢家与通用 prompt,词表之外的键、空模板或超过 2,000 码点的模板与 channel 一样是校验错误。刷新定时器在 app 创建之前就于 `packages/cli/src/serve/run-qwen-serve.ts` 中启动,因此它经延迟绑定的 getter 访问共用的准入与恢复,与它今天访问归档协调器(`getArchiveCoordinator`)的方式相同。当 workspace 的 view 集合——其 open 绑定 PR 加上列表没有返回的 closed PR(§1)——不超过 30 项时,检测延迟名义上为一个刷新间隔(默认 5 分钟;小于 1 分钟回退到默认值,`0` 关闭扫描);集合为 n 项且不变时,名义上为 ⌈n/30⌉ 个间隔。两个数字都不是上界:workspace 顺序每个 tick 轮换(§1),其它 workspace 的列表与 view 先于它运行,一个 PR 在 tick 内被观察的时刻因此会移动;截止时间截断窗口、`running` 守卫跳过一个 tick(§1)、集合变化使窗口移位、view 失败、扫描无法枚举该 workspace 或读不到该 PR 的 sidecar、监视文件读取错误跳过该 PR(§2),或所有权转给另一个 daemon(§2)时,检测还会更晚;保留的收件者无法到达或角色组等待不完整的扫描时(§4),休眠收件者等待 daemon 同时运行的四个恢复名额之一时(每个名额在其恢复结束前一直被占用,因此 k 个这样的收件者中最后一个要再等 ⌈k/4⌉ − 1 次或更多次排空),以及恢复临时失败时,投递都要再等后续的排空;`wakeDormant: false` 时则等到会话的下一次 live 回合。在 PR B 开启的弃用窗口内(§7),`sessionPrWatch` 未设置时,GitHub channel 的 `prWatchEvents` 与 `watch:*` 动作被读作别名。
### 7. channel 迁移
GitHub channel 的 `deliverToBoundSessions` 及其评论投递不变。两个 PR,且不存在两个轮询器同时运行的窗口期。
**PR A** 增加 daemon 的观察与投递及其测试——监视存储及其 owner 记录、排空、共用的准入与恢复、prompt 构造——但置于一个保持关闭的内部开关之后:刷新不运行任何 view,也不投递任何东西,`sessionPrWatch` 设置也尚未被读取。适配器监视保持不变,因此任何构建(包括 nightly)都不会同时运行两个轮询器,两者也无需分工或交接任何事件类型。
**PR A** 增加 daemon 的观察与投递及其测试——监视存储及其 owner 记录、排空、共用的准入与恢复、prompt 构造——但置于一个保持关闭的内部开关之后:刷新不运行任何 view,也不投递任何东西,`sessionPrWatch` 设置也尚未被读取。适配器监视的行为保持不变——唯一的改动是改为导入 PR A 移入 `@qwen-code/channel-base` 的模板代码(§4)——因此任何构建(包括 nightly)都不会同时运行两个轮询器,两者也无需分工或交接任何事件类型。
**PR B** 打开该开关并加入 `sessionPrWatch` 设置(§6),并在同一个改动中移除适配器监视——监视通道、任务存储中的监视分支、30 个 PR 的上限与 CI 复查间隔——开启一个 minor 版本的弃用窗口。B 不再依据监视状态文件做监视:升级后首次连接时读一次该文件,记录其中仍有多少未投递条目,然后给它加上 `.retired` 后缀改名,使这行日志只记一次;并把每个持久化的 `watch:` 入站任务私下退役并记日志,绝不经评论车道的公开回退。`connect()` 继续接受 `prWatchEvents` 与 `boundSessionActions` 的 `watch:*` 键,只记一条点名 `sessionPrWatch` 的告警,不跑监视通道,Web Shell 描述符将其标为已弃用。`sessionPrWatch` 未设置时,daemon 按 host 把它们读作别名:本 daemon 承载的每个 GitHub channel——按 channel 承载已有的信任门控加载,因此不受信 workspace 的设置绝不会参与(`packages/cli/src/serve/channel-workspace-grouping.ts` 拒绝承载其 channel)——为其 web host 上的 PR 提供自己的 `prWatchEvents` 作为事件集合,以及自己的 `watch:*` 动作(`watch:<type>` → `<type>`,`<role>:watch:<type>` → `<role>:<type>`,`default` 与 `<role>:default` 保留)。`boundSessionActions` 非空的 channel 在别名下仍是角色化的,即使它没有任何键适用于 PR 状态事件,因此没有为任何角色订阅监视事件的 channel 仍然收不到它们。在 owner 认领中(§2),别名算作承载该 channel 的 daemon 的生效集合——每个 global qwen dir 只有一个这样的 daemon,即 channel 服务的预留;若另一个带显式集合的 daemon 已持有监视,则以那个集合为准。其它 host 上的 PR 从别名得不到任何东西,显式的 `sessionPrWatch` 优先;同一 host 上的两个 channel 取事件的并集,每种类型的动作——角色化模式与键——取自 `prWatchEvents` 包含该类型、且其配置会投递该类型(没有动作,或有能为它解析出模板的链)的按名字排第一的 channel,并记录冲突;别名生效时记一次日志。别名的 view 使用 daemon 的 `gh`,而不是 channel 的 token,因此 channel 读得到而 daemon 的 `gh` 读不到的 PR 从别名得不到任何东西:daemon 在对它连续两次 view 失败后记一条点名该 PR 与错误类别的告警,每个失败周期一次;别名生效而刷新定时器未运行时,也在启动时告警一次。channel 用自己 token 连接的运维者应在升级前让宿主的 `gh` 能读到这些仓库。因此在 v0.26.7 上开启了监视的运维者跨过 B 所在的版本,对 daemon 的 `gh` 能 view 的每个 PR 仍收到这些转换,只是延迟从 channel 的 60 秒变为刷新间隔;有一个边界:B 之前 daemon 不保存任何监视文件,因此升级后的第一次扫描静默初始化每个绑定 PR(§2),channel 最后一次轮询与这次扫描之间的转换、以及升级落地时 channel 尚未投递的转换——还没到下一次轮询、仍因背压被推迟、或已进死信——都不会投递;B 的那一次日志会统计这类条目。
**PR B** 打开该开关并加入 `sessionPrWatch` 设置(§6),并在同一个改动中移除适配器监视——监视通道、任务存储中的监视分支、30 个 PR 的上限与 CI 复查间隔——开启一个 minor 版本的弃用窗口。B 不再依据监视状态文件做监视:升级后首次连接时读一次该文件,记录其中仍有多少未投递条目,然后给它加上 `.retired` 后缀改名,使这行日志只记一次;并把每个持久化的 `watch:` 入站任务私下退役并记日志,绝不经评论车道的公开回退。`connect()` 继续接受 `prWatchEvents` 与 `boundSessionActions` 的 `watch:*` 键,只记一条点名 `sessionPrWatch` 的告警,不跑监视通道,Web Shell 描述符将其标为已弃用。`sessionPrWatch` 未设置时,daemon 按 host 把它们读作别名:本 daemon 承载的每个 GitHub channel——按 channel 承载已有的信任门控加载,因此不受信 workspace 的设置绝不会参与(`packages/cli/src/serve/channel-workspace-grouping.ts` 拒绝承载其 channel)——为其 web host 上的 PR 提供自己的 `prWatchEvents` 作为事件集合,以及自己的 `watch:*` 动作(`watch:<type>` → `<type>`,`<role>:watch:<type>` → `<role>:<type>`,`default` 与 `<role>:default` 保留)。`boundSessionActions` 非空的 channel 在别名下仍是角色化的,即使它没有任何键适用于 PR 状态事件,因此没有为任何角色订阅监视事件的 channel 仍然收不到它们。在 owner 认领中(§2),别名算作承载该 channel 的 daemon 的生效集合——每个 global qwen dir 只有一个这样的 daemon,即 channel 服务的预留;若另一个带显式集合的 daemon 已持有监视,则以那个集合为准。其它 host 上的 PR 从别名得不到任何东西,显式的 `sessionPrWatch` 优先;同一 host 上的两个 channel 取事件的并集,每种类型的动作——角色化模式与键——取自 `prWatchEvents` 包含该类型、且其配置会投递该类型(没有动作,或有能为它解析出模板的链)的按名字排第一的 channel,并记录冲突;别名生效时记一次日志。别名的 view 使用 daemon 的 `gh`,而不是 channel 的 token,因此 channel 读得到而 daemon 的 `gh` 读不到的 PR 从别名得不到任何东西:daemon 在对它连续两次 view 失败后记一条点名该 PR 与错误类别的告警,每个失败周期一次;别名生效而刷新定时器未运行时,也在启动时告警一次。channel 用自己 token 连接的运维者应在升级前让宿主的 `gh` 能读到这些仓库。因此在 v0.26.7 上开启了监视的运维者跨过 B 所在的版本,在承载该 channel 的 daemon 持有监视(§2)且运行其刷新定时器期间,对 daemon 的 `gh` 能 view 的每个 PR 仍收到这些转换,只是延迟从 channel 的 60 秒变为 §6 所述的延迟;有一个边界:B 之前 daemon 不保存任何监视文件,因此升级后的第一次扫描静默初始化每个绑定 PR(§2),channel 最后一次轮询与这次扫描之间的转换、以及升级落地时 channel 尚未投递的转换——还没到下一次轮询、仍因背压被推迟、或已进死信——都不会投递;B 的那一次日志会统计这类条目。
B 之后的第一个 minor 版本在 `connect()` 以同一条信息拒绝 `prWatchEvents` 与 `watch:*` 键,并去掉别名。该设置已随 v0.26.7 发布,已发布且写进发布说明的选项不能见到就拒绝,即便它需在 `deliverToBoundSessions` 之后可选开启、实际采用量大概率接近于零;`watch:*` 键(#16258)目前只在 nightly 构建中发布,遵循同一个窗口。B 同时更新 channel 文档,在 A 之后合入。
B 之后的第一个 minor 版本在 `connect()` 以同一条信息拒绝 `prWatchEvents` 与 `watch:*` 键,并去掉别名。该设置已随 v0.26.7 发布,已发布且写进发布说明的选项不能见到就拒绝,即便它需在 `deliverToBoundSessions` 之后可选开启、实际采用量大概率接近于零;`watch:*` 键(#16258)已随 v0.26.8 发布,出于同样的原因遵循同一个窗口。B 同时更新 channel 文档,在 A 之后合入。
## 关键决策
1. **daemon 拥有 PR 状态。** 它已经为侧栏用宿主凭据轮询这些状态;多加三个字段和一个投递步骤比第二个轮询器便宜,而且服务所有绑定会话,而不只是配置了 channel 的那些。
2. **按 URL 的逐 PR `gh pr view`,而非批量 rollup。** 刷新的 slim 批量列表之所以存在,是因为 rollup 字段在大的 `--state all` 查询上超时;单个 PR 的 view 避开了这一点,按 URL 执行使指向别的仓库的绑定也能抓对,且受 open 绑定 PR 数量约束(每次扫描上限 30,旋转,受一个 tick 截止时间约束)。
2. **按 URL 的逐 PR `gh pr view`,而非批量 rollup。** 刷新的 slim 批量列表之所以存在,是因为 rollup 字段在大的 `--state all` 查询上超时;单个 PR 的 view 避开了这一点,按 URL 执行使指向别的仓库的绑定也能抓对,且受 view 集合——open 绑定 PR 加上列表没有返回的 closed PR(§1)——约束:每次扫描上限 30,旋转,受一个 tick 截止时间约束。
3. **与 channel 监视相同的选择与转换规则**,包括角色化动作,迁移过来的运维者看到的事件相同,只有一处刻意的 CI 判定差异(决策 5、开放问题 2)。
4. **每个 PR 一个监视文件,既是快照也是队列。** PR 状态按 PR 存在,因此基线、待投递转换与已投递记录在一个由 daemon 持有的小文件中每个 PR 只存一份,而不是存在每个绑定者的 sidecar 中。没有副本会落后,因此设计不需要任何从陈旧副本采用、合并或重新检测的规则,sidecar 也只保留侧栏读取的内容。
5. **单一 CI 定义**(core 的 rollup),在下面的开放问题定案前接受 `cancelled` 与 `stale` 算作失败。
6. **带别名的一个版本弃用窗口**,针对 `prWatchEvents` 与 `watch:*` 动作:v0.26.7 已发布该设置(#16117,2026-11-04 发布)并在发布说明中点名,因此不能见到就拒绝;采用量大概率接近于零(只有几小时、需在 `deliverToBoundSessions` 之后可选开启),所以一个 minor 版本内旧字段告警并被读作别名——对 daemon 的 `gh` 能 view 的 PR,已配置的运维者在第一次扫描之后不会断事件,不能 view 的 PR 由 daemon 告警——就足够,之后再由 `connect()` 拒绝。
6. **带别名的一个版本弃用窗口**,针对 `prWatchEvents` 与 `watch:*` 动作:v0.26.7 已发布该设置(#16117,2026-11-04 发布),v0.26.8 已发布 `watch:*` 动作(#16258,2026-11-05 发布),两者都在发布说明中点名,因此都不能见到就拒绝;采用量大概率接近于零(只有几天、需在 `deliverToBoundSessions` 之后可选开启),所以一个 minor 版本内旧字段告警并被读作别名——在承载 channel 的 daemon 持有监视且运行其刷新定时器期间,对 daemon 的 `gh` 能 view 的 PR,已配置的运维者在第一次扫描之后不会断事件,不能 view 的 PR 由 daemon 告警——就足够,之后再由 `connect()` 拒绝。
7. **本次改动默认关闭**;默认开启是之后的产品决策。
## 涉及文件
| 层 | 文件 | 改动 |
| --------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| core | `packages/core/src/utils/github-prs.ts` | 带 `url`、`title`、`headRefOid`、`mergeable`、rollup 的 `fetchGitHubPullRequestView(url)`;映射;导出 `aggregateChecks` |
| daemon | `packages/cli/src/serve/server/pr-watch-store.ts`(新增) | 监视目录及其每 PR 文件:带校验的读取(无效即视为不存在)、在每 PR 锁下的原子写入、由 owner 删除;owner 记录及其每个 tick 的认领 |
| daemon | `packages/cli/src/commands/channel/pidfile.ts`、`packages/cli/src/serve/serve-app-lifecycle.ts` | 带身份校验的记录与独占创建推广为由调用方指定路径;关闭时先停止 PR 监视再删除 owner 记录 |
| daemon | `packages/cli/src/serve/server/session-pr-refresh.ts` | 在扫描所得绑定上按 URL 的逐 PR 观察(上限 30、按 GitHub 偏移旋转、tick 级截止时间、workspace 顺序轮转、允许的 host、workspace 环境、daemon 私有工作目录、列表之外的 closed PR、不受信 primary 只做 view)、对照监视文件的转换检测、文件删除、带在飞 key 的排空、经共用准入与恢复投递并释放注册且恢复不在 tick 上运行、角色化动作、view 失败告警、内部开关(PR A 中关闭);别名(PR B) |
| daemon | `packages/cli/src/serve/routes/session-pr-bindings.ts` | 供刷新使用的进程内扫描入口 |
| daemon | `packages/cli/src/serve/routes/session.ts` | 把 prompt 准入(幂等环、分类、按受信分类判断的回填跳过)与 `restoreSessionHandler` 背后的恢复(带供排空用的恢复钩子)抽成路由与刷新共用的函数;取消归档、恢复与重绑路径清除收件者排除;bridge 与会话子进程本来就从受信 context 重新装配 `qwen.channel.prompt`,无需改动 |
| daemon | `packages/cli/src/serve/run-qwen-serve.ts` | 共用准入与恢复的延迟绑定 getter,传给定时器 |
| 配置(PR B) | settings schema + `packages/cli/src/serve/server.ts` | `sessionPrWatch`(`events`、`wakeDormant`、`actions`)/ `QWEN_SESSION_PR_WATCH_EVENTS`、`QWEN_SESSION_PR_WATCH_WAKE_DORMANT` |
| channel(PR B) | `packages/channels/github/src/GithubAdapter.ts`、`index.ts`、Web Shell 描述符 | 移除监视通道;读一次监视状态文件统计未投递条目后将其退役;私下退役持久化的 `watch:` 任务;弃用 `prWatchEvents` 与 `watch:*` 键(告警、忽略、别名) |
| 文档 | `docs/users/features/channels/github.md`、新的 `sessionPrWatch` 用户页、两份设计文档 | 记录这次迁移,包括角色化动作 |
| 测试 | 每个改动文件并置的 `.test.ts` | 见验证 |
| 层 | 文件 | 改动 |
| ------------------------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| core | `packages/core/src/utils/github-prs.ts` | 带 `url`、`title`、`headRefOid`、`mergeable`、rollup 的 `fetchGitHubPullRequestView(url)`;映射;导出 `aggregateChecks` |
| daemon | `packages/cli/src/serve/server/pr-watch-store.ts`(新增) | 监视目录及其每 PR 文件:带校验的读取(无效即视为不存在)、临时文件加改名的写入与由 owner 删除,它们自身不取锁,因为每个调用方在读与写之间都持有每 PR 锁;owner 记录及其每个 tick 的认领,另一个 uid 拥有该目录或其中某个监视文件时拒绝认领 |
| daemon | `packages/cli/src/commands/channel/pidfile.ts`、`packages/cli/src/serve/serve-app-lifecycle.ts` | 带身份校验的记录与独占创建推广为由调用方指定路径;关闭时先停止 PR 监视再删除 owner 记录 |
| daemon | `packages/cli/src/serve/server/session-pr-refresh.ts` | 在扫描所得绑定上按 URL 的逐 PR 观察(上限 30、按 GitHub 偏移旋转、tick 级截止时间、workspace 顺序轮转、允许的 host、workspace 环境、daemon 私有工作目录、列表之外的 closed PR、不受信 primary 只做 view)、对照监视文件的转换检测、文件删除、带在飞 key 的排空、经共用准入与恢复投递并释放注册且恢复不在 tick 上运行、角色化动作、view 失败告警、内部开关(PR A 中关闭);别名(PR B) |
| daemon | `packages/cli/src/serve/routes/session-pr-bindings.ts` | 供刷新使用的进程内扫描入口 |
| daemon | `packages/cli/src/serve/routes/session.ts` | 把 prompt 准入(幂等环、分类、按受信分类判断的回填跳过)与 `restoreSessionHandler` 背后的恢复(带供排空用的恢复钩子)抽成路由与刷新共用的函数;取消归档、恢复与重绑路径清除收件者排除;bridge 与会话子进程本来就从受信 context 重新装配 `qwen.channel.prompt`,无需改动 |
| daemon | `packages/cli/src/serve/run-qwen-serve.ts` | 共用准入与恢复的延迟绑定 getter,传给定时器 |
| 配置(PR B) | settings schema + `packages/cli/src/serve/server.ts` | `sessionPrWatch`(`events`、`wakeDormant`、`actions`)/ `QWEN_SESSION_PR_WATCH_EVENTS`、`QWEN_SESSION_PR_WATCH_WAKE_DORMANT` |
| channel-base 与 channel(PR A) | `packages/channels/base/src`(新模块)、`packages/channels/github/src/GithubAdapter.ts` | 绑定动作模板契约(以事件词表为参数的键与模板校验、链解析、按变量值渲染)从适配器移出,适配器以不变的行为重新导入,并与 daemon 共用 |
| channel(PR B) | `packages/channels/github/src/GithubAdapter.ts`、`index.ts`、Web Shell 描述符 | 移除监视通道;读一次监视状态文件统计未投递条目后将其退役;私下退役持久化的 `watch:` 任务;弃用 `prWatchEvents` 与 `watch:*` 键(告警、忽略、别名) |
| 文档 | `docs/users/features/channels/github.md`、新的 `sessionPrWatch` 用户页、两份设计文档 | 记录这次迁移,包括角色化动作 |
| 测试 | 每个改动文件并置的 `.test.ts` | 见验证 |
## 约束与风险
- **延迟**为刷新间隔(默认 5 分钟),而非 channel 的 60 秒轮询。需要更快信号的运维者调低 `QWEN_SESSION_PR_REFRESH_MINUTES`(小于 1 回退到默认值;`0` 关闭扫描),代价是 view 次数:间隔每减半 view 量翻倍,在 1 分钟的下限一个 workspace 每小时最多 1,800 次 `gh pr view`——即 channel 监视在上限时的数字——五个 workspace 则是 9,000 次。tick 截止时间(§1)随间隔缩短(1 分钟时为 30 秒),因此新增的 view 至多让 tick 越过它一次 view 超时,恢复也不在 tick 上运行(§4);刷新已有的列表调用不变。
- **延迟**名义上为一个刷新间隔(默认 5 分钟),而非 channel 的 60 秒轮询;view 集合为 n 项时名义上为 ⌈n/30⌉ 个间隔。两个数字都不是上界:§6 列出使它更长的情形。需要更快信号的运维者调低 `QWEN_SESSION_PR_REFRESH_MINUTES`(小于 1 回退到默认值;`0` 关闭扫描),代价是 view 次数:间隔每减半 view 量翻倍,在 1 分钟的下限一个 workspace 每小时最多 1,800 次 `gh pr view`——即 channel 监视在上限时的数字——五个 workspace 则是 9,000 次。tick 截止时间(§1)随间隔缩短(1 分钟时为 30 秒),因此新增的 view 至多让 tick 越过它一次 view 超时,恢复也不在 tick 上运行(§4);刷新已有的列表调用不变。
- **凭据。** 需要 daemon 宿主上已认证的 `gh`,这是刷新与 PR 侧栏已有的要求。没有它的 workspace 保持今天的行为(不刷新状态,也无事件);自 PR B 起,daemon 的 `gh` 读不了的 PR 收不到 PR 状态事件,并会告警(§7)。
- **状态文件。** 最近 7 天内被绑定过的每个 PR 一个小 JSON 文件,位于每个 global qwen dir 由一个 daemon 持有的私有目录中(§2);sidecar 不变。未持有 owner 记录的 daemon 不投递 PR 状态事件,本机无法核验的记录在被手动删除之前会阻止监视,与 channel 服务相同(§2)。
- **状态文件。** 最近 7 天内被绑定过的每个 PR 一个小 JSON 文件,位于每个 global qwen dir 由一个 daemon 持有的私有目录中(§2);sidecar 不变。未持有 owner 记录的 daemon 不投递 PR 状态事件,本机无法核验的记录在被手动删除之前会阻止监视,与 channel 服务相同;以监视目录或其中某个监视文件的所有者之外的用户运行的 daemon 从不认领它(§2)。
- **API 预算。** 每 workspace 每次扫描最多 30 次 `gh pr view`:W 个被扫描的 workspace 在默认间隔下每小时 360·W 次(W = 5 时 1,800 次,即 channel 监视单个 channel 在上限时的数字),叠加在现有列表之上,受半个刷新间隔的 tick 级 view 截止时间约束(§1)。view 每个 tick 按 PR 跨 workspace 去重(§1),绑定在多个 workspace 的 PR 每个 tick 只抓取一次。
- **不可信内容进入交互式会话。** 事件 prompt 把 PR 标题与 check 名称作为数据携带、标记为不可信并经 `sanitizePromptText` 处理、受 channel 的 6,000 码点上限约束(§4),与 channel 绑定 prompt 的契约相同;会话自身的审批模式决定其行为。
- **不可信内容进入交互式会话。** 事件 prompt 把 PR 标题与 check 名称作为数据携带、标记为不可信并经 `sanitizePromptText` 处理、受 channel 的 6,000 码点上限约束(§4),与 channel 绑定 prompt 的契约相同;会话自身的审批模式决定其行为。不受信 primary 中的绑定以 daemon 自己的凭据 view(§1)——设置 `sessionPrWatch` 时在每个允许的 host 上,别名下在 channel 的 web host 上(§7)——因此它指向的、该凭据能读的任意仓库中的 PR,其标题与 check 名称会作为不可信数据进入该会话。这比 channel 监视今天的范围更宽:后者只在自己的 web host 上、以运维者交给 channel 的 token 读取这类绑定(`GithubAdapter.ts` 中的 `parsePrBindingUrl`),而在别名下,这一变化随升级到来,而不是由运维者选择开启。本设计接受这一点;不希望如此的运维者不设置 `sessionPrWatch`,并删除已弃用的 `prWatchEvents`,从而结束别名。
- **准入分类。** 非交互式 prompt 的 daemon 来源回合必须对客户端不可伪造。本设计不新增授权,也不新增标记:它沿用 channel prompt 的受信链——路由的 worker 凭据、bridge 的受信 context、子进程的受信父进程检查(§4)。
## 验证计划
- 单元:view 映射(`url`、`title`、`headRefOid`、`mergeable` 状态、rollup)与身份检查(返回其它 host 或编号的 view 被丢弃;带 `www.` 或 `/files` 写法的绑定能匹配;改名或转移后的仓库的 view 被采用,并继续更新该绑定自己的文件);允许的 host(其它 host 上的绑定、或 `origin` 不是 GitHub 的 workspace,不引起任何 `gh` 调用;`www.github.com` 与 `github.com` 视为相同;未配置 channel 时 `github.com` 也被允许);view 环境(workspace 自己的 `GH_TOKEN` 作用于它的 PR)与工作目录(daemon 私有的监视目录);closed PR(列表没有返回的 closed PR 被 view,其重新打开被检测到);转换规则,包括 force-push 回访下每个 head 一次的 `ci_failure`(`A failing → B failing → A failing` 触发两次而非三次)、逐 head 重置(新 head 首次观察即失败会触发;首次观察时已失败的 PR 在那次及下一次扫描都不触发)、边沿触发的冲突、在 `UNKNOWN` view 之后首次出现的 `conflicting` 会触发、`CONFLICTING` 之后的 `UNKNOWN` 保留已存值且不产生转换、只有 slim 字段的文件在重新打开后的第一次 view 静默填入其它字段、重开 → 关闭;监视文件(原子写入;开始时间早于文件 `observedAt` 的观察被丢弃;无效文件视为不存在且不动任何 sidecar;其它读取错误让该 PR 本 tick 跳过,文件及其 pending 条目原样保留;完整扫描无绑定者时与 `lastBoundAt` 满 7 天后删除(该时钟跨重启保留),未投递条目丢弃并记日志;删除后静默初始化;删除前重绑从最后一次观察继续);多个绑定者之间的选择,含分属不同 workspace 与不受信 primary 的绑定者;检测时绑定者所在 workspace 不可达、不受信或已退役,之后又回来(它成为候选,不会重新检测任何东西);角色化动作(每组一个赢家、链解析、无匹配或无合格绑定者跳过该组、加组后缀的 key、已服务的组在下一组被服务前已记录、每个匹配组都已服务后才移出条目);排空(两次并发排空只准入一次,包括在另一方准入并记录该 key 之前就已读取 `pending` 的一方;遇到在飞 key 的排空保留条目;休眠收件者的准入在恢复后重新读取文件;记录写入失败时 key 在内存中保持已准入并重试写入,绝不再次准入;重启时仍在排队的已准入 prompt 不会被重新注入;被拒绝的准入会重试;`delivered` 中的 key 绝不再次准入;超过 7 天的条目被丢弃);崩溃窗口(写入前、写入后、准入后与记录写入前——最后一种让该 key 重复一次);检测与排空之间收件者被归档或被超过;归档与恢复(另有绑定者一直跟随时恢复的会话收到此后的转换;文件删除后的恢复静默初始化);配置解析(`events`、`wakeDormant`、`actions` 词表);超长 key 的哈希,含组后缀;共用准入函数(不带 worker 凭据、携带 `qwen.channel.prompt` 的客户端请求体得不到分类)与共用恢复(被唤醒收件者的客户端注册在其 prompt 结束后被释放);`wakeDormant: false` 把条目留到下一次 live 回合;恢复不在 tick 上运行(挂住的恢复不改变 tick 长度,其 key 保持在飞;同时最多四个);恢复失败(永久性失败——404,或 409 `session_archived`、`worktree_session_superseded`、`worktree_marker_missing`、`worktree_reset_interrupted`——排除该会话并把转换交给其组内下一个绑定者;临时性失败——超时、503、429、`restore_in_progress`、`session_busy`、`session_writer_conflict`——保留条目给同一收件者;恢复期间被归档、之后取消归档的会话经取消归档路径重新成为候选,或在一小时后);无法枚举最近活跃绑定者的不完整扫描保留其行作为赢家、条目留在 pending;不完整扫描中的否定性决定(不删除文件、不跳过任何组、不因扫描看不到的绑定者而丢弃转换);owner 记录(独占创建;本次启动中已死 owner 的记录被清扫,下一个已配置的 daemon 在其下一个 tick 认领;重启机器之前的记录在被删除前阻止认领并记日志;事件集合为空的 daemon 从不认领;非 owner 不 view、不排空、不删除;owner 关闭时先停止 PR 监视再删除记录);扫描不完整时的删除(被保留行提到的 PR 其 `lastBoundAt` 保持新鲜,绝不会被删除);别名来源(不受信 workspace 设置中的 channel 记录既不提供事件、动作,也不提供允许的 host);未设置该项时零次 `gh pr view` 且列表调用次数不变;prompt 构造(摘要含换行与行首 `[PR event]:` 时得到单行摘要、没有标签打头的行;超长正文在上限处截断并带可见标记);跨 workspacLine truncated
- 单元:view 映射(`url`、`title`、`headRefOid`、`mergeable` 状态、rollup)与身份检查(返回其它 host 或编号的 view 被丢弃;带 `www.` 或 `/files` 写法的绑定能匹配;改名或转移后的仓库的 view 被采用,并继续更新该绑定自己的文件);允许的 host(其它 host 上的绑定、或 `origin` 不是 GitHub 的 workspace,不引起任何 `gh` 调用;`www.github.com` 与 `github.com` 视为相同;未配置 channel 时 `github.com` 也被允许);view 环境(workspace 自己的 `GH_TOKEN` 作用于它的 PR)与工作目录(daemon 私有的监视目录);closed PR(列表没有返回的 closed PR 被 view,其重新打开被检测到);转换规则,包括 force-push 回访下每个 head 一次的 `ci_failure`(`A failing → B failing → A failing` 触发两次而非三次)、逐 head 重置(新 head 首次观察即失败会触发;首次观察时已失败的 PR 在那次及下一次扫描都不触发)、边沿触发的冲突、在 `UNKNOWN` view 之后首次出现的 `conflicting` 会触发、`CONFLICTING` 之后的 `UNKNOWN` 保留已存值且不产生转换、只有 slim 字段的文件在重新打开后的第一次 view 静默填入其它字段、重开 → 关闭;监视文件(原子写入;开始时间早于文件 `observedAt` 的观察被丢弃;无效文件视为不存在且不动任何 sidecar;其它读取错误让该 PR 本 tick 跳过,文件及其 pending 条目原样保留;完整扫描无绑定者时与 `lastBoundAt` 满 7 天后删除(该时钟跨重启保留),未投递条目丢弃并记日志;删除后静默初始化;删除前重绑从最后一次观察继续);多个绑定者之间的选择,含分属不同 workspace 与不受信 primary 的绑定者;检测时绑定者所在 workspace 不可达、不受信或已退役,之后又回来(它成为候选,不会重新检测任何东西);角色化动作(每组一个赢家、链解析、无匹配或无合格绑定者跳过该组、加组后缀的 key、已服务的组在下一组被服务前已记录、每个匹配组都已服务后才移出条目);daemon 词表下的共用模板契约(接受 `fix:ci_failure`,拒绝 `mention` 与 `watch:ci_failure`,拒绝超过 2,000 码点的模板)与渲染值(`%repo%` 与 `%url%` 保留赢家绑定 URL 的大小写与端口,`%sender%` 渲染为 `GitHub`,`%event%` 为裸类型,别名下的 channel 模板亦然);适配器中移出代码的行为一致性(评论 lane 回退、`%event%` 渲染为 `watch:<type>`、`%url%` 取自 channel 的 web origin);排空(两次并发排空只准入一次,包括在另一方准入并记录该 key 之前就已读取 `pending` 的一方;与排空的记录写入竞争的检测、slim 列表 `state` 或 `lastBoundAt` 写入既不会恢复已移除的 `pending` 条目,也不会丢掉 `delivered` 中的 key;遇到在飞 key 的排空保留条目;休眠收件者的准入在恢复后重新读取文件;记录写入失败时 key 在内存中保持已准入并重试写入,绝不再次准入;重启时仍在排队的已准入 prompt 不会被重新注入;被拒绝的准入会重试;`delivered` 中的 key 绝不再次准入;超过 7 天的条目被丢弃;`pending` 的 16 条上限丢弃最旧的条目并记日志;`ciFired` 保留最新的 16 个 head、淘汰最旧的);崩溃窗口(写入前、写入后、准入后与记录写入前——最后一种让该 key 重复一次);检测与排空之间收件者被归档或被超过;归档与恢复(另有绑定者一直跟随时恢复的会话收到此后的转换;文件删除后的恢复静默初始化);配置解析(`events`、`wakeDormant`、`actions` 词表);超长 key 的哈希,含组后缀;共用准入函数(不带 worker 凭据、携带 `qwen.channel.prompt` 的客户端请求体得不到分类)与共用恢复(被唤醒收件者的客户端注册在其 prompt 结束后被释放);`wakeDormant: false` 把条目留到下一次 live 回合;恢复不在 tick 上运行(挂住的恢复不改变 tick 长度,其 key 保持在飞;同时最多四个);恢复失败(永久性失败——404,或 409 `session_archived`、`worktree_session_superseded`、`worktree_marker_missing`、`worktree_reset_interrupted`——排除该会话并把转换交给其组内下一个绑定者;临时性失败——超时、503、429、`restore_in_progress`、`session_busy`、`session_writer_conflict`——保留条目给同一收件者;恢复期间被归档、之后取消归档的会话经取消归档路径重新成为候选,或在一小时后);无法枚举最近活跃绑定者的不完整扫描保留其行作为赢家、条目留在 pending;不完整扫描中的否定性决定(不删除文件、不跳过任何组、不因扫描看不到的绑定者而丢弃转换);owner 记录(独占创建;本次启动中已死 owner 的Line truncated
- 刷新测试(现有带假 `fetchPullRequests` 的测试框架):每个转换投递一次、休眠会话被恢复、归档与 channel 自有会话被跳过、上限与旋转(对 40 个 PR 的两次连续扫描观察到全部 40 个,第二个窗口回绕到集合头部)、tick 级截止时间(五个 workspace 的 view 都挂住时,半个间隔之后不再发起 view、下一个 tick 不被跳过、workspace 顺序轮转)、按 URL view 外来绑定、派发前的信任/退役重新解析、不受信 primary 只做 view,以及开关(像 PR A 发布时那样关闭时,即使所有输入都具备,也不运行 view、不投递任何东西);适配器测试中的验收标准 3(一条告警、不跑监视通道、channel 零次 PR 状态 API 调用、持久化的 `watch:` 任务被私下退役、状态文件只读一次并改名、经别名投递)。
- E2E(daemon + 桩 `gh` 二进制):在会话中绑定 PR,把桩的 rollup 翻成失败,下一次扫描后在该会话观察到恰好一条注入 prompt,daemon 重启后没有新的注入。
## 验收标准
1. 设置 `sessionPrWatch.events` 后,绑定会话对 CI 失败、合并冲突、head 变更、合并/关闭的每个转换实例收到一条 prompt——设置 `actions` 时每个匹配的角色组一条——重复触发语义与 channel 监视相同,且无需配置任何 GitHub channel;本设计另有说明之处除外:首次观察与监视文件删除前的重绑(§2)、16 条上限与 7 天年龄上限(§5)、没有剩余候选(§4)、daemon 的 `gh` 读不了的 PR(§7)、同时以旧仓库名与新仓库名被绑定的 PR(按每个名字各投递一次,§1)、另一个进程正在写入的会话——CLI 会话,或未开启会话写锁的另一个 daemon(§4)、只由未持有 owner 记录的 daemon 服务的会话、本机无法核验的记录阻止认领期间,以及 PR 监视不支持的、global qwen dir 不同却共用一个运行时根目录的多个 daemon(§2)、daemon 重启时仍在排队的已准入 prompt(§5),以及标准 2 的重启余量。
1. 设置 `sessionPrWatch.events` 后,绑定会话对 CI 失败、合并冲突、head 变更、合并/关闭的每个转换实例收到一条 prompt——设置 `actions` 时每个匹配的角色组一条——重复触发语义与 channel 监视相同,且无需配置任何 GitHub channel;本设计另有说明之处除外:首次观察与监视文件删除前的重绑(§2)、16 条上限与 7 天年龄上限(§5)、没有剩余候选(§4)、daemon 的 `gh` 读不了的 PR(§7)、同时以旧仓库名与新仓库名被绑定的 PR(按每个名字各投递一次,§1)、另一个进程正在写入的会话——CLI 会话,或未开启会话写锁的另一个 daemon(§4)、只由未持有 owner 记录的 daemon 服务的会话、本机无法核验的记录或属于其他用户的监视目录或监视文件阻止认领期间,以及 PR 监视不支持的、global qwen dir 不同却共用一个运行时根目录的多个 daemon(§2)、daemon 重启时仍在排队的已准入 prompt(§5),以及标准 2 的重启余量。
2. 不重启时,一个转换至多被检测一次、其每个 key 至多准入一次,无论绑定者的会话与 workspace 在此期间发生什么——归档、被超过、读不了、不受信、已退役——两次排空并发运行时亦然。跨重启时一个转换至多投递两次——只有准入与记录它的写入之间的崩溃会让它重复——且至少投递一次,除非该 PR 的监视文件丢失或不再通过校验(§2),或其 prompt 已被准入、在 daemon 重启时仍排在运行中回合之后(§5)。
3. GitHub channel 不再发出任何 PR 状态 API 调用;带 `prWatchEvents` 或 `watch:*` 动作的配置仍能连接,只记一条点名 `sessionPrWatch` 的弃用告警、不跑监视通道,且在 `sessionPrWatch` 未设置时,对承载该 channel 的 daemon 的 `gh` 能 view 的每个 PR 从升级后的第一次扫描起经该 daemon 收到每个转换,按其 `watch:*` 动作路由,不能 view 的 PR 会告警——除非另一个带显式 `sessionPrWatch` 的 daemon 持有监视,此时以其集合为准(§2、§7);PR B 之后的第一个 minor 版本起在 `connect()` 以同一条信息拒绝。
3. GitHub channel 不再发出任何 PR 状态 API 调用;带 `prWatchEvents` 或 `watch:*` 动作的配置仍能连接,只记一条点名 `sessionPrWatch` 的弃用告警、不跑监视通道,且在 `sessionPrWatch` 未设置时,对承载该 channel 的 daemon 的 `gh` 能 view 的每个 PR 从升级后的第一次扫描起经该 daemon 收到每个转换,按其 `watch:*` 动作路由,不能 view 的 PR 会告警——除非另一个带显式 `sessionPrWatch` 的 daemon 持有监视(此时以其集合为准),或承载该 channel 的 daemon 因其他原因未持有监视(§2),或未运行刷新定时器——对此它在启动时告警(§7);PR B 之后的第一个 minor 版本起在 `connect()` 以同一条信息拒绝。
4. 未设置 `sessionPrWatch`、也没有生效的别名时,刷新的行为与 API 调用量不变。
5. 同一仓库的两个受信 workspace 各有一个会话绑定同一个 PR 时,每个转换只投递一次,给其中最近活跃的会话,收件者在检测与排空之间被归档或被超过、或任一 workspace 一段时间不可达时亦然;force-push 回到一个 CI 失败已触发过的 head 时,只要该 head 仍在该 PR 的 16 个 head 历史内,`ci_failure` 不再触发。
6. `wakeDormant: false` 时,休眠会话的转换保留在 `pending` 并在其下一次 live 回合投递;任何构建都不会同时运行两个轮询器:PR B 之前由适配器投递所有 PR 状态事件、daemon 一个也不投递,自 PR B 起全部由 daemon 投递。