mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(channels): deliver GitHub events to PR-bound sessions (#16117)
* feat(channels): deliver GitHub events to PR-bound sessions A session that follows a PR (it created, reviewed, or verified the PR and carries the PR binding) never saw GitHub channel events: the channel routed everything to its own chat_thread sessions, and pure CI failures or merge conflicts produced no notification at all. This connects the two systems. When deliverToBoundSessions is enabled, a new read-only process-global route (GET /sessions/pr-bindings) reverse-looks-up the sessions bound to a PR, and the GitHub adapter injects the event into the most recently updated bound session as a prompt instead of a channel-owned session. The injected prompt marks GitHub content as untrusted, keeps the reply inside the session (no auto-publication), and runs the same group/sender/pairing preflight as the channel path. Dormant bound sessions are woken through the bridge's resume path; only a truly deleted session falls back to the normal channel path. Queue-full is treated as backpressure and does not consume the retry budget. prWatchEvents adds edge-triggered state watching for bound PRs inside the existing poll cycle (ci_failure, merge_conflict, head_change, state_change), with per-head dedupe, a bounded grace window for late-registering checks, partial-snapshot-safe pruning, and state persisted before dispatch so restarts never re-fire a transition. Both fields default off; existing deployments are unchanged. Design: docs/design/2026-11-02-github-channel-bound-session-events.md (EN) and .zh-CN.md (ZH). Verified with 2039 unit tests across channels/base, channels/github and the cli worker/route suites, plus isolated-daemon E2E against a mock GitHub API (bound delivery, dormant wake, watch transitions, default-off regression). * fix(channels): harden bound-session delivery against incomplete scans and crash windows Address the six review findings on the bound-session event delivery, each with a regression test: - Partial binding snapshots no longer replace the routing map or the accessor's wake cache: known bindings and cwds absent from an incomplete answer are retained, so a workspace listing failure can never switch a private delivery to the public channel path or turn a queued dormant delivery into a gone-session fallback. - A binding recorded merged/closed by the independent refresh stays watched until the poll itself observes the terminal transition, so state_change is never skipped by the refresh winning the race. - An empty combined commit status (total_count 0, which GitHub reports as state "pending" for SHAs without legacy statuses) no longer counts as active CI; Actions-only and no-CI heads settle instead of re-fetching checks on every poll. - Watch events now persist a per-head fired ring, so a flapping aggregate (failure -> success -> failure on the same head) delivers ci_failure exactly once, across restarts. - The bindings route flags live-merge degraded successes (liveMergeFailed) as partial, alongside truncation and scan-cap exhaustion. - Detected watch events are queued durably (pending) in the same write as the baseline, then claimed, marked fired, and delivered; a failed claim leaves the event queued, and a crash at any point leaves either a pending event or a claimed task for recovery. The watch pass runs before generic inbound recovery so recovery cannot remove a watch task the watch pass would then re-claim. Design docs (EN/ZH) updated to match. Tests: channels/github 258, cli worker+route 139, channels/base 1652 — all green. * fix(channels): close bound-delivery lifecycle and snapshot-authority gaps A multi-round undirected + adversarial audit of the bound-session delivery feature (61 confirmed findings, probe-verified) plus a follow-up review round and the triage escalation surfaced gaps around the very mechanisms the first hardening commit introduced. This closes them in one pass: Delivery lifecycle: watch dedupe keys now identify transition instances (episode counter) so dirty->clean->dirty, close->reopen->close and SHA revisits fire again while ci_failure stays once-per-head; pending events reconcile before generic recovery and independently of the API pass; terminal task records are replaced instead of silently swallowing recurring keys; acceptance is persisted before task removal so a crash cannot replay an injection; crash loops charge one attempt per recovery pickup; queue-full and the new BoundSessionUnavailableError defer within a bounded horizon and retire to a private dead-letter log instead of freezing the shared notification cursor or vanishing silently; watch events are private-only (maintainer decision: no public fallback that would publish operator-facing prompt text). Snapshot authority: the bindings route enumerates workspace entries and flags non-active trusted workspaces and multi-page scans partial; retention is session-granular and recency-competing; the comment lane defers until the first complete snapshot; cold-cache wake treats partial absence as transient; www host aliases normalize; malformed 200 bodies fail closed. Watch detection: periodic settled-head re-check (GitHub does not bump PR updated_at on check activity), ci_failure subscription gate, startup_failure conclusion, fail-closed state reads, array-shape guard, tmp cleanup on failed writes, rate-limit fast-skip so the watch cannot pin the notification lane. Worker hygiene: channel-turn _meta classification with worker authorization, wake registration detach after the retried prompt settles, response-body cancellation and body-read deadlines; the restore route distinguishes permanent workspace absence (workspace_not_found 404) from reversible draining states. Docs: both design languages record the new decisions (private-only watch, bounded backpressure + dead-letter) and corrected semantics; user doc cost claims qualified. ~90 new regression tests pin each fix witness. * fix(channels): repair bound-delivery build, error taxonomy, and watch dedupe A second undirected + adversarial audit of the bound-delivery fix commit found a broken build and 33 further confirmed findings. This closes them all in one pass: - build: transitionInboundTask's Pick omitted deliveredAt/deferCount/ backpressure, failing the github package's tsc (vitest masked it). - cold start: gate the comment lane on the first successful snapshot (boundSnapshotSeen), not complete-only, so a permanent partial no longer stalls the lane forever. - private-only watch: route watch envelopes by identity so a delivery-cleared watch task can never leak to the public path; retire watch events symmetrically on unbind; restrict terminal-task replacement to watch keys so a cancelled comment is never revived after a crash. - error taxonomy: superseded/archived sessions are gone, the worktree-reset barrier and cold-partial absence are backpressure, on both the direct prompt and wake paths — permanent states fall back, reversible states defer instead of burning the retry budget. - cross-workspace: the worker token is a process-level relay credential admitted for any served (primary/trusted) workspace, restoring channel-turn classification for cross-workspace bound delivery. - snapshot authority: flag an untrusted primary as partial; log per-workspace scan failures. - watch robustness: fail fast on secondary rate limits; pin ci_failure once-per-head against fired-ring eviction (ciFiredSha); roll back a claim whose pending->fired save fails; reconcile pending events independently of the binding fetch; surface watch-cap overflow. - dead-letter: surface the payload and stop claiming success on write failure. - restore contract: migrate unknown-cwd tests to 404 workspace_not_found and pin reversible states (draining/transitioning/blocked) out of it. Adds 38 regression and test-gap tests; each code fix mutation-verified red without its test. Both packages type-check; design docs (EN/zh-CN) and the user doc synced. * fix(channels): harden bound-delivery snapshot authority, recovery ordering, and watch fail-fast Round-3 undirected + adversarial audit found further defects; all high-confidence ones are closed here. - snapshot authority: a bound-session Gone evicts the dead session from the binding maps before the refresh, so a permanent partial snapshot cannot retain it by recency and shadow a live follower; a Gone from the re-delivery hop falls through to the public/retire contract instead of consuming the failure budget; a binding refresh that has never succeeded logs a distinct, throttled, actionable stall message (the gate stays fail-closed). - recovery ordering: a bound task's deliveredAt acceptance mark is persisted before the cross-lane pending-deliveries read, so a corrupt or unreadable file cannot leave the task running without the mark and replay the injection; watch state drops malformed per-entry values (keeping healthy entries) instead of throwing every poll. - error taxonomy: a post-wake retry 404 is reversible backpressure (the owner may be draining; the classification keys on "the wake ran", not the detach return), not Gone; the interrupted-worktree 409s (worktree_marker_missing / worktree_reset_interrupted) are gone for delivery (a resume retry never converges); an authoritative Gone evicts the cached wake cwd. - watch robustness: fail fast on any rate limit (GitHub does not always send retry-after) and permanent per-PR 404/410/451, not just primary limits. - GHEC data-residency: map api.<tenant>.ghe.com to the <tenant>.ghe.com web host so bound delivery works there. - docs: PR breaking-change notes, dead-letter filename, Files-affected table, ci_failure SHA-revisit semantics (both design docs, EN + zh-CN), plus two test-harness isolation fixes (shuffle-safe). Adds regression + test-gap tests; every code fix mutation-verified red without its test. Deferred with disclosure: a low-confidence pre-existing sidecar enrichment silent failure in the shared session-list surface. * docs(channels): note the post-wake 404 exception in the accessor comment Co-authored-by: Qwen-Coder <[email protected]> * fix(channels): close comment-round findings on absence, idempotency, and watch isolation An incomplete binding scan never establishes absence: empty-partial cold answers defer the lane for a bounded number of polls, and unmatched events defer recoverably (dead-letter at the horizon) until a complete snapshot lands. Scanned sessions with no bindings emit tombstone rows through the route, projection, and accessor so an observed unbind stops private delivery even on partial answers; unreadable sidecars flag partial. Prompt admission gains an idempotencyKey dedupe ring so a retry after a lost acceptance mark or a lost 202 re-injects nothing; the accessor sends the durable task id on every POST. Cross-workspace channel classification now requires the explicit bound-relay marker, scoping the process-level worker credential to its reason. 429 and other 5xx join the backpressure taxonomy. Watch hardening: reconciliation failure gates watch-task recovery, shared credential/rate-limit failures abort the whole pass (401 included), the pending CI re-check is interval-bounded, a failing Checks endpoint no longer suppresses the other detections, and pending envelopes validate with the task store's own validators. Bound prompts surface both truncation levels and the comment author; a failed dead-letter write keeps the task. Docs (user, protocol, design EN/ZH) and Web Shell i18n synced; every fix mutation-verified. Co-authored-by: Qwen-Coder <[email protected]> * fix(channels): make the aggregate per-comment display cut visible Real-scenario verification of the truncation fixes surfaced a third silent-tail surface in the same flow: the aggregate lane caps each comment at 400 display characters with no marker, and the comment is already recorded in dispatchedComments — a long comment's tail (possibly its actionable last line) vanished from every surface while the bullet read complete. The cut now carries an explicit [comment truncated: N more characters] marker, alongside the existing prompt-level and batch-level notices. Regression test added and mutation-verified; user doc and design docs (EN/ZH) synced. Co-authored-by: Qwen-Coder <[email protected]> * fix(channels): close round-1 audit findings on event loss and admission Undirected + adversarial audit of the merged diff found four event-loss paths and an admission-contract outlier; all fixed with regression tests and mutation witnesses: - The aggregate lane recorded dispatchedComments keys BEFORE the task claim; an unwritable task store plus the unconditional end-of-poll cursor save persisted the keys with no task, silently consuming the comments forever. Keys now land via applyTaskDedupe only after the task write succeeds. - The idempotency key is now the event identity (source chat + message id), not the task id: a crash after task removal but before the cursor save re-creates the task, and a task-scoped key let the daemon admit the same event twice. - A persistently failing Checks endpoint degraded the pending re-check interval to one doomed request per PR per poll; failed attempts now advance the interval clock, and the undefined-ci branch is bounded too. - Pruning a watch entry whose earlier transition still has a surviving task reset the episode counter; a rebind then collided keys and silently consumed the new transition into the old envelope. Prune now skips entries with recoverable watch tasks (and skips entirely when the task store is unreadable). - sendPrompt's unknown/closing admission rejections threw as rejected promises, letting the prompt route return a phantom 202 and the ring remember a non-admission; they now throw synchronously like the sibling admission paths, and the route forgets the key on any deferred SessionNotFoundError as defense in depth. Also: bounded the accessor wake-cwd cache under permanent partial, honest relabel of the routing-gate regression test, field-level body assertions, Web Shell coverage for the two new descriptor fields (EN/zh), descriptor reasonFilter description synced to the checkbox contract, and the protocol doc's ring bounds completed (1024-session cap and its lifetime exception). Co-authored-by: Qwen-Coder <[email protected]> * fix(channels): close round-2 audit findings on watch key identity and admission The round-1 event key still collided across PRs (one repo, one session, parallel episode counters) and across episode-counter resets (prune or corruption recovery plus rebind), letting the daemon's long-lived idempotency ring silently swallow new transitions. Watch keys now bake in the PR number and detection timestamp; ci_failure keeps once-per-head via PR+SHA prefix matching in the fired ring and pending queue. Over-length event keys hash deterministically instead of earning a route 400 that burns the retry budget. The prompt route forgets the idempotency key on any pre-admission deferred rejection, keyed off the admission signal rather than the error type, covering the standalone working-directory RequestError. Permanent partial snapshots can no longer grow binding retention or watch entries without bound (1024 cap, oldest-first, surfaced on stderr). Design docs (EN/ZH) and contract comments are synced, including the ring-lifetime exception at the 1024-session cap. Co-authored-by: Qwen-Coder <[email protected]> * fix(channels): close round-3 audit and maintainer review findings The public-routing gate trusted a historical complete snapshot: under a later partial answer an unmatched PR event could take the irreversible public path while a live follower hid in an unlisted workspace. Absence is now authoritative only on a current complete successful snapshot, or per PR when every previous non-channel binder is listed and excludes it (a listed session's PR set is complete, so its tombstone stays authoritative). Never-known PRs, omitted or retention-evicted binders, and failing refreshes defer as bounded backpressure; the new clauses are PR-scoped so issue events and channel-only bindings keep flowing publicly. The aggregate batch idempotency key identified only the first comment, so a superset batch re-listed after the cursor-save crash window deduped into the original admission and permanently consumed the new members. The batch message id is now a content hash of the sorted member identities; identical batches still dedupe across the acceptance crash window. Partial retention rebuilt only the old winners, so a listed tombstone on the newest follower emptied the routing map despite a retained alternate; the rebuild now iterates the retained session rows. Watch-entry cap eviction gained the prune's retained-binding and in-flight-task protections (an evicted entry re-baselines silently, so the earlier re-fire claim was wrong and is corrected). Disabling watch subscriptions no longer clears the reconciliation guard while unreconciled pendings exist, and the per-entry pending queue is capped at 64 with oldest overflow retired to the dead-letter. Cross-instance watch delivery is documented as per-subscription at-least-once, and the admission residual (202 before admission completes) is stated precisely in the route and contract comments. Design decisions 10/11 (EN/ZH) are synced. Co-authored-by: Qwen-Coder <[email protected]> * fix(channels): close round-4 audit findings on absence authority and republication A partial binding snapshot can never prove a PR unbound: an unscannable workspace may hold a never-observed follower, and a listed tombstone proves only that the listed session unbound. The round-3 per-PR authority mechanism granted public routing when every observed binder was listed and excluding the PR, which a probe showed misroutes a same-poll comment irreversibly. Remove the mechanism; unmatched PR events now defer as bounded backpressure until a complete snapshot lands, while listed tombstones still stop private delivery and watching immediately. The partial/failed clauses stay PR-scoped so issue events keep flowing once the cold-start gate opens. Close a pre-existing public-reply double-publish window: a reply could land (publication audit posted), the task be removed, and the cursor save lost; the re-listed comment was claimed and published again because only recovery checked the audit. The dispatch path now honors the same audit record before claiming, fail-closed on an unreadable audit, and records the dedupe keys so the cursor advances. The worker accessor flagged a listed session partial only when ALL its PR entries failed validation; any dropped entry now degrades the answer so a partial projection never masquerades as complete. The round-3 cap-eviction task protection gains a non-vacuous witness (the protected entry moved outside the retained candidate range). Bound preflight rejections and the disabled-subscription guard now surface accurate stderr, and the protocol doc states the 202-before-admission residual. Co-authored-by: Qwen-Coder <[email protected]> * fix(channels): close round-5 audit findings on audit fail-closed and wake classification The round-4 claim-time publication audit check claimed fail-closed but only skipped the single notification: with no task claimed, the end-of-poll cursor commit (mark-read included) advanced past the skipped event, so a persistently unreadable audit silently consumed every new public event instead of retrying. An unreadable audit now freezes the whole poll's cursor via a dedicated flag, and the next poll retries the read and the events. The audit consumption verdict treated any posting/posted/suppressed line as consumed regardless of a later terminal failed, so a reply that never landed (posting then failed) plus a lost cursor save permanently swallowed the event. The verdict is now the LAST outcome per source key: a terminal failed keeps the event retryable; a bare posting (crash mid-post) still consumes at-most-once. The gone-session public fallback bypassed the absence gate: under a partial scan an unscannable workspace may still follow the PR, making the public reply irreversible. The fallback now defers as backpressure until a complete snapshot establishes absence (delivery cleared so recovery re-resolves without a doomed POST); deliverToBoundSession returns a settled flag so runInboundTask's success path cannot retire a task the fallback deferred. The wake/resume path classified 429 and non-503 5xx as delivery failures, burning the attempt budget into an unrecoverable dead-letter on a transient resume rate limit or spawn jitter; they are backpressure now, aligned with the POST path. The watch drain stops at the first failed delivery to preserve per-PR ordering, and the worker flags ANY dropped session row partial, symmetric with the PR-entry rule. Co-authored-by: Qwen-Coder <[email protected]> * fix(channels): close round-6 audit findings on freeze bypass and ordering The round-5 audit freeze only froze the cursor commit, but the direct lane and the first-contact body feed recorded their dedupe keys unconditionally after dispatch; the run loop's unconditional cursor save then persisted the keys, so a transient unreadable audit permanently filtered the re-listed events — the same silent consumption the freeze was meant to prevent, revived through two lanes. Both lanes now honor the dispatch refusal before recording, matching the comment lane. The audit consumption verdict treated every terminal failed as a provable no-write, but ambiguous errors (502/timeout) may have landed the comment; unlocking those keys let a crash window double-publish an irreversible public reply. Ambiguous failures now audit as failed_ambiguous and stay consumed (at-most-once); only definite no-write failures (secondary rate limit with remaining=0) unlock retry. The per-PR drain ordering held only within a single pass: the watch pass re-drains after reconcile and the next poll's reconcile runs before recovery, so a later event could still arrive before its unsettled predecessor. The drain now skips the whole entry while an earlier watch transition of the same PR has an unsettled task (a task whose key equals the pending head is that event's own crash-window claim and drains normally). A claim persistence failure propagates out of the drain so reconcile fails and the recovery guard holds instead of delivering an accepted task whose pending record is unreconciled. Disabling watch subscriptions with legacy unreconciled pendings parked the accepted tasks as backpressure so they stop freezing the shared notification cursor channel-wide; an unusable watch state container is surfaced on stderr like the corruption path instead of wiping silently. Co-authored-by: Qwen-Coder <[email protected]> * test(channels): cover the audit-freeze recovery with deliverToBoundSessions off The maintainer's P2 reproduction exercised the unreadable-audit window with deliverToBoundSessions both enabled and disabled; parameterize the R5-A1 freeze/recovery test over both modes so the public-only path is pinned too. Co-authored-by: Qwen-Coder <[email protected]> * test(channels): mock pulls.get in the direct-lane freeze test processDirectLane fetches PR meta before dispatch; without an explicit pulls.get mock the fetch throws under isolated runs, the per-notification catch skips the event, and the test never reaches the freeze guard — passing only by mock leakage from earlier tests in a full-suite run and failing (vacuously) in isolation. The mock makes the witness genuine in both run modes; the guard-off mutation now kills it isolated too. Co-authored-by: Qwen-Coder <[email protected]> * test(channels): pin the reader's failed_ambiguous consumption verdict The writer-side failed_ambiguous tests only read the audit file; no test drove the reader's consumption of an ambiguous verdict, so deleting the reader's failed_ambiguous branch kept the suite green. The distinguishing sequence is posting -> definite failed -> failed_ambiguous: the ambiguous retry must supersede the stale definite-failed verdict and stay consumed, or the double-publish window reopens. The new test seeds that sequence (plus a definite-failed retryable contrast) and drives dispatch; the reader-branch mutation now kills it. Co-authored-by: Qwen-Coder <[email protected]> * fix(channels): let the notification cursor flow while watch reconcile is blocked Co-authored-by: Qwen-Coder <[email protected]> * test(channels): prime the comment lane in the direct-lane freeze test Co-authored-by: Qwen-Coder <[email protected]> * fix(channels): decouple watch retires from the public reply store and sanitize dead-letter dumps Co-authored-by: Qwen-Coder <[email protected]> * fix(serve): throw synchronously on the standalone admission guard so relays never consume a phantom 202 Co-authored-by: Qwen-Coder <[email protected]> * test(serve): harden deferred-forget retries and align admission contract comments Co-authored-by: Qwen-Coder <[email protected]> * fix(channels): protect the dispatched-comment ring from frozen-cursor eviction and sanitize error diagnostics Co-authored-by: Qwen-Coder <[email protected]> * fix(channels): remove the hard-cap comment refusal that silently consumed events; harden the cursor ring Co-authored-by: Qwen-Coder <[email protected]> * fix(channels): prune the comment-time map every poll, not only on dispatch Co-authored-by: Qwen-Coder <[email protected]> * fix(channels): iterate live followers in the gone-fallback and prune the time map in finally Co-authored-by: Qwen-Coder <[email protected]> * fix(channels): exempt issue events from the whole bound-absence gate The per-event absence gate's !boundSnapshotTrusted clause was not PR-scoped, so under a permanent partial binding scan (a multi-page scan, a long-lived non-active workspace, an untrusted primary, or a corrupt sidecar) a complete snapshot never lands and unmatched issue events deferred to the 240-deferral horizon and dead-lettered without a public reply — even though bindings are PR-only and an issue's absence is provable without any snapshot. PR-scope the whole gate, cold start included; issue events route publicly at once. The labeling assumption is verified against 50 real GitHub notifications (zero subject-type/URL mismatches) plus GitHub's exclusive issue/PR number space. Sync the design EN+ZH (gone-fallback iteration, the finally prune timing, the whole-gate issue exemption) and the user doc, and align the stale R13-M1 test comment with the finally semantics. * docs(channels): qualify the issue exemption and document the audit write trade-off Round-15 audit follow-up, documentation only: The user-doc sentence added in round 14 overstated the issue exemption ("never held by the binding scan ... at once"): round 14 PR-scoped only the per-event absence gate, while the lane-level cold-start gate still holds every notification (issue events included) until the first successful binding lookup, and the empty-partial gate holds the lane up to 30 polls. Qualify the sentence to the per-event gate, state the lane-level hold explicitly, and tighten the design's stale-bindings shorthand to match (EN + ZH). Also document the publication audit's WRITE semantics in key decision 10 (EN + ZH): the write is best-effort (fail-open) so an unwritable audit store does not break the public-reply contract — a deliberate trade-off pinned by the "keeps successful publication when its audit write fails" test. Under total storage failure no durable consumption intent can be recorded, so a failure spanning the posting record, the POST, and the task removal can let recovery re-publish; the read path stays fail-closed. Notes the fail-closed write-ahead alternative and its availability cost. * fix(channels): tolerate .diff/.patch binding URLs and pin the aggregate sort parsePrBindingUrl rejected GitHub's /pull/N.diff and /pull/N.patch spellings while the refresh sweep's pullRequestKey tolerates them. The bind route accepts any http(s) URL without normalizing the path, so a manually pasted .diff/.patch binding persisted and was then skipped by the routing-map build, the gone-fallback, and the PR watch — silently degrading that PR to public replies and never watching it. Align the suffix alternation with pullRequestKey; the existing number-match guard still rejects mismatched url/number bindings. Add parameterized .diff / .patch regression tests. Also pin the aggregate content hash's order-independence: the existing R3-B1 test only fed ascending members, so removing the implementation's sort survived. Add an out-of-order batch test asserting the key equals the sorted batch's key. Documentation: key decision 11 (EN + ZH) still carried round 14's overstatement that issue events are exempt from the "ENTIRE absence gate, cold start included"; round 15 fixed the user doc and the stale-bindings paragraph but missed this numbered decision. Qualify it to the per-event absence gate (including its !boundSnapshotTrusted cold-start clause) and state explicitly that the separate lane-level cold-start gate still holds issue events until the first successful lookup. * docs(channels): document the hidden-catalog delivery limitation Round-17 audit follow-up, documentation only (EN + ZH): Document the bindings snapshot's hidden-catalog exclusion as a deliberate design limitation: the machine-owned multi-agent task sessions (agent-host / agent) are excluded by the session-list path without setting partial, and they are not interactive delivery targets, so a PR bound only to such a session takes the public channel path. The public reply is safe; the private-delivery contract does not extend to those sessions. Making the snapshot authoritative over them or gating the write path is left to the multi-agent subsystem's design. Also finish the per-event qualification sweep: the absence bullet still said issue events are exempt from "the whole gate"; qualify it to "the whole per-event gate" to match key decision 11 and the sibling surfaces. * fix(channels): address review round — wake-cache presence, watch 403 isolation, aggregate ack Resolve the review-bot findings (one Critical) plus the doudouOUC doc gap, each pinned by a regression test and an isolated mutation witness: - R2-1 (Critical): the cold-cache wake branch read the target's cwd from the FIFO-capped hint cache, so a listed, live, dormant session beyond the 1024-row cap on a complete snapshot was misreported as gone and its event posted publicly. Read presence from the fresh full snapshot instead; the cap stays a bound (fast-path hint only) and the Gone verdict is unchanged. A present session is now woken even on a partial answer (presence is authoritative); only an absent session defers there. - R1-7: a failing refresh after one successful empty partial stalled the comment lane forever (the bounded escape only advanced on the success path) and hid behind a misleading "keeping previous map" log. Advance the same escape and key the distinct stall log off the no-authoritative-map state. - R1-11: a per-repo permission 403 aborted the whole watch pass, starving every other watched PR. Abort on a 403 only when provably a shared rate limit; otherwise isolate it per PR. - R2-2: the aggregate batch's non-numeric hash messageId silently disabled the eyes reaction. Record the batch's first comment as a bounded reaction anchor resolved by the prompt-start/end overrides. - R2-18: export PR_WATCH_EVENTS and build both the validator set and the UI option list from it; add the four localized option labels (EN + ZH). - doudouOUC: document the >100-session permanent-partial steady state and add a throttled diagnostic naming the listed session count. Test gaps closed: prsReadFailed production setters (R2-3), both idempotency FIFO bounds (R2-4), the real-fetch comment-time capture (R2-5), and the editor field descriptions (R2-6). Documentation: name the third relay _meta credential key (R2-7), complete the Files-affect inventory (R2-8), and qualify the unconditional acceptance criteria to the two FIFO caps and the restart recovery (R2-9), mirrored EN + ZH. --------- Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
1 parent
7ccf477d47
commit
f30da36c6f
33 files changed
+14548
-167
No files matched your search
@@ -0,0 +1,187 @@
|
||||
# GitHub Channel Events to PR-Bound Sessions
|
||||
|
||||
[English](2026-11-02-github-channel-bound-session-events.md) | [简体中文](2026-11-02-github-channel-bound-session-events.zh-CN.md)
|
||||
|
||||
Status: proposed (decisions confirmed by the maintainer on 2026-11-02: prompt-injection delivery, in-channel state watch, event scope = CI failure + merge conflict + new comments + head change / merged / closed).
|
||||
|
||||
## Problem
|
||||
|
||||
A user who follows a PR in a Qwen Code session (a Web Shell or CLI session that created, reviewed, or verified the PR) gets no help from the GitHub channel when that PR changes:
|
||||
|
||||
1. **Two disconnected session systems.** The GitHub channel routes every event to a channel-owned session keyed by `github:<owner>/<repo>:pr:<N>` (`SessionRouter`, `chat_thread` scope). The session that actually follows the PR — the one with the worktree, the context, and the PR binding in its `prs` sidecar — never sees the event.
|
||||
2. **Event coverage gaps.** The adapter polls the Notifications API and enumerates comments. A pure CI failure (reason `ci_activity`, no new comment) dispatches nothing. A merge conflict produces no GitHub notification at all. New pushes and merge/close transitions are likewise invisible unless someone comments.
|
||||
|
||||
The building blocks for closing both gaps already exist:
|
||||
|
||||
- **PR bindings**: `packages/core/src/services/session-pr-service.ts` persists `<chatsDir>/<sessionId>.pr.json`; the daemon refresh timer (`packages/cli/src/serve/server/session-pr-refresh.ts`) already scans every trusted workspace's sidecars on a cycle. There is no reverse lookup (PR → sessions) yet.
|
||||
- **Delivery into an existing session**: `POST /session/:id/prompt` accepts prompts for any live session, resolves the owner runtime by session id (cross-workspace capable), and FIFO-queues while the session is busy (`DEFAULT_MAX_PENDING_PROMPTS_PER_SESSION = 5` in `packages/acp-bridge/src/session-control-plane.ts`; queue full → HTTP 503 `prompt_queue_full` + `Retry-After`).
|
||||
- **Session creation**: the channel's existing `SessionRouter` path already creates a channel session when no route exists.
|
||||
|
||||
## Goals
|
||||
|
||||
- When a GitHub event concerns a PR that is bound to a live session, deliver the event into that session as a prompt turn, so the session can handle it with its existing context.
|
||||
- Sense the events the Notifications API cannot deliver: CI failure on the PR head, merge-conflict appearance, head advancement, and PR merged/closed transitions — for the set of PRs bound to sessions.
|
||||
- When no bound session exists, keep today's behavior: the event flows through the normal channel path and creates (or reuses) a channel session.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No webhook push ingestion. The daemon's `POST /channels/:channelName/webhooks/:source` route stays as it is (static targets); true push delivery is future work.
|
||||
- No channel takeover of bound sessions (`SessionRouter.activateManagedSession` is not used): a bound session's replies must NOT be auto-published as GitHub comments under the channel publication contract.
|
||||
- No cross-session peer messaging for this path (review-class gating and external-peer registration add complexity without benefit here).
|
||||
- No automatic PR binding of channel-created sessions. Channel sessions keep their existing routing and public-reply behavior.
|
||||
- No support for standalone `qwen channel start` (non-daemon ACP mode): the reverse lookup is a daemon route. The feature degrades to today's behavior there.
|
||||
- Inline PR review comments remain out of scope (existing adapter limitation).
|
||||
|
||||
## Design
|
||||
|
||||
### Layer 1 — daemon reverse-lookup route
|
||||
|
||||
New process-global, read-only route:
|
||||
|
||||
```
|
||||
GET /sessions/pr-bindings
|
||||
→ { "bindings": [ { sessionId, workspaceCwd, sourceType, updatedAt, prs: [{number, url, state?}] } ], "partial": false }
|
||||
```
|
||||
|
||||
- Ownership class: **process-global** (same class as `POST /sessions/backfill-prs`). It iterates the registry's workspace entries (`listAllEntries()`) and enumerates active sessions of each active trusted runtime through the live-merged session-list path (`listWorkspaceSessionsForResponse`, which enriches summaries from the PR sidecars), paginating up to a bounded page cap. Untrusted non-primary workspaces are skipped (they cannot create or restore sessions, so their absence is legitimate); an untrusted PRIMARY is skipped with `partial` set, because session creation/restore exempts primary from the trust gate and it still serves bound sessions.
|
||||
- Archived sessions are excluded. `sourceType` is included so callers can exclude channel-owned sessions. `prs` entries are projected to `{number, url, state?}`. **Every successfully scanned session gets a row, including an empty one**: a `prs: []` row is a tombstone meaning "scanned, no bindings" (an observed unbind), which callers must be able to distinguish from "this answer did not enumerate the session" — otherwise any partial answer would revive a stale binding after an explicit unbind. A session whose sidecar EXISTS but could not be read (I/O error, invalid JSON, failed shape check — the projection's `prsReadFailed` flag, backed by core's `readSessionPrsDetailed` absent/invalid distinction) is omitted with `partial` set: its bindings are unknown, not empty. **Design limitation — hidden-catalog sessions.** The underlying session-list path also excludes the machine-owned hidden-catalog sources (`agent-host` / `agent`, the multi-agent task sessions no session listing or count shows) **without** setting `partial`. Those sessions are not interactive delivery targets, so a PR bound only to such a session — the prompt-route bind (`/review <N>` / `/verify-pr`) does not gate by source type, so an agent task session can acquire a binding — is treated as unbound and its events take the public channel path. The public reply is safe (no loss or duplication); the private-delivery contract deliberately does not extend to machine-owned agent task sessions. Making the snapshot authoritative over them (enumerate hidden sessions, or set `partial` on their exclusion) or gating the write path to skip them is left to the multi-agent subsystem's design rather than this channel feature.
|
||||
- **`partial`** is true when the answer is incomplete: a workspace failed to list, a trusted workspace is not active (draining / transitioning / blocked — it still serves sessions but cannot be enumerated), an untrusted primary workspace (still serves bound sessions, skipped), a live-merge degraded success (`liveMergeFailed`), a page reported truncation, the page cap was exhausted, or the scan needed more than one page. The last source matters because activity-ordered pagination is not snapshot-isolated: a live session whose activity advances between page fetches can be skipped on every page while the scan still finishes clean, so any multi-page answer is non-authoritative. Consumers must not treat a missing entry in a partial answer as an authoritative absence (the adapter merges partial answers into its known bindings and skips watch-state pruning). A per-workspace scan failure is logged to stderr (workspace + cause) so a persistent partial has a diagnostic surface.
|
||||
- Authenticated like the other session routes (bearer when `--require-auth`). No new write surface.
|
||||
- The route is a scan, not an index: at the scale of the sidecar system (tens of sessions per workspace) a per-poll scan is acceptable; the adapter caches the result for one poll cycle.
|
||||
|
||||
### Layer 2 — worker-provided delivery accessor
|
||||
|
||||
`ChannelBaseOptions` (in `packages/channels/base/src/ChannelBase.ts`) gains one optional field; the `ChannelAgentBridge` contract is unchanged:
|
||||
|
||||
```ts
|
||||
boundSessions?: {
|
||||
listPrBindings(): Promise<{ bindings: PrBindingSummary[]; partial: boolean }>;
|
||||
deliverPrompt(
|
||||
sessionId: string,
|
||||
prompt: string,
|
||||
idempotencyKey?: string,
|
||||
): Promise<void>;
|
||||
};
|
||||
```
|
||||
|
||||
- The daemon channel worker (`packages/cli/src/commands/channel/daemon-worker.ts`) implements it with the SDK client it already holds: `listPrBindings` calls the new route and rebuilds its session→cwd cache from each complete answer (sessions that left the bindings drop out); a malformed HTTP-200 body (non-array `bindings`) throws instead of returning an authoritative empty snapshot, routing the failure into the adapter's fail-closed refresh handling. Empty-prs tombstone rows pass through to the adapter (they are the observed-unbind signal); ANY malformed PR entry — or a dropped session row — flags the answer partial, and a listed session whose PR entries ALL fail shape validation is additionally skipped instead of projecting an authoritative empty set. `deliverPrompt` posts the prompt as a text content block and treats acceptance into the FIFO queue as delivery — the worker does not wait for the target session's turn to finish. Every POST carries the caller's `idempotencyKey` (a stable event identity derived from the source chat and message id): the prompt route remembers admitted keys per session and returns the original admission on a repeat, so a retry after a lost acceptance mark or a lost 202 re-injects nothing within the daemon's lifetime — dedupe is best-effort under the two FIFO caps (128 keys per session, 1024 sessions), and the ring dies with the in-memory prompt queue, so after a restart re-injection is the correct recovery (Key decision 10). Every reachable pre-enqueue admission check throws SYNCHRONOUSLY — including the reserved standalone working-directory guard, which answers a retryable `409 working_directory_missing` while the session's managed conversation binding has not released — so the route never returns a phantom 202 that the relay would consume as durable acceptance; the route's key-forget on a deferred non-admission remains defense-in-depth. The POST rides the same channel-turn classification as the bridge — three `_meta` keys that admission verifies together and strips before forwarding: the `qwen.channel.prompt` marker, the `qwen.daemon.channelBoundRelay` intent marker, and the `qwen.daemon.channelPromptAuthorization` worker credential (the worker sends it only when it holds one) — so the relay is not treated as an interactive user prompt (loop-detected rejection, live PR-binding backfill on relayed text); a POST carrying the two markers without the credential is admitted as an ordinary interactive prompt. The worker authorization is a **process-level relay credential scoped to its reason**: home-workspace turns ride the per-workspace registration; CROSS-workspace classification additionally requires the explicit `qwen.daemon.channelBoundRelay` marker and a primary/trusted target, because bound delivery is cross-workspace (Key decision 3) but a plain prompt carrying a foreign worker token must stay an ordinary interactive prompt. A forged or unregistered token is still dropped at the route and again at the bridge admission strip. Every response body is consumed or cancelled and every body read is bounded by an abort signal: an unread undici body holds its connection until GC, and a stalled mid-body read would otherwise hang the poll loop.
|
||||
- **Dormant wake**: the bindings route lists persisted active sessions, but the prompt route only serves live ones; after a daemon restart a bound session is dormant and answers 404. On 404 the accessor restores the session through the same resume path the channel bridge uses for its own dormant sessions (workspace cwd taken from the cached bindings, refreshed once if unknown), then retries the prompt once. The restore mints a client registration; the accessor releases it (detach) once the retried prompt settles — a non-empty registration blocks the daemon's prompt-settled idle close, so discarding it without detaching would pin the session live until the TTL reaper. When the cwd is unknown and the refresh returns a partial snapshot that omits the session, the accessor defers with a backpressure error (`BoundSessionUnavailableError`) instead of classifying the 404 as gone or consuming the failure budget: absence from an incomplete enumeration is not evidence of deletion.
|
||||
- **Wake error classification**: a 404 restore (session record gone, or the workspace itself unregistered — the restore route answers a distinguishable `workspace_not_found` 404 for permanent absence, while reversible draining/transitioning states keep `workspace_mismatch` / `workspace_runtime_unavailable`), an archived session (409 `session_archived`, excluded from the candidate set by contract), a superseded session (409 `worktree_session_superseded` — a worktree reset severs it permanently, never restored again), and the interrupted-worktree shapes (409 `worktree_marker_missing` / `worktree_reset_interrupted`, whose repair is a worktree-reset retry rather than a resume retry, so deferring them would dead-letter without ever falling back) become `BoundSessionGoneError`. Queue-full, temporarily-unavailable workspaces/sessions (503, the daemon's own prompt-tier rate limit 429 and other 5xx, restore conflicts, session limits, an armed worktree-reset prompt barrier 409 `worktree_reset_active` — the reset flow tells channel-worker callers to retry), bindings-refresh failures, and a **404 on the retry AFTER a successful wake** (the owner may have begun draining between the resume and the retry, and the prompt route answers the same reversible 404 for a non-active owner) are backpressure or transient errors so the adapter defers or retries instead of degrading into public channel delivery — the next wake's resume reports a permanent 404/409 if the session is truly gone. The direct prompt path classifies these 409 codes the same way as wake (archived/superseded → gone; reset barrier/restore-in-progress/busy → backpressure), plus the unreleased standalone working-directory binding (`working_directory_missing`, which the daemon marks retryable with `Retry-After`) as backpressure: the bind/release window around a conversation restore is transient, so the event defers instead of burning the failure budget. An authoritative Gone also evicts the session's cached wake cwd, so a permanent partial snapshot cannot make every later delivery pay a doomed resume round-trip.
|
||||
- `deliverPrompt` surfaces gone, queue-full (`BoundSessionQueueFullError`) and temporarily-unavailable (`BoundSessionUnavailableError`) as distinct errors so the adapter can fall back, defer, or retry.
|
||||
- When the option is absent (standalone mode, tests), adapters behave exactly as today.
|
||||
|
||||
### Layer 3 — GitHub adapter changes
|
||||
|
||||
All in `packages/channels/github/src/GithubAdapter.ts` plus the plugin descriptor in `index.ts`.
|
||||
|
||||
#### Configuration
|
||||
|
||||
Two new management fields:
|
||||
|
||||
| Field | Kind | Default | Meaning |
|
||||
| ------------------------ | ----------- | ---------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `deliverToBoundSessions` | boolean | `false` | Route events for PRs bound to a live session into that session instead of the channel session. |
|
||||
| `prWatchEvents` | string-list | `[]` (off) | State transitions to watch for bound PRs: `ci_failure`, `merge_conflict`, `head_change`, `state_change`. |
|
||||
|
||||
`validateConfig` rejects a non-empty `prWatchEvents` when `deliverToBoundSessions` is off (the watch set is defined by bindings; watching without delivery has no MVP meaning). The descriptor delegates to the adapter's shared validator (`validateBoundSessionConfig`), so the management UI and `connect()` enforce one vocabulary and cannot drift. Both fields are opt-in; existing deployments are unaffected.
|
||||
|
||||
#### Bound-session selection
|
||||
|
||||
At the start of each poll, when `deliverToBoundSessions` is on, the adapter fetches `GET /sessions/pr-bindings` and builds a map `owner/repo#number → session`. The key lowercases the repo path on both sides: GitHub URLs are case-insensitive (core's `canonicalSessionPrUrl` lowercases them), binding URLs may carry any case while notification subject URLs carry the canonical case. A leading `www.` is stripped on both sides of the host comparison (users paste `www.github.com` links; core's `repoKeyFromWebUrl` normalizes the same alias). Binding URL parsing tolerates trailing tab segments (`/files`, `/commits`, `/checks`) because the binding routes accept any http(s) URL naming the PR.
|
||||
|
||||
- Keep only bindings whose PR URL host matches the channel's GitHub host (Aone and other-provider bindings are ignored).
|
||||
- Keep only entries with recorded `state` not `merged`/`closed` for the active watch set, unless the terminal transition has not been observed yet (see PR state watch); delivery candidates for comment events accept any recorded state (a comment on a just-merged PR still deserves delivery while the binding refresh catches up).
|
||||
- Exclude sessions with `sourceType === 'channel'` — channel sessions are reached through the normal channel path, which preserves their public-reply contract.
|
||||
- Multiple candidates: pick the most recently updated session (`updatedAt` desc; the route falls back to `createdAt` for live summaries that lack `updatedAt`, so a fresh live session is never outranked by a stale persisted one). Deterministic, no broadcast.
|
||||
- A failed refresh keeps the previous map (stale candidates fail at delivery time and fall back); the watch pass is skipped for that poll so a transient failure cannot prune the persisted baseline. A `partial` snapshot is merged into the previous map and binding list — known bindings the incomplete answer failed to list are retained, so private delivery never degrades to the public path on a scan failure — and watch-state pruning is disabled. Retention is per session, not per PR key: a LISTED session's PR set is authoritative (the route returns each listed session's complete sidecar projection), so retention never revives a PR that session just unbound; and a retained candidate competes by recency, so a partial answer listing only an older session cannot displace the newer known one. The accessor likewise keeps its session→cwd wake cache on partial answers.
|
||||
- **Cold start**: until the first snapshot succeeds (complete OR partial — the map is populated from partial answers too), the comment lane is deferred (the cursor does not advance). Claiming delivery against a cold empty map would permanently freeze events onto the public channel path even after the scan recovers; persisted tasks still recover while the lane is deferred. The gate opens on any successful snapshot (`boundSnapshotSeen`), not complete-only: a permanent partial (a multi-page scan or a long-lived non-active workspace) would otherwise stall the lane forever. **An EMPTY partial answer is additionally bounded**: while no complete snapshot has ever succeeded and the map is empty, consecutive empty-partial answers keep the lane closed for up to `MAX_COLD_EMPTY_PARTIAL_POLLS = 30` polls (throttled stderr) — a draining/unreadable workspace answering `{bindings: [], partial: true}` on the first poll after a restart must not route every event publicly and commit the cursor before the real snapshot arrives. At the cap the lane opens anyway (a permanently empty partial must not freeze the channel), and unmatched PR events then take the per-event bounded deferral below instead of the public path. Re-validation and watch pruning still require a complete snapshot (`boundSnapshotTrusted`). Because a binding refresh that has NEVER succeeded (an old daemon without the route, rotated credentials) would otherwise freeze every notification behind a misleading "keeping previous map" line, the deferral logs a distinct, throttled, actionable message naming the stalled lane and the route to check — the gate itself stays fail-closed.
|
||||
- **Absence is never established by an incomplete scan**: while `boundSnapshotTrusted` is false, an event whose PR has no candidate in the map is NOT routed publicly. It is claimed and deferred as backpressure (no delivery yet, same durable retry and `MAX_BOUND_DEFERRALS` horizon as every other deferral, dead-letter at the horizon); recovery re-resolves such deferred events against the current map, so a complete snapshot that lands later routes them privately. Known targets are unaffected and proceed immediately. Public routing of an unmatched PR event requires a complete snapshot to have established the absence at least once; issue events are exempt from the whole per-event gate (Key decision 11).
|
||||
|
||||
#### Comment delivery intercept
|
||||
|
||||
`dispatchEnvelope` is the single funnel for accepted comment/body/review/assign events. Before claiming the inbound task it consults the bound-session map:
|
||||
|
||||
- **Bound session found** → the persisted `InboundTaskRecord` gains `delivery: { kind: 'bound-session', sessionId }`. `runInboundTask` delivers the event prompt through `boundSessions.deliverPrompt` instead of `handleInbound`. Because this bypasses `handleInbound`, the bound path runs the same `preflightInbound` (group policy, sender gate, pairing — side effects included) before delivering; watch events are exempt (no human sender, operator opt-in). The frozen target is re-validated against the latest complete snapshot at delivery time, so a live rebind retargets the event instead of misdelivering to the old session; on a partial/failed scan the persisted target stands (fail-closed). Dedupe keys and the recoverable-task machinery are unchanged — only the terminal delivery differs.
|
||||
- **No bound session** → today's path for comment events (`handleInbound` → channel session). **Watch events are private-only**: they are operator-facing machine state with no pre-existing public path, and a channel session's reply would publish the internal prompt text on the PR (and re-gate it through the sender allowlist). A watch event with no bound target — at delivery, after a gone re-resolution, or at drain time — is logged and retired, never fallen back publicly.
|
||||
- **Delivery fails**: 404 → the accessor first tries the dormant wake (Layer 2); only a session that cannot be restored surfaces as gone, and the adapter then evicts the dead session from its binding maps (so a partial snapshot cannot retain it by recency and shadow a live follower) and iterates through the PR's remaining live followers from a fresh binding fetch (most recent first, excluding every session already Gone in this attempt); each live candidate is re-delivered in turn (a Gone from a re-delivery hop evicts that candidate and tries the next instead of consuming the failure budget or falling back early — the Gone set bounds the loop by the finite follower count), no candidate left on a COMPLETE snapshot → comment events fall back to `handleInbound`, watch events retire with a log; under a partial/failed refresh the comment fallback defers as backpressure until a complete snapshot establishes absence (Key decision 11). Queue-full and temporarily-unavailable sessions/workspaces are backpressure, not failure: they do not consume the retry budget, so a busy or draining session defers the event instead of losing it after `MAX_INBOUND_TASK_ATTEMPTS = 3`. Backpressure is bounded (`MAX_BOUND_DEFERRALS = 240`, ~4 hours at the default poll): beyond the horizon the event is retired to a private dead-letter log (`github-bound-deadletter.jsonl`) with a stderr alert — surfaced, never silently consumed. If the dead-letter append itself fails, the task is KEPT (the payload is dumped to stderr and the durable record survives for the next poll's retry or operator recovery) — a removal there would leave one log line as the event's only trace. Pure bound backpressure — including the targetless deferrals an incomplete snapshot produces — does not hold the shared notification cursor (one stuck session must not freeze mark-as-read for every unrelated PR); other recoverable tasks still hold it, except watch tasks while reconciliation is blocked (they cannot make progress and do not ride the notification cursor). Non-backpressure bound failures exhaust the 3-attempt cap and likewise retire to the dead-letter log (the private path's exhaustion surface — it posts no public error comment). Acceptance is persisted (`deliveredAt`) before the task is removed; the mark is an optimization, not the replay guard: every injection carries a stable event identity (source chat + message id) as an admission-side `idempotencyKey`, so a crash or write failure in the acceptance window (or a lost 202) replays the POST with the SAME key — even when recovery re-creates the task with a new id — and the daemon deduplicates it into the original admission (Key decision 10). A crash-loop charges one attempt per recovery pickup so it terminates. Recovery after restart honors the persisted `delivery` field and re-resolves targetless backpressure deferrals against the current map.
|
||||
|
||||
The injected prompt reuses the webhook-task prompt shape (`buildChannelWebhookPrompt` style): event header, explicit "untrusted event data" warning, PR identity (repo, number, title, URL), the lane's `Trigger:`/`Event:` lines plus the direct lane's factual `Author:` details, the comment author/body or event details, and guidance that the reply stays in this session (the session is not under the channel publication contract; it acts on GitHub through its own normal tools if needed). The mention and first-contact lanes supply no `Author:` metadata line, so the prompt falls back to the envelope's sender name — the session must be able to tell a maintainer's instruction from a drive-by commenter's. Channel-facing publication wording never reaches the bound session: the metadata's publication instructions are dropped, and the aggregate lane's "output exactly `<no-reply/>`" prefix is replaced with a plain "New comments on the PR" header. **No tail is silently consumed**: comment content is capped at 6,000 code points with an explicit `[prompt truncated: N more characters omitted]` notice appended outside the slice; an aggregate batch that drops older comments at the 20-comment cap prepends an omission notice (at the FRONT, so the code-point cap cannot truncate the notice itself); and each aggregated comment's 400-character display cap carries its own `[comment truncated: N more characters]` marker — every such comment is already recorded in `dispatchedComments`, so a silent cut at any level would lose it from every surface while the prompt reads complete.
|
||||
|
||||
#### PR state watch
|
||||
|
||||
When `prWatchEvents` is non-empty, `pollOnce` reconciles durable pending events, then runs a watch detection pass after the binding refresh and before generic inbound recovery and notification processing:
|
||||
|
||||
1. **Watch set**: bound PRs from the same binding fetch, filtered to the channel's GitHub host, capped at 30 (most recently updated bindings first) for active polling. When more than 30 PRs are bound, the overflow is logged to stderr (count + excluded keys) so the oldest bindings being deterministically unwatched is surfaced, never silent. A binding recorded `merged`/`closed` by the independent refresh stays polled until the poll itself observes the terminal transition (`prState: 'closed'`), so `state_change` is never skipped by the refresh winning the race. Once observed, the terminal MARKER is retained without polling for as long as the binding lists the PR — pruning it would re-add the closed PR as a first observation on the next poll (query oscillation). Binding retention and active polling are separate concerns. No bound PRs → zero API calls. Persisted entries are pruned against the full candidate set (not the capped watch set, so PRs rotating out of the cap keep their baseline), only when the snapshot is complete — a partial snapshot never prunes — and never while the entry still holds undelivered pending events. Because a permanent partial never prunes, retention is bounded separately: at most 1024 unlisted sessions and 1024 idle watch entries are kept (oldest-first eviction, surfaced on stderr for both caps), so a permanently unscannable workspace cannot grow adapter state without bound. Watch-entry eviction additionally skips entries a retained binding still claims or an in-flight watch task still owns; an evicted entry's PR has no delivery target, and a later rebind re-baselines silently like any first observation, so eviction bounds memory without losing a deliverable event. A dropped session's unmatched events defer (an evicted binder is unlisted, so absence is not authoritative) and surface in the dead-letter at the horizon.
|
||||
2. **Per PR**: `pulls.get` every poll (head SHA, `mergeable`, `mergeable_state`, `state`, `updated_at`). Check runs (`checks.listForRef`) and combined commit status (`repos.getCombinedStatusForRef`) are fetched only when `ci_failure` is subscribed AND: the head SHA changed since the last poll, no CI observation exists yet, the last observed CI aggregate was pending AND the 5-minute pending re-check interval (`CI_PENDING_RECHECK_INTERVAL_MS`) has elapsed, the PR's `updated_at` advanced, the aggregate is `none` within a 10-minute grace window after the head was first observed (checks register asynchronously), or the last CI fetch is older than the 10-minute settled-head re-check interval. The pending interval is load-bearing in the other direction: a workflow queued for environment approval or an offline runner can stay pending for hours, and an unbounded per-poll re-fetch would turn the settled-head cost model into ~3,600 CI calls/hour at the watch cap — over budget once `pulls.get` is counted. The periodic re-check is load-bearing too: GitHub does NOT bump a PR's `updated_at` on check activity (verified against the live API), so a settled head whose check re-run turns red — or CI that appears late on a manual `workflow_dispatch` — would otherwise be missed forever. Steady-state cost is one call per PR per poll plus the bounded periodic re-checks. A failed CI fetch is isolated in its own try/catch: CI is a conditional refinement of the `pulls.get` baseline, so a persistent Checks-endpoint failure (a classic PAT without Checks permission, an enterprise policy) logs and continues WITHOUT writing a CI state — `merge_conflict`/`state_change` still fire from the data that already arrived, and the last known aggregate stands (writing `none` would re-fire `ci_failure` when the endpoint heals). Watch API calls fail fast on conditions a retry cannot fix — credential failure (401), any rate limit (403/429; GitHub does not always send a `retry-after` header on a secondary/abuse limit) and permanent per-PR statuses (404/410/451 — the PR was deleted, transferred, or the repo became unavailable) — with no blocking cooldown sleep. The shared conditions (401/403/429) abort the WHOLE pass at the failing PR: walking the rest of the set would issue one doomed request per watched PR per poll for the whole reset window; per-PR permanent statuses stay isolated so one deleted PR does not stop the others. The fail-fast predicate is watch-local so the publication lane keeps its own retry semantics, and the notification lane still runs in the same poll.
|
||||
3. **Transition detection** against persisted watch state (`channelFilePath('github-pr-watch.json')`, pruned against the full candidate set on complete snapshots — see item 1):
|
||||
- `ci_failure`: aggregate turns to failure (any completed check run with `failure`/`timed_out`/`action_required`/`startup_failure` conclusion, or a nonempty combined status reporting `failure`/`error`). An empty combined status (`total_count: 0`, which GitHub returns as `state: "pending"` for SHAs without legacy statuses) never counts as active CI. Fires once per head SHA via the durable `fired` ring, even if the aggregate flaps back to success and fails again; a new head that fails fires again. A per-entry `ciFiredSha` pin backs the ring: the capped ring can evict the per-head `ci_failure` key after 64 non-ci episodes on a long-lived head, and the pin preserves the once-per-head guarantee even after that eviction (it resets when the head changes). The event summary names the failed checks (up to 5) so the session can act without re-querying.
|
||||
- `merge_conflict`: `mergeable_state` enters `dirty`. Edge-triggered; `dirty → clean → dirty` fires again. `mergeable === null` (GitHub still computing) never overwrites the last known state.
|
||||
- `head_change`: head SHA differs from the last observed SHA (first observation initializes silently). A force-push revisit of a recent SHA is a new transition and fires again.
|
||||
- `state_change`: PR `state` leaves `open` (merged or closed). First observation initializes silently; close → reopen → close fires again.
|
||||
- **Dedupe key identity**: every watch key bakes in the PR number and the detection timestamp (`watch:<type>:<pr>:<seq>:<detectedAt>`; `watch:ci_failure:<pr>:<sha>:<detectedAt>`). The PR number scopes the key because the delivery idempotency key carries only chat + message id, and two PRs of one repo bound to the same session issue parallel episode counters. The timestamp keeps instances unique across episode-counter resets (prune, corruption recovery): the daemon's idempotency ring outlives the worker, so a re-issued bare counter key would dedupe into the old admission and silently swallow the new transition. `ci_failure` still fires once per head SHA — the fired ring and pending queue match its keys by PR+SHA prefix — while the other three key on the episode counter, a transition INSTANCE rather than a state value, so the legitimate recurrences above are never suppressed by the fired ring. The counter is persisted with the baseline. An event key that would exceed the prompt route's 200-character cap is replaced by a deterministic hash of the same identity (`github:h:<sha256-40>`), so retries still dedupe instead of burning the failure budget on 400s. Watch identities are instance-local by design: two channel configurations watching the same repository each deliver their own watch notifications (at-least-once per subscription) — the daemon ring dedupes retries of ONE delivery, not independent subscriptions; comment events still dedupe across instances via their stable GitHub ids.
|
||||
4. **Durable event queue**: detected events are appended to a `pending` queue persisted in the same write as the new baseline, so a crash between detection and claim loses nothing. A reconciliation pass drains ALL pending entries (no GitHub calls) BEFORE generic recovery and independently of the live watch pass: a failing or rate-limited API pass cannot starve already-detected events, and recovery cannot deliver-and-remove a task that a later drain would re-claim (a crash between the claim and the pending→fired save leaves both records on disk). **When reconciliation ITSELF fails** (e.g. the watch file is unreadable), generic recovery skips watch tasks entirely until a reconciliation succeeds — delivering and removing an accepted watch task with its pending record still unreconciled on disk would let a later drain claim and deliver the same transition a second time; unrelated comment recovery keeps running. Blocked watch tasks also stop holding the shared notification cursor: they cannot make progress while reconciliation fails, and watch events are detected from PR state rather than notifications, so freezing mark-as-read for them would stall every unrelated PR until the sidecar recovers; once reconciliation succeeds they hold the cursor again until their bounded retries settle them. Draining then, per event: re-validate the recipient against the latest complete snapshot, claim the inbound task (durable — a terminal failed/cancelled record for a recurring WATCH key is replaced, never silently reused; comment/event/issue-body keys are stable identifiers that never re-fire, so their terminal records stay a permanent dedupe barrier — replacing them would revive a cancelled comment after a crash leaves the cursor behind the cancel), move the key from `pending` to a capped `fired` ring (durable), then run the task. A crash at any point leaves either a pending event or a claimed task; the `fired` ring is the durable once-per-transition record (task-level dedupe alone would let a flapping aggregate re-fire the same head after the completed task is removed). A failed claim leaves the event queued for the next poll. The drain stops at the first failed delivery so a later event cannot jump ahead of an earlier retrying one (per-PR ordering); pending and fired are already persisted, so stopping loses nothing. The ordering holds across passes too: while an earlier watch transition of the same PR still has an unsettled task, the whole entry's drain is skipped (the watch pass re-drains after reconcile, and the next poll's reconcile runs before recovery) — a task whose key equals the pending head is that event's own crash-window claim and drains normally. A claim persistence failure propagates out of the drain so reconcile fails and the recovery guard holds. The per-entry pending queue is capped at 64: a persistently failing claim cannot grow it without bound; overflow retires the OLDEST queued event to the dead-letter, and a failed dead-letter write keeps it queued for the next detection. A failed pending→fired save AFTER a successful claim rolls the claim back (removes the task, restores the pending event) and retries next poll, so a watch-file-unwritable / task-file-writable divergence cannot deliver the same transition twice across a restart. Event tasks use `source.messageId = watch:<eventType>:<pr>:<headSha|seq>:<detectedAt>` and are delivered privately like comments — but with no public fallback (see Comment delivery intercept).
|
||||
5. **Binding state upkeep** stays with the existing 5-minute refresh timer; the adapter does not PATCH metadata. Once the timer records `merged`/`closed` AND the watch has observed the terminal transition, the PR leaves active polling; its terminal marker is retained until the binding itself disappears.
|
||||
6. **Persistence failure semantics**: a transient read error (EACCES/EMFILE/EIO) fails CLOSED — the watch pass is skipped and nothing is saved until the state loads again, so a degraded in-memory state can never overwrite the intact file. Only ENOENT (first start) and persistent JSON corruption start fresh; a syntactically valid file whose individual entries are malformed is repaired in place — each bad entry is dropped (with a stderr line) and the healthy entries survive, so one corrupt value cannot starve the API-independent drain of the entries after it. The per-entry shape check validates pending events with the SAME envelope and delivery validators the task store applies at claim time: a shape-valid envelope missing `chatId` would otherwise throw in the binding-key lookup and abort the whole reconcile loop, and a partial envelope would be claimed verbatim, rejected on the next task-file read, and wedge the store until an operator deletes the file. Failed atomic writes clean up their UUID-named tmp file so sustained disk pressure cannot accumulate orphans. The state file and directory are mode 0600/0700 (they persist private comment envelopes).
|
||||
|
||||
#### API budget
|
||||
|
||||
Default 60 s poll, cap 30 watched PRs: steady state ≤ 1 800 calls/hour, plus the periodic settled-head re-check (≤ 2 calls per PR per 10-minute interval → ≤ 360/hour) and the bounded pending re-check (≤ 2 calls per PR per 5-minute interval → ≤ 720/hour worst case with every watched PR pending). CI-active bursts add 1 combined-status call plus `ceil(checkRuns/100)` check-list pages per affected PR per re-check — a 300-check monorepo head costs 4 calls, so large-CI bursts can still exceed the 5 000 calls/hour token budget; the watch then aborts the pass on the shared rate-limit/credential failure (one doomed call, not one per PR) and retries next poll rather than blocking. Notification polling overhead is unchanged. The adapter logs and skips the watch pass when the binding fetch fails (notifications still flow).
|
||||
|
||||
## Key decisions
|
||||
|
||||
1. **Prompt injection, not channel attach.** `activateManagedSession` would bind the followed session's outbound target to the GitHub thread, so every final reply of an interactive session would be published as a public PR comment (publication contract). Injection keeps replies in the session and leaves GitHub actions to the session's own judgment and tools.
|
||||
2. **In-channel state watch, not webhooks.** The watch reuses the channel's existing poll loop and credentials; detection happens at the API layer and costs zero model tokens until a real transition. Webhook push would need a public tunnel plus repository webhook creation (operator consent) and a rework of the static webhook targets. The watch is polling, but it is the gate-probe shape the maintainer accepts: cheap detection outside the model, model only on change.
|
||||
3. **Reverse lookup lives in the daemon.** Bindings span workspaces; the worker is workspace-scoped and already talks to the daemon over authenticated HTTP. A read-only process-global route reuses the refresh timer's scan pattern instead of duplicating sidecar access in the worker.
|
||||
4. **One candidate, most recently updated.** Broadcasting to every bound session multiplies model cost and produces duplicate GitHub actions; the freshest non-archived, non-channel session is the one most likely to hold the working context.
|
||||
5. **Channel sessions excluded from candidates.** A channel-created session bound via `/review` backfill must keep its public-reply contract; the normal channel path already serves it.
|
||||
6. **Opt-in defaults.** Public-repository events driving an interactive session is a new trust surface; operators enable it explicitly, and comment sender gates still apply before delivery.
|
||||
7. **Wake dormant sessions instead of falling back.** E2E showed that after a daemon restart the bound session is dormant and the prompt route 404s; treating that as "gone" silently diverted every post-restart event into public channel delivery — breaking the feature's core private-delivery promise. The accessor therefore restores the session (the same resume path the channel bridge already uses) and retries once; only an unrestorable session falls back. The restore mints a client registration that the accessor explicitly detaches once the retried prompt settles: a non-empty registration blocks the daemon's prompt-settled idle close, so without the detach the woken session would stay pinned live until the TTL reaper (or forever with the reaper disabled).
|
||||
8. **Watch events are private-only.** A comment has a pre-existing public path to fall back to; a watch event does not. Falling a watch event back to a channel session would publish operator-facing machine state ("CI failed on head X. Inspect the failed checks…") as a public PR comment and re-gate it through the sender allowlist. Maintainer decision (2026-11-02, resolving the triage/verification escalation): a watch event with no bound target is logged and retired, never published. The retirement is surfaced (stderr + task removal), not a silent drop.
|
||||
9. **Bounded backpressure with a dead-letter surface.** Queue-full and unavailable conditions defer without consuming the failure budget, but the deferral horizon is bounded (~4 hours) and pure backpressure — bound deliveries AND the targetless deferrals an incomplete snapshot produces — does not hold the shared notification cursor: one stuck session must not freeze mark-as-read for every unrelated PR, and a permanently stuck session must not wedge a task forever. Events that exhaust the horizon or the failure budget retire to a private dead-letter log: the private path posts no public error comment, so the dead-letter is its exhaustion surface. A failed dead-letter write keeps the task instead of removing it.
|
||||
10. **Admission-side idempotency, not a bigger acceptance window.** The durable `deliveredAt` mark can only protect crashes AFTER it lands; the acceptance write itself can fail (EIO) or the 202 can be lost, and the daemon mints a fresh prompt id per request, so a retry used to re-inject the same event. Moving the mark before the POST would trade a duplicate for a silent loss. Instead the POST carries a stable event identity (source chat + message id) as an `idempotencyKey` — stable across task re-creation, which a task-scoped key would not be — and the prompt route deduplicates admitted keys per session (bounded in-memory ring, 128 keys per session, 1024 sessions, FIFO): a retry returns the original admission without enqueueing. The ring's lifetime equals the in-memory prompt queue's — a daemon restart loses both together, making re-injection the correct recovery there. Either cap can also evict a key while its queue is still live — the per-session cap in the ordinary case of one long-lived session admitting more than 128 distinct events, the session cap when more than 1024 sessions hold keys — and a retry after such an eviction re-injects. The mark remains as a fast path that retires the task without a round-trip. For aggregate comment batches the message id is a content hash of the sorted member ids (`agg:<sha256-40>`), not the first comment: a crash before the cursor save can re-list a SUPERSET batch, and a first-comment key would let the daemon ring dedupe the grown batch into the original admission, silently dropping the new members; identical batches still dedupe across the acceptance crash window. The cursor's `dispatchedComments` ring backs this with **window-aware eviction**: each key carries its comment's `created_at` (`dispatchedCommentTimes`, captured at fetch), and once past the soft cap (500) eviction drops only keys whose comment can no longer be re-listed (`created_at <=` the committed cursor) — a still-re-listable key (or a legacy key with no recorded time) is protected, so a frozen cursor cannot slide an already-replied comment out of the ring and let a re-listed superset re-publish it under a fresh hash. There is deliberately **no hard-cap refusal**: refusing dispatches without freezing the cursor would mark-read the refused events (silent loss), and freezing the cursor would deadlock (the ring can only shrink once the cursor advances). The trade-off is that a _sustained_ mark-read outage grows the protected ring with the re-list window (bounded by traffic × outage duration); it shrinks again once mark-read recovers. Recovery re-records a still-pending task's keys (restoring dedupe); a re-record without a captured time stays protected, the conservative direction. The auxiliary time map is itself bounded (`MAX_COMMENT_TIMES = 8192`, oldest non-ring entries dropped first, ring keys' times always retained) and pruned every poll in a `finally` around the mark-read commit — after a successful commit against the ADVANCED cursor, and after a failed commit against the FROZEN cursor — so a frozen cursor with zero dispatch (a sender-rejected stream, or mark-read itself failing) cannot grow it without bound. The publication audit's consumption verdict is the LAST outcome per source key: a terminal `failed` (a provable no-write) after `posting` leaves the event retryable, while an ambiguous failure (`failed_ambiguous` — the write may have landed) and a `posting` with no terminal record (crash mid-post) consume at-most-once, since a second public reply would be irreversible; an unreadable audit freezes the poll's cursor rather than skipping events past a mark-read, and every dispatch lane honors the refusal before recording its dedupe key. The audit WRITE is best-effort by contrast: an unwritable audit store does not block the public reply (a full or corrupted audit file must not break the reply contract — pinned by the "keeps successful publication when its audit write fails" test), so a storage failure spanning the `posting` record, the POST, and the task removal can leave no durable consumption intent and let a later recovery re-publish. Under total storage failure no durable intent can be recorded at all, so the write path trades strict at-most-once for reply availability while the read path stays fail-closed; making the `posting` write fail-closed (write-ahead) would restore at-most-once at the cost of blocking replies whenever the audit store is unwritable. The freeze is enforced at DISPATCH time: a recovery pass holding only watch tasks reads neither public store (watch events are never published, so they can match neither a pending delivery nor an audit key), and a private watch retire completes even while the audit is unreadable — the next public dispatch still refuses and freezes.
|
||||
11. **An incomplete scan never establishes absence.** A partial bindings answer — especially an EMPTY one after a restart — is not evidence that a PR is unbound, and public routing is irreversible (the cursor commits in the same poll). So the cold gate defers empty-partial answers for a bounded number of polls, and unmatched PR events defer per-event (recoverable, dead-letter at the horizon) instead of taking the public path whenever absence is not authoritative — before the first complete snapshot, and during ANY partial answer or failing refresh. A partial answer cannot prove absence even when every OBSERVED binder is listed and excludes the PR: an unscannable workspace may hold a never-observed follower, and a listed tombstone proves only that the listed session unbound (it still stops private delivery and watching immediately). Known targets proceed immediately. Issue events are exempt from the entire PER-EVENT absence gate, including its `!boundSnapshotTrusted` cold-start clause: bindings are PR-only and GitHub's issue/PR number space is exclusive, so an issue event can never have a bound target — its absence is provable without any snapshot, and once the lane is open it must not stall (or dead-letter) behind a degraded binding scan, including a permanent partial where a complete snapshot never lands. The lane-level cold-start gate is separate and still holds issue events with every other notification until the first successful binding lookup (see the Cold start bullet above). The bounds keep a permanently degraded deployment from freezing: the lane opens at the cap and PR events surface in the dead-letter rather than vanishing.
|
||||
|
||||
## Files affected
|
||||
|
||||
| Layer | File | Change |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| core types | `packages/core/src/services/session-pr-service.ts` | additive `readSessionPrsDetailed` (absent vs invalid sidecar distinction); `readSessionPrs` delegates, unchanged contract |
|
||||
| daemon route | `packages/cli/src/serve/routes/session-pr-bindings.ts` (new) + registration | `GET /sessions/pr-bindings` scan route with empty-prs tombstones and `prsReadFailed` → partial |
|
||||
| daemon projection | `packages/cli/src/serve/server/session-list.ts`, `packages/acp-bridge/src/bridgeTypes.ts` | sidecar enrichment distinguishes unreadable sidecars (`prsReadFailed` on `BridgeSessionSummary`) from absent ones |
|
||||
| daemon route | `packages/cli/src/serve/routes/session.ts` | restore route: distinguishable `workspace_not_found` 404 for permanent workspace absence; prompt route: relay-marker-scoped cross-workspace worker authorization + admission-side `idempotencyKey` dedupe |
|
||||
| daemon admission | `packages/cli/src/serve/channel-worker-prompt-authorization.ts` | process-level relay predicate `isChannelWorkerPromptToken` + explicit `CHANNEL_BOUND_RELAY_META_KEY` intent marker for cross-workspace classification |
|
||||
| bridge admission | `packages/acp-bridge/src/session-control-plane.ts` | three prompt-admission failures (missing session, closing session, reserved standalone working-directory guard) converted from `return Promise.reject(...)` to synchronous `throw`, so the route can never answer a phantom 202 (Key decision 10) |
|
||||
| channel base | `packages/channels/base/src/ChannelBase.ts`, `types.ts`, `bound-session-delivery.ts` (new), `index.ts` | optional `boundSessions` option + `PrBindingSummary` type + shared delivery error classes (gone / queue-full / unavailable) |
|
||||
| channel base | `packages/channels/base/src/PollingChannelBase.ts` | poll-error path sanitizes the error text (`sanitizeLogText`, 200-code-point cap) in both the stderr line and the recorded channel event — shared base of every polling channel |
|
||||
| worker | `packages/cli/src/commands/channel/daemon-worker.ts` | implement the accessor with the SDK client |
|
||||
| adapter | `packages/channels/github/src/GithubAdapter.ts`, `index.ts` | config fields, selection map, delivery intercept, state watch, watch-state persistence |
|
||||
| docs | `docs/users/features/channels/github.md`, `docs/developers/qwen-serve-protocol.md` | document the two fields, event semantics, limits, the two stall states, the restore 404/400 split, and `idempotencyKey` |
|
||||
| web shell | `packages/web-shell/client/components/channels/ChannelEditorDialog.tsx`, `client/i18n.tsx` | label/description keys (EN + ZH) for the two new descriptor fields |
|
||||
| tests | collocated `.test.ts` for each changed file, except `session-control-plane.ts` (covered by `packages/acp-bridge/src/bridge.test.ts`) | see validation |
|
||||
|
||||
## Constraints and risks
|
||||
|
||||
- **Untrusted content into an interactive session.** Event payloads are marked untrusted in the injected prompt (same contract as webhook tasks). Comment sender gates apply unchanged. State events have no human sender; they exist only because the operator opted in, and they are private-only (no public fallback). The target session's own approval mode governs its actions.
|
||||
- **Process-level relay credential (widened worker authorization).** Bound delivery is cross-workspace, so the prompt route cannot keep the old home===target equality for the worker token. The widening is scoped to its reason: cross-workspace channel classification requires the explicit `qwen.daemon.channelBoundRelay` marker IN ADDITION to a registered worker token and a primary/trusted target, and a plain prompt carrying a foreign worker token gets no channel classification and no caller-supplied display projection. Residual risk, stated plainly: the marker is intent, not authentication — a holder of workspace B's worker token can set it and relay into a trusted workspace A, opting that turn out of loop-detected rejection and supplying its display text. The security boundary remains token possession plus the target's trust (the same trust model the bindings route enumerates); a daemon serving an internal interactive workspace alongside a workspace whose channel ingests attacker-controlled text extends that channel's reach into the trusted workspace's sessions. Operators who cannot accept that should not register channel workers on the same daemon, or should keep the ingestion workspace untrusted (untrusted non-primary workspaces are already excluded from both the bindings scan and the relay gate).
|
||||
- **Queue pressure.** A burst of events can fill the target's 5-slot prompt queue; queue-full and temporarily-unavailable conditions are backpressure (they do not consume the retry budget) and delivery retries on later polls. The deferral horizon is bounded (`MAX_BOUND_DEFERRALS`, ~4 hours); beyond it — or when non-backpressure failures exhaust the 3-attempt cap — the event retires to the private dead-letter log with a stderr alert. Pure bound backpressure does not hold the shared notification cursor; other recoverable tasks still hold it, except watch tasks while reconciliation is blocked (they cannot make progress and do not ride the notification cursor). No bound event is silently consumed: it is delivered, deferred, or dead-lettered.
|
||||
- **Rate limits.** The watch caps, conditional check fetches and the bounded periodic/pending re-checks keep the steady state under the 5 000 calls/hour token budget (large-CI bursts can exceed it; see API budget). A shared credential/rate-limit failure (401/403/429) aborts the whole watch pass with a log — one doomed call, not one per watched PR — and notifications are unaffected, rather than sleeping out the reset window ahead of the notification lane. The daemon's own prompt-tier 429 is backpressure for delivery (defer, never burn the failure budget).
|
||||
- **Stale bindings.** A dormant bound session is woken by the accessor (Layer 2), so daemon restarts do not divert events into public channel delivery; the cold-start gate defers the comment lane until the first successful snapshot populates the binding map, and empty-partial answers keep it closed for a bounded number of polls (Key decision 11). Unmatched PR events defer recoverably instead of routing publicly whenever absence is not authoritative (no complete snapshot yet, or any partial answer or failing refresh); issue events are exempt from this per-event gate — bindings are PR-only, so their absence needs no snapshot (the lane-level cold-start gate still holds them with every other notification until the first successful lookup). A binding whose session was truly deleted fails the wake → comment events fall back to the channel path, watch events retire with a log; the next poll's binding fetch heals the map. Under a partial/failed scan the comment fallback defers as backpressure until a complete snapshot establishes absence — an unscannable workspace may still hold a never-observed follower, and the public reply would be irreversible. An observed unbind (a scanned session listed with an empty PR set) stops private delivery and watching even on a partial answer — the tombstone row makes "scanned, no bindings" distinguishable from "not enumerated" — while public routing for the PR still waits for a complete snapshot.
|
||||
- **Closed sessions.** `DELETE /session/:id` closes a session without deleting its record; the record stays resumable and bound, so such sessions are still woken for delivery (verified in E2E). Only record deletion (`POST /sessions/delete`) or archiving removes a session from the candidate set (an archived session's restore answers 409 `session_archived`, classified as gone for delivery). Closing is a resource action, not an unsubscribe; the binding expresses the follow intent.
|
||||
- **Scan cost.** The route scans sidecars per call; at current scale this is milliseconds. Multi-page scans are flagged partial (activity-ordered pagination is not snapshot-isolated), which makes consumers retain known bindings rather than prune — a deliberate conservative trade. If scale grows, a snapshot-consistent enumeration or an in-memory index invalidated by `session_metadata_updated` is the follow-up, not part of this change.
|
||||
|
||||
## Validation plan
|
||||
|
||||
- **Unit**: route scan (trusted/untrusted, archived exclusion, sidecar corruption tolerance); adapter selection (host filter, state filter, channel-source exclusion, recency tie-break); delivery intercept (bound → inject, unbound → channel path, 404 fallback, queue-full retry, recovery honors persisted delivery); watch transitions (each event type, dedupe per head, null-mergeable guard, first-observation silence, state-before-dispatch ordering); config validation.
|
||||
- **E2E** (daemon + local build): bind a PR to a session via PATCH metadata; simulate events with a stubbed GitHub API layer; assert the bound session receives exactly one injected prompt per transition, replies stay in-session, and an unbound PR still creates a channel session. Plan details in `.qwen/e2e-tests/github-channel-bound-session-events.md`.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. With both fields enabled, a new comment on a bound PR arrives as a prompt in the bound session (not the channel session), and the session's reply is not auto-posted to GitHub.
|
||||
2. A CI failure on the bound PR's head, a transition to `mergeable_state: dirty`, a head advance, and a merge/close each produce exactly one injected prompt per transition instance, surviving daemon restarts without re-firing; legitimate recurrences (dirty → clean → dirty, close → reopen → close, a `head_change` SHA revisit, a new head failing) fire again, and a settled head whose check re-run turns red is caught by the periodic re-check. `ci_failure` is once per head SHA within the fired-ring window — a revisited SHA's CI failure is suppressed while that head's key remains in the ring, and `head_change` carries the revisit itself.
|
||||
3. With no bound session on a complete snapshot, comment events behave exactly as today (channel session created/reused, public reply contract intact); under a partial snapshot or a failing refresh the fallback defers until a complete snapshot establishes absence (Key decision 11); watch events are logged and retired, never published.
|
||||
4. With the fields disabled (default), behavior and API call volume are unchanged.
|
||||
5. Queue-full, rate-limited (429), and unavailable paths defer without losing events and without freezing the shared cursor; session-gone comment paths fall back without loss; watch paths retire privately; exhausted events are dead-lettered (and a failed dead-letter write keeps the task); within a daemon's lifetime, no crash window replays an accepted injection — a retry after a lost acceptance mark or a lost 202 carries the same idempotency key and deduplicates into the original admission; dedupe is best-effort under the two FIFO caps (128 keys per session, 1024 sessions), and the ring dies with the in-memory prompt queue, so after a restart re-injection is the correct recovery (Key decision 10) — and none silently consumes a transition; an empty partial cold snapshot never routes events publicly, and an observed unbind stops private delivery even on a partial answer.
|
||||
|
||||
## Open questions
|
||||
|
||||
None. Webhook push delivery and an in-memory binding index are recorded as future work, not blockers.
|
||||
@@ -0,0 +1,187 @@
|
||||
# GitHub Channel 事件投递到 PR 绑定会话
|
||||
|
||||
[English](2026-11-02-github-channel-bound-session-events.md) | [简体中文](2026-11-02-github-channel-bound-session-events.zh-CN.md)
|
||||
|
||||
状态:提案(维护者已于 2026-11-02 确认决策:prompt 注入投递、channel 内状态监视、事件范围 = CI 失败 + 合并冲突 + 新评论 + head 前进 / merged / closed)。
|
||||
|
||||
## 问题
|
||||
|
||||
在 Qwen Code 会话里跟进一个 PR 的用户(创建、评审或验证过该 PR 的 Web Shell 或 CLI 会话),在该 PR 发生变化时得不到 GitHub channel 的任何帮助:
|
||||
|
||||
1. **两套互不相通的会话体系。** GitHub channel 把每个事件路由到 channel 自有的会话,路由 key 为 `github:<owner>/<repo>:pr:<N>`(`SessionRouter`,`chat_thread` scope)。真正跟进该 PR 的会话——拥有 worktree、上下文、且 `prs` sidecar 里登记了该 PR 绑定的那个——永远看不到事件。
|
||||
2. **事件覆盖缺口。** 适配器轮询 Notifications API 并枚举评论。纯 CI 失败(reason 为 `ci_activity`、无新评论)不产生任何投递。合并冲突根本不会产生 GitHub 通知。新推送与合入/关闭迁移同样不可见,除非有人发表评论。
|
||||
|
||||
补齐这两个缺口所需的积木已经存在:
|
||||
|
||||
- **PR 绑定**:`packages/core/src/services/session-pr-service.ts` 持久化 `<chatsDir>/<sessionId>.pr.json`;daemon 刷新定时器(`packages/cli/src/serve/server/session-pr-refresh.ts`)已经在周期性扫描所有 trusted workspace 的 sidecar。目前还没有反查(PR → 会话)。
|
||||
- **向已有会话投递**:`POST /session/:id/prompt` 接受任意存活会话的 prompt,按 session id 解析 owner runtime(可跨 workspace),会话 busy 时 FIFO 排队(`packages/acp-bridge/src/session-control-plane.ts` 的 `DEFAULT_MAX_PENDING_PROMPTS_PER_SESSION = 5`;队满 → HTTP 503 `prompt_queue_full` + `Retry-After`)。
|
||||
- **新建会话**:channel 现有的 `SessionRouter` 路径在无路由时本就会创建 channel 会话。
|
||||
|
||||
## 目标
|
||||
|
||||
- 当 GitHub 事件涉及的 PR 已绑定到某个存活会话时,把事件作为 prompt 回合投递进该会话,让它带着已有上下文处理。
|
||||
- 感知 Notifications API 给不了的事件:PR head 的 CI 失败、合并冲突出现、head 前进、PR 合入/关闭迁移——针对已绑定到会话的 PR 集合。
|
||||
- 没有绑定会话时保持今天的行为:事件走正常 channel 路径,创建(或复用)channel 会话。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不做 webhook 推送接入。daemon 的 `POST /channels/:channelName/webhooks/:source` 路由保持原样(静态 targets);真推送投递是后续工作。
|
||||
- 不让 channel 接管绑定会话(不使用 `SessionRouter.activateManagedSession`):绑定会话的回复绝不能按 channel publication contract 自动发布为 GitHub 评论。
|
||||
- 此路径不走跨会话 peer 消息(review-class 审批门与 external peer 注册在此徒增复杂度)。
|
||||
- 不自动给 channel 创建的会话登记 PR 绑定。Channel 会话保持既有路由与公开回复行为。
|
||||
- 不支持 standalone `qwen channel start`(非 daemon 的 ACP 模式):反查是 daemon 路由,该模式下功能退化为今天的行为。
|
||||
- 行内 PR review 评论仍不在范围内(适配器既有限制)。
|
||||
|
||||
## 设计
|
||||
|
||||
### 第 1 层 —— daemon 反查路由
|
||||
|
||||
新增进程级只读路由:
|
||||
|
||||
```
|
||||
GET /sessions/pr-bindings
|
||||
→ { "bindings": [ { sessionId, workspaceCwd, sourceType, updatedAt, prs: [{number, url, state?}] } ], "partial": false }
|
||||
```
|
||||
|
||||
- 所有权类别:**进程级**(与 `POST /sessions/backfill-prs` 同类)。遍历 registry 的 workspace 条目(`listAllEntries()`),对每个 active 的受信 runtime 经 live-merged 会话列表路径(`listWorkspaceSessionsForResponse`,它会用 PR sidecar 补全会话摘要)枚举 active 会话,分页有上限。Untrusted 非 primary workspace 跳过(它们无法创建或恢复会话,缺席是合法的);untrusted PRIMARY 跳过但置 `partial`,因为会话创建/恢复对 primary 豁免信任门,它仍在服务绑定会话。
|
||||
- 排除归档会话。携带 `sourceType` 以便调用方排除 channel 自有会话。`prs` 条目投影为 `{number, url, state?}`。**每个成功扫描到的会话都输出一行,包括空行**:`prs: []` 的行是 tombstone,意为「已扫描、无绑定」(观测到的解绑),调用方必须能把它与「本次答案未枚举到该会话」区分开——否则任何 partial 答案都会在显式解绑后复活过期绑定。sidecar 存在但读取失败(I/O 错误、JSON 非法、形状校验失败——投影的 `prsReadFailed` 标志,底层是 core 的 `readSessionPrsDetailed` 对 absent/invalid 的区分)的会话被省略并置 `partial`:它的绑定是未知,不是空。**设计限制——hidden-catalog 会话。** 底层会话列表路径还会排除机器自有的 hidden-catalog 源(`agent-host` / `agent`,即任何会话列表与计数都不显示的多智能体任务会话),且**不**置 `partial`。这些会话不是交互式投递目标,因此仅绑定到这类会话的 PR——prompt 路由绑定(`/review <N>` / `/verify-pr`)不按 sourceType 设门,agent 任务会话可获得绑定——会被当作未绑定,其事件走公开 channel 路径。公开回复是安全的(无丢失或重复);私密投递契约刻意不延伸到机器自有的 agent 任务会话。让快照对它们具权威(枚举 hidden 会话,或对其排除置 `partial`)、或在写路径设门跳过它们,留给多智能体子系统的设计而非本 channel 功能。
|
||||
- **`partial`** 在答案不完整时为 true:某个 workspace 列表失败、某个受信 workspace 非 active(draining / transitioning / blocked——它仍在服务会话但无法被枚举)、untrusted primary workspace(仍在服务绑定会话,被跳过)、live-merge 降级成功(`liveMergeFailed`)、某页报告截断、页数上限耗尽、或扫描需要多页。最后一条之所以重要,是因为活动序分页不是快照隔离的:某个 live 会话的活动在两次翻页之间前进,就可能被每一页都跳过而扫描仍然「干净」结束,因此任何多页答案都是非权威的。调用方绝不能把 partial 答案中缺失的条目当作权威性的「不存在」(适配器把 partial 答案并入已知绑定,并跳过监视状态剪枝)。单个 workspace 扫描失败会打 stderr 日志(workspace + 原因),持续 partial 因此有诊断面。
|
||||
- 与其他会话路由一样鉴权(`--require-auth` 时 bearer)。不新增写入面。
|
||||
- 该路由是扫描而非索引:以 sidecar 体系的规模(每 workspace 几十个会话),每次轮询扫一遍可以接受;适配器将结果缓存一个轮询周期。
|
||||
|
||||
### 第 2 层 —— worker 提供的投递访问器
|
||||
|
||||
`ChannelBaseOptions`(`packages/channels/base/src/ChannelBase.ts`)增加一个可选字段;`ChannelAgentBridge` 契约不变:
|
||||
|
||||
```ts
|
||||
boundSessions?: {
|
||||
listPrBindings(): Promise<{ bindings: PrBindingSummary[]; partial: boolean }>;
|
||||
deliverPrompt(
|
||||
sessionId: string,
|
||||
prompt: string,
|
||||
idempotencyKey?: string,
|
||||
): Promise<void>;
|
||||
};
|
||||
```
|
||||
|
||||
- daemon channel worker(`packages/cli/src/commands/channel/daemon-worker.ts`)用它已持有的 SDK client 实现:`listPrBindings` 调新路由,并从每次完整答案重建 session→cwd 缓存(离开绑定的会话随之掉出);畸形的 HTTP-200 响应体(`bindings` 非数组)抛错而非返回权威空快照,把失败导入适配器的 fail-closed 刷新处理。空 prs 的 tombstone 行原样传给适配器(它们是观测到解绑的信号);任一 PR 条目畸形——或任一会话行被丢弃——都把答案标记为 partial,而某会话列出的 PR 条目全部形状校验失败时还会跳过该会话,而非投影出权威的空集。`deliverPrompt` 以 text content block 投递 prompt,进入 FIFO 队列即算投递成功——worker 不等待目标会话的回合结束。每个 POST 都携带调用方的 `idempotencyKey`(由源 chat 与 message id 导出的稳定事件身份):prompt 路由按会话记住已受理的键,重复请求返回原受理结果,因此受理标记丢失或 202 丢失后的重试在 daemon 生命周期内不会重复注入——去重在两个 FIFO 上限(每会话 128 键、1024 会话)下尽力而为,环与内存 prompt 队列同生共死,重启后重新注入正是正确的恢复(关键决策 10)。每个可达的入队前 admission 检查都**同步抛错**——包括保留 standalone 工作目录守卫:会话的托管会话绑定尚未 release 时它返回可重试的 `409 working_directory_missing`——因此路由绝不会返回会被中继当作持久受理消费掉的幻影 202;路由对延迟非受理的键遗忘保留为纵深防御。POST 携带与 bridge 相同的 channel 回合分类——三个 `_meta` 键由 admission 一并校验并在转发前剥离:`qwen.channel.prompt` 标记、`qwen.daemon.channelBoundRelay` 意图标记、以及 `qwen.daemon.channelPromptAuthorization` worker 凭据(worker 持有凭据时才发送)——因此中继不会被当作交互式用户 prompt(loop-detected 拒绝、对中继文本做 live PR 绑定回填);只带两个标记而无凭据的 POST 会被当作普通交互式 prompt 受理。worker 授权是**限定于其存在理由的进程级中继凭据**:本 workspace 的回合走按 workspace 的注册;跨 workspace 的分类额外要求显式的 `qwen.daemon.channelBoundRelay` 标记与 primary/受信目标,因为绑定投递本就跨 workspace(关键决策 3),但携带外部 worker token 的普通 prompt 必须仍是普通交互式 prompt。伪造或未注册的 token 仍在路由处与 bridge admission 剥离处两次被丢弃。每个响应体都被消费或取消,每次 body 读取都有 abort signal 兜底:未读的 undici body 会把连接占到 GC,而停滞的 mid-body 读取否则会挂死整个轮询循环。
|
||||
- **休眠唤醒**:绑定路由列出的是持久化的 active 会话,但 prompt 路由只服务 live 会话;daemon 重启后绑定会话处于休眠态,prompt 会 404。访问器遇 404 时经 channel bridge 同款 resume 路径恢复该会话(workspace cwd 取自缓存的绑定,未知时先刷新一次),再重试一次 prompt。恢复会铸造一个 client 注册;访问器在重试的 prompt 落定后显式释放它(detach)——非空注册会挡住 daemon 的 prompt-settled idle close,不 detach 就会把会话钉在 live 态直到 TTL reaper。若 cwd 未知且刷新返回的 partial 快照漏列了该会话,访问器以背压错误(`BoundSessionUnavailableError`)推迟,而非把 404 判为消失或消耗失败预算:不完整枚举中的缺席不是删除的证据。
|
||||
- **唤醒错误分类**:404 恢复失败(会话记录真正不存在,或 workspace 本身未注册——restore 路由对永久性缺席返回可区分的 `workspace_not_found` 404,可逆的 draining/transitioning 状态仍返回 `workspace_mismatch` / `workspace_runtime_unavailable`)、归档会话(409 `session_archived`,按契约排除在候选集外)、被取代会话(409 `worktree_session_superseded`——worktree reset 永久切断它,绝不再恢复),以及中断的 worktree 形状(409 `worktree_marker_missing` / `worktree_reset_interrupted`——其修复是 worktree-reset 重试而非 resume 重试,推迟它们只会在视界后死信而永不回退)成为 `BoundSessionGoneError`。队满、workspace/会话暂不可用(503、daemon 自身 prompt 档限流 429 与其他 5xx、恢复冲突、会话数上限、已武装的 worktree-reset prompt 屏障 409 `worktree_reset_active`——reset 流程明确要求 channel-worker 调用方重试)、绑定刷新失败,以及**成功 wake 之后重试得到的 404**(属主可能在 resume 与重试之间开始 drain,而 prompt 路由对非 active 属主返回同一个可逆 404)作为背压或瞬时错误传播,让适配器推迟或重试,而不是降级成公开 channel 投递——若会话确已消失,下一轮 wake 的 resume 会报告永久 404/409。直接 prompt 路径对这些 409 码的分类与 wake 一致(archived/superseded → gone;reset 屏障/restore-in-progress/busy → 背压),另把未 release 的 standalone 工作目录绑定(`working_directory_missing`,daemon 标记 retryable 并带 `Retry-After`)归为背压:会话恢复前后的 bind/release 窗口是瞬态的,事件推迟而不消耗失败预算。权威 Gone 还会逐出该会话缓存的 wake cwd,永久 partial 快照因此不会让后续每次投递都空转一次注定失败的 resume。
|
||||
- `deliverPrompt` 把消失、队满(`BoundSessionQueueFullError`)与暂不可用(`BoundSessionUnavailableError`)作为可区分的错误抛出,供适配器回退、推迟或重试。
|
||||
- 该选项缺失时(standalone 模式、测试),适配器行为与今天完全一致。
|
||||
|
||||
### 第 3 层 —— GitHub 适配器改动
|
||||
|
||||
全部在 `packages/channels/github/src/GithubAdapter.ts` 及 `index.ts` 的插件描述符内。
|
||||
|
||||
#### 配置
|
||||
|
||||
新增两个管理字段:
|
||||
|
||||
| 字段 | 类型 | 默认 | 含义 |
|
||||
| ------------------------ | ----------- | ------------ | ----------------------------------------------------------------------------------------- |
|
||||
| `deliverToBoundSessions` | boolean | `false` | 把已绑定到存活会话的 PR 的事件投递进该会话,而非 channel 会话。 |
|
||||
| `prWatchEvents` | string-list | `[]`(关闭) | 对绑定 PR 监视的状态迁移:`ci_failure`、`merge_conflict`、`head_change`、`state_change`。 |
|
||||
|
||||
`deliverToBoundSessions` 关闭时 `validateConfig` 拒绝非空 `prWatchEvents`(监视集合由绑定定义;只监视不投递在 MVP 没有意义)。描述符委托适配器的共享校验器(`validateBoundSessionConfig`),管理界面与 `connect()` 共用同一份词表,不会漂移。两个字段都是 opt-in;既有部署不受影响。
|
||||
|
||||
#### 绑定会话选择
|
||||
|
||||
开启 `deliverToBoundSessions` 时,适配器在每次轮询开始拉取 `GET /sessions/pr-bindings`,构建 `owner/repo#number → session` 映射。键的两侧都对 repo 路径小写归一:GitHub URL 大小写不敏感(core 的 `canonicalSessionPrUrl` 即小写化),绑定 URL 可能带任意大小写,而通知 subject URL 带规范大小写。主机比较两侧都剥离前导 `www.`(用户会粘贴 `www.github.com` 链接;core 的 `repoKeyFromWebUrl` 归一同一别名)。绑定 URL 解析容忍尾部 tab 段(`/files`、`/commits`、`/checks`),因为绑定路由接受任何命名该 PR 的 http(s) URL。
|
||||
|
||||
- 只保留 PR URL 主机与 channel 的 GitHub 主机一致的绑定(Aone 等其他 provider 的绑定忽略)。
|
||||
- 活跃监视集合只保留记录 `state` 非 `merged`/`closed` 的条目,但终态迁移尚未被观测到的除外(见 PR 状态监视);评论事件的投递候选接受任意记录状态(刚合入的 PR 上的评论仍应投递,绑定状态由刷新定时器追平)。
|
||||
- 排除 `sourceType === 'channel'` 的会话——channel 会话走正常 channel 路径,以保留其公开回复契约。
|
||||
- 多个候选:取最近更新的会话(`updatedAt` 降序;live 摘要缺 `updatedAt` 时路由回退到 `createdAt`,新建的 live 会话因此不会被过期的持久化会话压过)。确定性选择,不广播。
|
||||
- 刷新失败保留旧映射(过期候选在投递时失败并回退);该轮询跳过监视回合,瞬态失败因此无法剪掉持久化基线。`partial` 快照会并入旧映射与绑定列表——不完整答案漏列的已知绑定被保留,私密投递绝不会因扫描失败降级成公开路径——并禁用监视状态剪枝。保留以会话为粒度而非 PR 键:已列出会话的 PR 集是权威的(路由返回每个已列出会话完整的 sidecar 投影),因此保留绝不会复活该会话刚解绑的 PR;被保留的候选按更新时间竞争,因此只列出较旧会话的 partial 答案不会挤掉更新的已知会话。访问器在 partial 答案下同样保留 session→cwd 唤醒缓存。
|
||||
- **冷启动**:首个快照成功之前(完整或 partial——映射同样由 partial 答案填充),评论车道推迟(游标不前进)。对着冷启动的空映射认领投递会把事件永久冻结到公开 channel 路径,即使扫描随后恢复;车道推迟期间持久化任务仍正常恢复。门在任意成功快照(`boundSnapshotSeen`)即开启,而非仅完整快照:永久 partial(多页扫描或长期非 active 的 workspace)否则会把车道永久停摆。**空的 partial 答案另外有界**:在从未有过完整快照且映射为空时,连续的空 partial 答案让车道最多关闭 `MAX_COLD_EMPTY_PARTIAL_POLLS = 30` 轮(限流 stderr)——重启后首轮就答 `{bindings: [], partial: true}` 的 draining/不可读 workspace,绝不能抢在真实快照到达之前把每个事件公开路由并提交游标。到达上限后车道仍然开启(永久空 partial 不能冻结 channel),未匹配的 PR 事件随后走下面的逐事件有界推迟而非公开路径。重新校验与监视剪枝仍要求完整快照(`boundSnapshotTrusted`)。由于从未成功过的绑定刷新(缺该路由的旧 daemon、轮换后的凭据)否则会在一句误导性的「keeping previous map」后面冻结所有通知,推迟会打一条可区分、限流、可操作的日志,点名停摆的车道与要检查的路由——门本身仍保持 fail-closed。
|
||||
- **不完整扫描绝不确立缺席**:`boundSnapshotTrusted` 为 false 期间,映射中无候选的 PR 的事件不会被公开路由。它被认领并作为背压推迟(尚无 delivery,与其他推迟同样的持久化重试与 `MAX_BOUND_DEFERRALS` 视界,视界尽头进死信);恢复时对照当前映射重新解析这类推迟事件,因此随后到达的完整快照会把它们私密投递。已知目标不受影响、立即投递。未匹配 PR 事件的公开路由要求至少有一次完整快照确立过缺席;issue 事件豁免整个逐事件门(关键决策 11)。
|
||||
|
||||
#### 评论投递拦截
|
||||
|
||||
`dispatchEnvelope` 是已接受评论/正文/review/assign 事件的唯一漏斗。在认领 inbound task 之前查询绑定会话映射:
|
||||
|
||||
- **找到绑定会话** → 持久化的 `InboundTaskRecord` 增加 `delivery: { kind: 'bound-session', sessionId }`。`runInboundTask` 经 `boundSessions.deliverPrompt` 投递事件 prompt,而非 `handleInbound`。由于绕过了 `handleInbound`,bound 路径在投递前执行同一套 `preflightInbound`(group policy、sender gate、pairing——含副作用);watch 事件豁免(无人类发送者、操作者 opt-in)。冻结的投递目标在投递时对照最新完整快照重新校验,因此 live 改绑会把事件改投新会话而非错投旧会话;partial/失败快照下持久化目标保持(fail-closed)。去重 key、可恢复任务机制不变——只有最终投递不同。
|
||||
- **无绑定会话** → 评论事件走今天的路径(`handleInbound` → channel 会话)。**watch 事件是私有-only**:它们是面向操作者的机器状态,没有既有的公开路径,channel 会话的回复会把内部 prompt 文本发布到 PR 上(并被 sender allowlist 重新 gating)。没有绑定目标的 watch 事件——投递时、gone 重解析后、或排空时——记日志并退役,绝不公开回退。
|
||||
- **投递失败**:404 → 访问器先尝试休眠唤醒(第 2 层);只有无法恢复的会话才视为消失,适配器随后从绑定映射中逐出该死会话(partial 快照因此无法按 recency 保留它、遮蔽活的跟随者)并重新拉取绑定、迭代遍历该 PR 其余存活跟随者(最近优先,逐个排除本次尝试中已 Gone 的会话);逐个改投存活候选(改投 hop 抛出的 Gone 会逐出该候选并试下一个,而不消耗失败预算或过早回退——Gone 集以有限跟随者数约束循环),完整快照下无候选 → 评论事件回退到 `handleInbound`,watch 事件记日志退役;partial/失败刷新下评论回退按背压推迟,直到完整快照确立缺席(关键决策 11)。队满与暂不可用的会话/workspace 是背压而非失败:不消耗重试预算,busy 或 draining 的会话因此推迟事件而不是在 `MAX_INBOUND_TASK_ATTEMPTS = 3` 后丢失它。背压有界(`MAX_BOUND_DEFERRALS = 240`,默认轮询下约 4 小时):超出视界的事件退役到私有死信日志(`github-bound-deadletter.jsonl`)并打 stderr 告警——被显式呈现,绝不静默消费。死信追加本身失败时任务被保留(payload 打到 stderr,持久化记录存活到下一轮重试或操作者恢复)——在那里移除任务会让一行日志成为事件仅存的痕迹。纯背压——包括不完整快照产生的无目标推迟——不持有共享通知游标(一个卡住的会话绝不能冻结所有无关 PR 的 mark-as-read);其他可恢复任务仍然持有它(reconciliation 被阻塞期间的 watch 任务除外:它们无法推进,也不依赖通知游标)。非背压的 bound 失败耗尽 3 次上限后同样退役到死信日志(私有路径的耗尽呈现面——它不发公开错误评论)。受理在任务移除前持久化(`deliveredAt`);该标记是优化而非重放守卫:每次注入都携带稳定的事件身份(源 chat + message id)作为准入侧 `idempotencyKey`,因此受理窗口内的崩溃或写失败(或丢失的 202)会以相同的键重放 POST——即使恢复以新 id 重建任务——daemon 将其去重为原受理结果(关键决策 10)。崩溃循环每次恢复捡回计一次尝试,因此会终止。重启后的恢复尊重持久化的 `delivery` 字段,并对照当前映射重新解析无目标的背压推迟。
|
||||
|
||||
注入的 prompt 复用 webhook 任务的 prompt 形状(`buildChannelWebhookPrompt` 风格):事件头、显式的「不可信事件数据」警告、PR 身份(repo、number、标题、URL)、该 lane 的 `Trigger:`/`Event:` 行及 direct lane 的事实性 `Author:` 详情、评论作者/正文或事件详情,以及「回复留在本会话」的指引(该会话不受 channel publication contract 约束;如需在 GitHub 上动作,用它自己的常规工具)。mention 与 first-contact lane 不提供 `Author:` metadata 行,因此 prompt 回退到信封的发送者名——会话必须能区分维护者的指令与路人的评论。面向 channel 的发布措辞绝不进入绑定会话:metadata 里的 publication instructions 被丢弃,聚合 lane 的「output exactly `<no-reply/>`」前缀被替换为普通的「New comments on the PR」头。**任何尾部都不被静默消费**:评论内容上限 6,000 码点,切片之外追加显式的 `[prompt truncated: N more characters omitted]` 提示;聚合批次在 20 条上限处丢弃较旧评论时前置一条省略提示(放在最前,码点上限因此不会截掉提示本身);每条聚合评论的 400 字符展示上限也带自己的 `[comment truncated: N more characters]` 标记——这些评论都已记入 `dispatchedComments`,任何一级的静默截断都会让它们从所有呈现面消失而 prompt 读起来却像完整的。
|
||||
|
||||
#### PR 状态监视
|
||||
|
||||
`prWatchEvents` 非空时,`pollOnce` 先对持久化 pending 事件做 reconciliation,再在绑定刷新之后、通用 inbound 恢复与通知处理之前运行监视检测回合:
|
||||
|
||||
1. **监视集合**:来自同一次绑定拉取的绑定 PR,过滤为主机匹配 channel 的 GitHub 主机,活跃轮询上限 30 个(按绑定更新时间取最新)。绑定 PR 超过 30 个时,溢出打 stderr 日志(数量 + 被排除的键),最旧绑定被确定性排除因此被显式呈现,绝不静默。被独立刷新记为 `merged`/`closed` 的绑定仍保留轮询,直到本轮询自己观测到终态迁移(`prState: 'closed'`),刷新抢跑因此不会漏掉 `state_change`。观测到终态后,终态标记在绑定仍列出该 PR 期间保留但不再轮询——剪掉它会让下一轮询把已关闭 PR 当作首次观测重新加入(查询振荡)。绑定保留与活跃轮询是两件事。无绑定 PR → 零 API 调用。持久化条目按完整候选集剪枝(而非截断后的监视集,轮转出上限的 PR 因此保留基线),仅在快照完整时剪枝——partial 快照绝不剪枝——且条目仍持有未投递 pending 事件时绝不删除。由于永久 partial 从不剪枝,保留另有上限:至多保留 1024 个未被列出的会话与 1024 个空闲 watch 条目(最旧先逐出,两个上限都打 stderr 呈现),永久不可扫描的 workspace 因此无法让适配器状态无界增长。watch 条目逐出另外跳过仍被保留绑定声明、或仍被在途 watch 任务拥有的条目;被逐出条目的 PR 没有投递目标,后续重新绑定像任何首次观测一样静默重建基线,因此逐出在界定内存的同时不丢失可投递事件。被丢弃会话的未匹配事件走推迟(被逐出的绑定者未被列出,缺席不具权威),在视界尽头呈现于死信。
|
||||
2. **每 PR**:每次轮询调 `pulls.get`(head SHA、`mergeable`、`mergeable_state`、`state`、`updated_at`)。Check runs(`checks.listForRef`)与组合提交状态(`repos.getCombinedStatusForRef`)仅在订阅了 `ci_failure` 且以下情况拉取:head SHA 较上次轮询变化、尚无 CI 观测、上次 CI 聚合为 pending 且已超过 5 分钟的 pending 复查间隔(`CI_PENDING_RECHECK_INTERVAL_MS`)、PR 的 `updated_at` 前进、聚合为 `none` 且处于 head 首次观测后的 10 分钟宽限窗口内(check 异步注册)、或上次 CI 拉取已超过 10 分钟的 settled-head 复查间隔。pending 间隔在另一个方向上承重:等待环境审批的 workflow 或离线 runner 可能 pending 数小时,无界的每轮询重拉会把 settled-head 成本模型变成监视上限下约 3,600 次 CI 调用/小时——算上 `pulls.get` 就超预算。周期复查同样承重:GitHub 不会因 check 活动 bump PR 的 `updated_at`(已对活体 API 核实),因此已 settled 的 head 复跑变红——或手动 `workflow_dispatch` 晚到的 CI——否则会被永久漏掉。稳态成本为每 PR 每轮询一次调用加有界的周期复查。CI 拉取失败被隔离在自己的 try/catch 里:CI 是 `pulls.get` 基线的条件性补充,因此 Checks 端点的持久失败(经典 PAT 无 Checks 权限、企业策略)记日志后继续、不写入任何 CI 状态——`merge_conflict`/`state_change` 仍从已到达的数据触发,最后已知聚合保持(写 `none` 会在端点恢复时重放 `ci_failure`)。监视 API 调用对重试无法修复的条件快速失败——凭据失败(401)、任何限流(403/429;GitHub 对二级/滥用限流并不总带 `retry-after` 头)与永久性的 per-PR 状态(404/410/451——PR 已删除、已转移或仓库不可用)——不做阻塞 cooldown sleep。共享条件(401/403/429)在失败的 PR 处中止整个回合:遍历其余集合只会在整个重置窗口内对每个被监视 PR 每轮询发一次注定失败的请求;per-PR 永久状态保持隔离,一个被删 PR 不会停掉其他 PR。快速失败谓词是监视局部的,发布车道保留自己的重试语义,通知车道在同一轮询照常运行。
|
||||
3. **迁移检测**:对照持久化监视状态(`channelFilePath('github-pr-watch.json')`,完整快照时按完整候选集剪枝——见第 1 条):
|
||||
- `ci_failure`:聚合转为失败(任一已完成 check run 结论为 `failure`/`timed_out`/`action_required`/`startup_failure`,或非空组合状态报告 `failure`/`error`)。空组合状态(`total_count: 0`,GitHub 对无 legacy status 的 SHA 返回 `state: "pending"`)绝不算活跃 CI。经持久化 `fired` 环每个 head SHA 只触发一次,即使聚合回绿后再次转红;新 head 再失败会再触发。每条目 `ciFiredSha` 钉作为环的后盾:有上限的环在长寿命 head 上经过 64 个非 ci episode 后可能逐出该 `ci_failure` 每-head 键,钉即使在该逐出后仍保住「每 head 一次」的保证(head 变化时重置)。事件摘要点名失败的 check(至多 5 个),会话无需再查即可行动。
|
||||
- `merge_conflict`:`mergeable_state` 进入 `dirty`。边沿触发;`dirty → clean → dirty` 会再次触发。`mergeable === null`(GitHub 仍在计算)绝不覆盖最后已知状态。
|
||||
- `head_change`:head SHA 与上次观测不同(首次观测静默初始化)。force-push 回到近期 SHA 是新迁移,会再次触发。
|
||||
- `state_change`:PR `state` 离开 `open`(merged 或 closed)。首次观测静默初始化;close → reopen → close 会再次触发。
|
||||
- **去重键身份**:每个 watch 键都嵌入 PR 编号与检测时间戳(`watch:<type>:<pr>:<seq>:<detectedAt>`;`watch:ci_failure:<pr>:<sha>:<detectedAt>`)。嵌入 PR 编号是因为投递幂等键只含 chat + message id,而同仓库两个 PR 绑定同一会话时各自并行递增 episode 计数。时间戳让迁移实例在计数重置(剪枝、损坏恢复)后依然唯一:daemon 的幂等环比 worker 长寿,重新发出的裸计数键会去重到旧受理上、静默吞掉新迁移。`ci_failure` 仍每 head SHA 一次——fired 环与 pending 队列按 PR+SHA 前缀匹配其键——其余三类以 episode 计数为键,标识迁移实例而非状态值,因此上述合法复发绝不会被 fired 环压制。计数与基线一同持久化。会超出 prompt 路由 200 字符上限的事件键改用同一身份的确定性哈希(`github:h:<sha256-40>`),重试因此仍能去重,而不是把失败预算烧在 400 上。watch 身份按设计是实例局部的:监视同一仓库的两个 channel 配置各自投递自己的 watch 通知(每订阅 at-least-once)——daemon 环去重的是同一次投递的重试,而非独立订阅;评论事件仍凭稳定的 GitHub id 跨实例去重。
|
||||
4. **持久化事件队列**:检测到的事件追加进 `pending` 队列,与新基线同一次写入持久化,检测与认领之间崩溃不会丢事件。reconciliation 回合在通用 inbound 恢复之前、独立于 live 监视回合排空所有 pending 条目(不调 GitHub API):失败或被限流的 API 回合不能饿死已检测事件,恢复也不会先投递并移除一个随后排空会重复认领的任务(认领与 pending→fired 落盘之间崩溃会留下两条记录)。**reconciliation 本身失败时**(如 watch 文件不可读),通用恢复完全跳过 watch 任务直到某次 reconciliation 成功——在 pending 记录尚未对账时投递并移除已受理的 watch 任务,会让随后的排空把同一迁移再认领投递一次;无关的评论恢复照常运行。被阻塞的 watch 任务同时不再持有共享通知游标:reconciliation 失败期间它们无法推进,而 watch 事件来自 PR 状态检测、不依赖通知游标,为它们冻结 mark-as-read 只会让所有无关 PR 停摆到 sidecar 恢复;reconciliation 成功后它们重新持有游标,直到有界重试使其结清。随后逐事件排空:对照最新完整快照重新校验收件人 → 认领 inbound task(持久化——复发的 WATCH 键命中的终态 failed/cancelled 记录会被替换,绝不静默复用;评论/事件/issue-body 键是永不复发的稳定标识,其终态记录保持永久去重屏障——替换它们会在崩溃使游标滞后于取消时复活已取消的评论)→ 把 key 从 `pending` 移入有上限的 `fired` 环(持久化)→ 执行任务。任何点崩溃都留下 pending 事件或已认领任务;`fired` 环是持久化的「每迁移一次」记录(仅靠任务级去重,聚合抖动会在已完成任务被移除后重放同一 head)。认领失败则事件留队下轮重试。drain 在首个投递失败处停下,避免较晚事件跳到较早的重试事件之前(按 PR 保序);pending 与 fired 已持久化,停下不丢任何东西。保序跨 pass 同样成立:同 PR 较早的 watch 迁移仍有未 settle 的任务时,整个条目的 drain 被跳过(watch pass 在 reconcile 之后会再次 drain,而下一 poll 的 reconcile 先于 recovery)——键等于 pending 头部的任务是该事件自己的崩溃窗口认领,正常 drain。认领持久化失败向上传播出 drain,使 reconcile 失败、恢复守卫保持。每条目 pending 队列上限 64:持续失败的认领无法让它无界增长;溢出把最旧的排队事件退役进死信,死信写失败则留队等待下次检测。认领成功之后 pending→fired 落盘失败则回滚认领(移除任务、恢复 pending 事件)下轮重试,watch 文件不可写 / 任务文件可写的分歧因此不会在重启后二次投递同一迁移。事件任务使用 `source.messageId = watch:<eventType>:<pr>:<headSha|seq>:<detectedAt>`,与评论一样私密投递——但没有公开回退(见评论投递拦截)。
|
||||
5. **绑定状态维护**仍由既有 5 分钟刷新定时器负责;适配器不 PATCH metadata。定时器记为 `merged`/`closed` 且监视已观测到终态迁移后,该 PR 离开活跃轮询;其终态标记保留到绑定本身消失。
|
||||
6. **持久化失败语义**:瞬态读错误(EACCES/EMFILE/EIO)fail-CLOSED——跳过监视回合且不保存,直到状态重新加载成功,降级的内存态因此绝不能覆盖完好的文件。只有 ENOENT(首次启动)与持久 JSON 损坏才从空状态起步;语法合法但个别条目畸形的文件就地修复——每个坏条目被丢弃(打一行 stderr),健康条目保留,因此一个损坏值不会饿死其后续条目的 API 无关排空。逐条目形状校验用与任务存储认领时相同的 envelope 与 delivery 校验器校验 pending 事件:缺少 `chatId` 的形状合法信封否则会在绑定键查找处抛错并中止整个 reconcile 循环,部分信封则会被原样认领、在下次任务文件读取时被拒绝、把存储楔住直到操作者删除文件。失败的原子写清理其 UUID 命名的 tmp 文件,持续磁盘压力因此不会累积孤儿。状态文件与目录权限为 0600/0700(它们持久化私密评论信封)。
|
||||
|
||||
#### API 预算
|
||||
|
||||
默认 60 秒轮询、上限 30 个被监视 PR:稳态 ≤ 1 800 次/小时,加周期性的 settled-head 复查(每 PR 每 10 分钟间隔 ≤ 2 次调用 → ≤ 360 次/小时)与有界的 pending 复查(每 PR 每 5 分钟间隔 ≤ 2 次调用 → 全部被监视 PR 都 pending 的最坏情况 ≤ 720 次/小时)。CI 活跃期每个受影响 PR 每次复查增加 1 次组合状态调用加 `ceil(checkRuns/100)` 页 check 列表——300 个 check 的 monorepo head 花费 4 次调用,大 CI 突发仍可能超出 5 000 次/小时的 token 预算;监视随后在共享的限流/凭据失败处中止回合(一次注定失败的调用,而非每 PR 一次)、下轮重试而非阻塞。通知轮询开销不变。绑定拉取失败时适配器记录日志并跳过监视回合(通知仍正常流动)。
|
||||
|
||||
## 关键决策
|
||||
|
||||
1. **prompt 注入,而非 channel 接管。** `activateManagedSession` 会把跟进会话的出站目标绑到 GitHub 线程,交互式会话的每条最终回复都会被发布为公开 PR 评论(publication contract)。注入让回复留在会话内,GitHub 动作交给会话自己的判断与工具。
|
||||
2. **channel 内状态监视,而非 webhook。** 监视复用 channel 既有轮询循环与凭据;判定发生在 API 层,真实迁移出现前零模型 token。Webhook 推送需要公网隧道加在仓库建 webhook(需操作者同意),还要改造静态 webhook targets。监视本质仍是轮询,但它是维护者接受的 gate 探针形状:模型之外做廉价判定,有变化才动模型。
|
||||
3. **反查放在 daemon。** 绑定跨 workspace;worker 是 workspace 作用域且已经通过鉴权 HTTP 与 daemon 通信。只读进程级路由复用刷新定时器的扫描模式,避免在 worker 里重复 sidecar 访问。
|
||||
4. **单候选,取最近更新。** 广播到所有绑定会话会成倍放大模型成本并产生重复的 GitHub 动作;最新的非归档、非 channel 会话最可能持有工作上下文。
|
||||
5. **候选排除 channel 会话。** 经 `/review` 回填绑定的 channel 自建会话必须保持公开回复契约;正常 channel 路径已经服务它。
|
||||
6. **默认 opt-in。** 公开仓库事件驱动交互式会话是新的信任面;操作者显式开启,且评论的 sender gate 在投递前照常生效。
|
||||
7. **唤醒休眠会话,而非回退。** E2E 发现 daemon 重启后绑定会话处于休眠态、prompt 路由 404;若把这当作「会话已消失」,重启后的所有事件都会静默改走公开 channel 投递——违背功能的核心私密投递承诺。因此访问器先恢复会话(channel bridge 已在用的同款 resume 路径)再重试一次;只有无法恢复的会话才回退。恢复铸造的 client 注册由访问器在重试 prompt 落定后显式 detach:非空注册会挡住 daemon 的 prompt-settled idle close,不 detach 就会把被唤醒的会话钉在 live 态直到 TTL reaper(reaper 禁用时则永久)。
|
||||
8. **watch 事件私有-only。** 评论有既有的公开路径可回退;watch 事件没有。把 watch 事件回退到 channel 会话会把面向操作者的机器状态(「CI failed on head X. Inspect the failed checks…」)发布为公开 PR 评论,并被 sender allowlist 重新 gating。维护者决策(2026-11-02,解决 triage/验证升级):没有绑定目标的 watch 事件记日志并退役,绝不发布。退役被显式呈现(stderr + 任务移除),不是静默丢弃。
|
||||
9. **有界背压加死信呈现面。** 队满与暂不可用条件推迟事件而不消耗失败预算,但推迟视界有界(约 4 小时),且纯背压——bound 投递与不完整快照产生的无目标推迟——不持有共享通知游标:一个卡住的会话绝不能冻结所有无关 PR 的 mark-as-read,一个永久卡住的会话也绝不能永久楔住任务。耗尽视界或失败预算的事件退役到私有死信日志:私有路径不发公开错误评论,死信就是它的耗尽呈现面。死信写失败时保留任务而非移除。
|
||||
10. **准入侧幂等,而非更大的受理窗口。** 持久化 `deliveredAt` 标记只能保护它落盘之后的崩溃;受理写本身可能失败(EIO),202 也可能丢失,而 daemon 对每个请求铸造新的 prompt id,重试因此会重复注入同一事件。把标记移到 POST 之前则是用静默丢失换重复。因此 POST 携带稳定的事件身份(源 chat + message id)作为 `idempotencyKey`——跨任务重建稳定,任务级键则做不到——prompt 路由按会话去重已受理的键(有界内存环,每会话 128 键、1024 会话,FIFO):重试返回原受理结果而不排队。环的寿命与内存 prompt 队列相同——daemon 重启两者一起丢失,那里的重新注入正是正确的恢复。两个上限也都可能在队列仍存活时逐出键——单个长寿命会话受理超过 128 个不同事件时是每会话上限(常态),超过 1024 个会话持有键时是会话上限——这种逐出之后的重试会重新注入。标记保留为快速路径:无需往返即退役任务。聚合评论批次的 message id 是排序后成员 id 的内容哈希(`agg:<sha256-40>`),而非首条评论:游标保存前崩溃可能重列出超集批次,首评论键会让 daemon 环把长大的批次去重到原受理上、静默丢弃新成员;相同批次仍跨受理崩溃窗口去重。游标的 `dispatchedComments` 环以**窗口感知逐出**支撑这一点:每个键携带其评论的 `created_at`(`dispatchedCommentTimes`,在 fetch 时捕获),超过软上限(500)后逐出只丢弃评论已不可再重列的键(`created_at <=` 已提交游标)——仍可重列的键(或无记录时间的 legacy 键)受保护,因此冻结游标无法把已回复评论滑出环、让重列超集以新哈希重新发布它。**刻意不设硬上限拒绝**:不冻结游标而拒绝派发会把被拒事件 mark-read(静默丢失),冻结游标又会死锁(环只有在游标推进后才能收缩)。代价是*持续* mark-read 故障会让保护环随重列窗口增长(受 流量 × 故障时长 界定);mark-read 恢复后即收缩。恢复会重录仍挂起任务的键(恢复去重);无捕获时间的重录保持受保护,即保守方向。辅助时间映射自身有界(`MAX_COMMENT_TIMES = 8192`,最旧非环条目优先丢弃,环键时间始终保留),并在每轮包裹 mark-read 提交的 `finally` 中 prune——成功提交后对照**已推进**的游标、失败提交后对照**冻结**的游标——因此零派发的冻结游标(sender 拒绝流、或 mark-read 本身失败)无法使其无界增长。publication audit 的消费判定按 source key 取最后一条 outcome:`posting` 之后的终态 `failed`(可证明未写入)让事件保持可重试,而歧义失败(`failed_ambiguous`——写入可能已落地)与无终态记录的 `posting`(发布中崩溃)按 at-most-once 消费,因为第二条公开回复不可逆;audit 不可读时冻结本轮游标,而不是把事件跳过 mark-read,且每个 dispatch lane 在记录 dedupe 键前都尊重该拒绝。审计**写入**则尽力而为:不可写的审计存储不阻断公开回复(写满或损坏的审计文件绝不能破坏回复契约——由「keeps successful publication when its audit write fails」测试钉住),因此横跨 `posting` 记录、POST 本身与任务移除的存储故障可能不留任何持久消费意图,让随后的恢复重新发布。完全存储故障下根本无法记录持久意图,故写路径以严格 at-most-once 换回复可用性,读路径仍 fail-closed;把 `posting` 写改为 fail-closed(write-ahead)可恢复 at-most-once,代价是审计存储不可写时阻断回复。冻结在 **dispatch 时**强制:只含 watch 任务的恢复回合两个公共存储都不读(watch 事件从不发布,既不会匹配 pending delivery 也不会匹配 audit 键),audit 不可读时私有 watch retire 仍能完成——下一个公共 dispatch 依旧拒绝并冻结。
|
||||
11. **不完整扫描绝不确立缺席。** partial 绑定答案——尤其是重启后的空答案——不是 PR 未绑定的证据,而公开路由是不可逆的(游标在同一轮询提交)。因此冷启动门对空 partial 答案推迟有界轮数,且只要缺席不具权威,未匹配的 PR 事件就逐事件推迟(可恢复,视界尽头死信)而非走公开路径——首个完整快照之前,以及任何 partial 答案或刷新失败期间。partial 答案即使每个已观测绑定者都被列出并排除该 PR 也无法证明缺席:不可扫描的 workspace 可能持有从未被观测的跟随者,而被列出的 tombstone 只证明该被列出会话解绑(它仍立即停止私密投递与监视)。已知目标立即投递。issue 事件豁免整个**逐事件**缺席门(含其 `!boundSnapshotTrusted` 冷启动子句):绑定仅针对 PR,且 GitHub 的 issue/PR 编号空间互斥,issue 事件绝不可能有绑定目标——其缺席无需任何快照即可证明,车道开启后绝不能因降级的绑定扫描而停摆(或死信),包括完整快照永不落地的永久 partial。车道级冷启动门是另一道门,仍像扣住其他所有通知一样扣住 issue 事件,直到首个成功绑定拉取(见上文冷启动条)。这些界限让永久降级的部署不至于冻结:车道在上限处开启,PR 事件呈现在死信里而非消失。
|
||||
|
||||
## 影响文件
|
||||
|
||||
| 层 | 文件 | 改动 |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| core 类型 | `packages/core/src/services/session-pr-service.ts` | 新增 `readSessionPrsDetailed`(区分 sidecar 缺席与非法);`readSessionPrs` 委托实现,契约不变 |
|
||||
| daemon 路由 | `packages/cli/src/serve/routes/session-pr-bindings.ts`(新增)+ 注册 | `GET /sessions/pr-bindings` 扫描路由:空 prs tombstone、`prsReadFailed` → partial |
|
||||
| daemon 投影 | `packages/cli/src/serve/server/session-list.ts`、`packages/acp-bridge/src/bridgeTypes.ts` | sidecar 补全区分不可读 sidecar(`BridgeSessionSummary` 上的 `prsReadFailed`)与缺席 |
|
||||
| daemon 路由 | `packages/cli/src/serve/routes/session.ts` | restore 路由:对永久性 workspace 缺席返回可区分的 `workspace_not_found` 404;prompt 路由:relay 标记限定的跨 workspace worker 授权 + 准入侧 `idempotencyKey` 去重 |
|
||||
| daemon 准入 | `packages/cli/src/serve/channel-worker-prompt-authorization.ts` | 进程级中继谓词 `isChannelWorkerPromptToken` + 跨 workspace 分类所需的显式 `CHANNEL_BOUND_RELAY_META_KEY` 意图标记 |
|
||||
| bridge 准入 | `packages/acp-bridge/src/session-control-plane.ts` | 三处 prompt 准入失败(会话不存在、会话关闭中、保留 standalone 工作目录守卫)由 `return Promise.reject(...)` 改为同步 `throw`,路由因此绝不会返回幻影 202(关键决策 10) |
|
||||
| channel base | `packages/channels/base/src/ChannelBase.ts`、`types.ts`、`bound-session-delivery.ts`(新增)、`index.ts` | 可选 `boundSessions` 选项 + `PrBindingSummary` 类型 + 共享投递错误类(gone / queue-full / unavailable) |
|
||||
| channel base | `packages/channels/base/src/PollingChannelBase.ts` | poll 错误路径对错误文本做净化(`sanitizeLogText`,200 码点上限),stderr 行与记录的 channel 事件两处都生效——所有轮询 channel 的共享基类 |
|
||||
| worker | `packages/cli/src/commands/channel/daemon-worker.ts` | 用 SDK client 实现访问器 |
|
||||
| 适配器 | `packages/channels/github/src/GithubAdapter.ts`、`index.ts` | 配置字段、选择映射、投递拦截、状态监视、监视状态持久化 |
|
||||
| 文档 | `docs/users/features/channels/github.md`、`docs/developers/qwen-serve-protocol.md` | 记录两个字段、事件语义、限制、两种停摆状态、restore 的 404/400 划分与 `idempotencyKey` |
|
||||
| web shell | `packages/web-shell/client/components/channels/ChannelEditorDialog.tsx`、`client/i18n.tsx` | 两个新描述符字段的标签/说明键(中英双语) |
|
||||
| 测试 | 各改动文件的 collocated `.test.ts`(`session-control-plane.ts` 除外,其覆盖在 `packages/acp-bridge/src/bridge.test.ts`) | 见验证 |
|
||||
|
||||
## 约束与风险
|
||||
|
||||
- **不可信内容进入交互式会话。** 事件 payload 在注入 prompt 中标记为不可信(与 webhook 任务同一契约)。评论 sender gate 照常生效。状态事件没有人类发送者;它们存在仅因为操作者 opt-in,且私有-only(无公开回退)。目标会话自己的审批模式约束其动作。
|
||||
- **进程级中继凭据(放宽的 worker 授权)。** 绑定投递跨 workspace,prompt 路由因此无法对 worker token 保持旧的 home===target 相等性。放宽被限定于其存在理由:跨 workspace 的 channel 分类除已注册 worker token 与 primary/受信目标外,还要求显式的 `qwen.daemon.channelBoundRelay` 标记;携带外部 worker token 的普通 prompt 不获得 channel 分类,也不获得调用方提供的展示投影。残余风险明说:标记是意图而非认证——持有 workspace B 的 worker token 者可以设置它并中继进受信的 workspace A,使该回合豁免 loop-detected 拒绝并提供其展示文本。安全边界仍是 token 持有加目标信任(与绑定路由枚举的信任模型相同);一个同时服务内部交互 workspace 与「channel 摄入攻击者可控文本」workspace 的 daemon,把该 channel 的触手延伸进了受信 workspace 的会话。不能接受这一点的操作者不应在同一 daemon 上注册 channel worker,或应让摄入 workspace 保持 untrusted(untrusted 非 primary workspace 本就被绑定扫描与 relay 门同时排除)。
|
||||
- **队列压力。** 事件突发可能填满目标会话的 5 槽 prompt 队列;队满与暂不可用条件按背压处理(不消耗重试预算),投递在后续轮询重试。推迟视界有界(`MAX_BOUND_DEFERRALS`,约 4 小时);超出视界——或非背压失败耗尽 3 次上限——事件退役到私有死信日志并打 stderr 告警。纯 bound 背压不持有共享通知游标;其他可恢复任务仍然持有它(reconciliation 被阻塞期间的 watch 任务除外:它们无法推进,也不依赖通知游标)。bound 事件绝不静默消费:要么投递、要么推迟、要么死信。
|
||||
- **速率限制。** 监视上限、条件化 check 拉取与有界的周期/pending 复查把稳态压在 5 000 次/小时的 token 预算之内(大 CI 突发可能超出;见 API 预算)。共享的凭据/限流失败(401/403/429)中止整个监视回合并记日志——一次注定失败的调用,而非每被监视 PR 一次——通知不受影响,而不是在通知车道之前睡完重置窗口。daemon 自身的 prompt 档 429 对投递是背压(推迟,绝不消耗失败预算)。
|
||||
- **过期绑定。** 休眠的绑定会话由访问器唤醒(第 2 层),daemon 重启不会把事件改道公开 channel 投递;冷启动门在首个成功快照填充绑定映射之前推迟评论车道,空 partial 答案让它在有界轮数内保持关闭(关键决策 11)。只要缺席不具权威(尚无完整快照,或任何 partial 答案或刷新失败),未匹配的 PR 事件就可恢复地推迟而非公开路由;issue 事件豁免此逐事件门——绑定仅针对 PR,其缺席无需快照(车道级冷启动门仍像扣住其他所有通知一样扣住它们,直到首个成功拉取)。会话真正已删除的绑定唤醒失败 → 评论事件回退 channel 路径,watch 事件记日志退役;下一轮询的绑定拉取自愈映射。partial/失败扫描下评论回退按背压推迟,直到完整快照确立缺席——不可扫描的 workspace 可能仍持有从未被观测的跟随者,而公开回复不可逆。观测到的解绑(被扫描会话以空 PR 集列出)即使在 partial 答案下也停止私密投递与监视——tombstone 行使「已扫描、无绑定」与「未枚举」可区分——而该 PR 的公开路由仍等待完整快照。
|
||||
- **已关闭的会话。** `DELETE /session/:id` 只关闭会话、不删除记录;记录仍可 resume 且绑定仍在,因此这类会话仍会被唤醒投递(E2E 已实证)。只有删除记录(`POST /sessions/delete`)或归档才会把会话移出候选集(归档会话的 restore 返回 409 `session_archived`,投递分类为 gone)。关闭是资源动作,不是退订;绑定本身表达跟随意图。
|
||||
- **扫描成本。** 路由每次调用扫 sidecar;当前规模下是毫秒级。多页扫描标记 partial(活动序分页非快照隔离),使调用方保留已知绑定而非剪枝——这是刻意的保守取舍。若规模增长,快照一致枚举或用 `session_metadata_updated` 失效的内存索引是后续工作,不属于本改动。
|
||||
|
||||
## 验证计划
|
||||
|
||||
- **单测**:路由扫描(trusted/untrusted、归档排除、sidecar 损坏容忍);适配器选择(主机过滤、状态过滤、channel-source 排除、更新时间 tie-break);投递拦截(绑定 → 注入、未绑定 → channel 路径、404 回退、队满重试、恢复尊重持久化 delivery);监视迁移(各事件类型、按 head 去重、null-mergeable 守卫、首次观测静默、先写状态再投递的顺序);配置校验。
|
||||
- **E2E**(daemon + 本地构建):经 PATCH metadata 把 PR 绑定到会话;用 stub 的 GitHub API 层模拟事件;断言绑定会话每个迁移恰好收到一条注入 prompt、回复留在会话内、未绑定 PR 仍创建 channel 会话。计划详见 `.qwen/e2e-tests/github-channel-bound-session-events.md`。
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 两个字段开启后,绑定 PR 上的新评论以 prompt 形式进入绑定会话(而非 channel 会话),且会话回复不自动发布到 GitHub。
|
||||
2. 绑定 PR head 的 CI 失败、`mergeable_state: dirty` 迁移、head 前进、合入/关闭,每个迁移实例恰好产生一条注入 prompt,daemon 重启后不重放;合法复发(dirty → clean → dirty、close → reopen → close、`head_change` 的 SHA 回访、新 head 失败)会再次触发,已 settled head 的 check 复跑变红由周期复查捕获。`ci_failure` 在 fired 环窗口内每个 head SHA 只触发一次——被回访的 SHA 的 CI 失败在该 head 的键仍留在环内时被抑制,回访本身由 `head_change` 承载。
|
||||
3. 完整快照下无绑定会话时,评论事件行为与今天完全一致(创建/复用 channel 会话,公开回复契约不变);partial 快照或刷新失败期间回退推迟,直到完整快照确立缺席(关键决策 11);watch 事件记日志退役,绝不发布。
|
||||
4. 字段关闭(默认)时,行为与 API 调用量不变。
|
||||
5. 队满、限流(429)与暂不可用路径推迟事件、不丢失、不冻结共享游标;会话消失的评论路径无丢失回退;watch 路径私密退役;耗尽事件进死信(死信写失败时保留任务);daemon 生命周期内任何崩溃窗口都不重放已受理的注入——受理标记丢失或 202 丢失后的重试携带相同幂等键、去重为原受理结果;去重在两个 FIFO 上限(每会话 128 键、1024 会话)下尽力而为,环与内存 prompt 队列同生共死,重启后重新注入正是正确的恢复(关键决策 10)——也不静默消费迁移;空 partial 冷启动快照绝不公开路由事件,观测到的解绑即使在 partial 答案下也停止私密投递。
|
||||
|
||||
## 开放问题
|
||||
|
||||
无。Webhook 推送投递与内存绑定索引记录为后续工作,不构成阻塞。
|
||||
@@ -3041,9 +3041,9 @@ Request:
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Notes |
|
||||
| ----- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `cwd` | no | Same canonicalization + `workspace_mismatch` rules as `POST /session`. Omit to inherit `/capabilities.workspaceCwd`. When `features` contains `multi_workspace_sessions`, callers may pass any trusted registered `workspaces[].cwd`; untrusted non-primary workspaces return `403 untrusted_workspace`. `mcpServers` is intentionally NOT accepted here — daemon-wide MCP is settings-driven (matches `POST /session`). |
|
||||
| Field | Required | Notes |
|
||||
| ----- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `cwd` | no | Same canonicalization as `POST /session`, with a stricter absence contract for restore: an explicit `cwd` with NO registry entry (removed, or never registered) returns `404 workspace_not_found` — a permanent absence callers may classify as gone — while a workspace whose entry exists in a reversible state (draining, transitioning, blocked) still returns `400 workspace_mismatch`. Omit to inherit `/capabilities.workspaceCwd`. When `features` contains `multi_workspace_sessions`, callers may pass any trusted registered `workspaces[].cwd`; untrusted non-primary workspaces return `403 untrusted_workspace`. `mcpServers` is intentionally NOT accepted here — daemon-wide MCP is settings-driven (matches `POST /session`). |
|
||||
|
||||
Response:
|
||||
|
||||
@@ -3073,7 +3073,8 @@ The replay-window byte caps apply after the child has reconstructed the persiste
|
||||
**Errors:**
|
||||
|
||||
- `404` — persisted session id doesn't exist (`SessionNotFoundError`).
|
||||
- `400` — `workspace_mismatch` (same shape as `POST /session`).
|
||||
- `404` — `workspace_not_found` when an explicit `cwd` has no registry entry at all (removed, or never registered): a PERMANENT absence, distinct from the reversible states below. Body: `{ error, code: "workspace_not_found", boundWorkspace, requestedWorkspace }` — no `workspaceCount`. A client that read the old "404 means the session is gone" contract should treat this code as a routing failure it can route around (e.g. re-register the workspace), not as evidence the session was deleted.
|
||||
- `400` — `workspace_mismatch` (same shape as `POST /session`), including the reversible lifecycle states: a workspace whose registry entry exists but is draining, transitioning, or blocked. Retry once the workspace settles; do not classify it as gone.
|
||||
- `403` — `untrusted_workspace` when `cwd` targets an untrusted non-primary workspace.
|
||||
- `503` — `session_limit_exceeded` (counts against `--max-sessions`; in-flight restores are accounted for too).
|
||||
- `504` — `session_restore_timeout`; retryable, with a `Retry-After` derived from the restore budget (clamped to 5-120s) because the same session id stays fenced until late cleanup settles.
|
||||
@@ -3784,6 +3785,39 @@ send or publish a delivery result.
|
||||
|
||||
Validation: `prompt` must be a non-empty array of objects. Other failures return `400` before reaching the bridge.
|
||||
|
||||
`idempotencyKey` is an optional non-empty string of at most 200 characters
|
||||
(invalid values return `400 invalid_idempotency_key`). It identifies the
|
||||
caller's EVENT, not the HTTP request: the first admission remembers the key
|
||||
per session, and a repeat POST with the same key returns the original
|
||||
admission — `202 { promptId, lastEventId, eventEpoch, deduplicated: true }`
|
||||
with the FIRST prompt's `promptId` and a fresh cursor/epoch — without
|
||||
enqueueing a second turn. This lets a relay that marks acceptance durably
|
||||
after the `202` (or loses the response) retry safely. Dedupe is best-effort
|
||||
within a daemon's lifetime: the ring is in-memory and bounded (128 keys per
|
||||
session, 1024 sessions, FIFO eviction at both levels). Either cap can evict
|
||||
a key while its event's queue entry is still live — the per-session cap in
|
||||
the ordinary case of one long-lived session admitting more than 128 distinct
|
||||
events, the session cap when more than 1024 sessions hold keys — and a retry
|
||||
after such an eviction re-injects. The ring also dies with the in-memory
|
||||
prompt queue it protects, so a daemon restart loses both together and
|
||||
re-injection becomes the correct recovery. Keys are scoped per session; the
|
||||
field is admission metadata and is never forwarded to the agent.
|
||||
|
||||
Channel relays ride three `_meta` keys that the route strips before
|
||||
forwarding: `qwen.channel.prompt` (channel-turn classification),
|
||||
`qwen.daemon.channelBoundRelay: true` (cross-workspace bound-delivery
|
||||
intent), and `qwen.daemon.channelPromptAuthorization` (the channel-worker
|
||||
authorization token; the reference worker sends it only when it holds a
|
||||
credential). `qwen.channel.prompt` requires a registered channel-worker
|
||||
authorization token — the per-workspace registration for the target
|
||||
workspace, or, for a CROSS-workspace bound delivery, any registered worker
|
||||
token plus the explicit `qwen.daemon.channelBoundRelay: true` marker and a
|
||||
primary/trusted target. A relay that sends only the two markers without the
|
||||
credential gets no channel classification: it is admitted as an ordinary
|
||||
interactive prompt (loop-detected rejection, live PR-binding backfill on the
|
||||
relayed text). A plain prompt carrying a foreign worker token likewise gets
|
||||
no channel classification and no caller-supplied display projection.
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
@@ -3792,7 +3826,17 @@ Response:
|
||||
|
||||
The `202` response acknowledges admission, not Agent completion. Observe the
|
||||
session SSE stream after `lastEventId` and correlate `turn_complete` or
|
||||
`turn_error` by `promptId`.
|
||||
`turn_error` by `promptId`. Every reachable pre-enqueue admission check —
|
||||
including the reserved standalone working-directory guard — throws
|
||||
synchronously, so the route answers a non-202 error BEFORE any acceptance:
|
||||
the guard answers `409 working_directory_missing` (`retryable: true`,
|
||||
`Retry-After: 5`) while the session's managed conversation binding has not
|
||||
released yet, and a channel relay defers the event instead of consuming a
|
||||
phantom acceptance. Should a bridge still reject deferred before admission,
|
||||
the route drops the request's `idempotencyKey` so a subsequent retry is
|
||||
admitted fresh instead of deduping into a phantom admission — a
|
||||
defense-in-depth for that case, whose CURRENT delivery is already consumed
|
||||
by the caller.
|
||||
|
||||
`turn_complete.data.stopReason` carries the ACP `StopReason` the agent
|
||||
returned — `end_turn`, `max_tokens`, `max_turn_requests`, `refusal` or
|
||||
|
||||
@@ -80,15 +80,17 @@ Local `gh` authentication requires an HTTPS `baseUrl` so the daemon host credent
|
||||
|
||||
## Configuration Options
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `token` | unset | Optional classic PAT with `notifications` scope; overrides local `gh` authentication |
|
||||
| `useLocalGh` | `false` | Explicitly reuse the daemon host's account-wide GitHub CLI authentication |
|
||||
| `pollInterval` | `60000` | Poll interval in ms |
|
||||
| `baseUrl` | `https://api.github.com` | API base URL (for GHE) |
|
||||
| `groupPolicy` | `"disabled"` | Must be `"open"`, `"allowlist"` with the repo (`owner/repo`) listed in `groups`, or `"pairing"` with the repo approved for notifications to flow |
|
||||
| `groups.*.requireMention` | `true` | Require @mentions for ordinary comments; directed notification reasons still run |
|
||||
| `reasonFilter` | unset | Optional allowlist of GitHub notification reasons to process |
|
||||
| Option | Default | Description |
|
||||
| ------------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `token` | unset | Optional classic PAT with `notifications` scope; overrides local `gh` authentication |
|
||||
| `useLocalGh` | `false` | Explicitly reuse the daemon host's account-wide GitHub CLI authentication |
|
||||
| `pollInterval` | `60000` | Poll interval in ms |
|
||||
| `baseUrl` | `https://api.github.com` | API base URL (for GHE) |
|
||||
| `groupPolicy` | `"disabled"` | Must be `"open"`, `"allowlist"` with the repo (`owner/repo`) listed in `groups`, or `"pairing"` with the repo approved for notifications to flow |
|
||||
| `groups.*.requireMention` | `true` | Require @mentions for ordinary comments; directed notification reasons still run |
|
||||
| `reasonFilter` | unset | Optional allowlist of GitHub notification reasons to process |
|
||||
| `deliverToBoundSessions` | `false` | Deliver events for PRs bound to a live session into that session as a prompt instead of a channel-owned session (daemon-managed channels only) |
|
||||
| `prWatchEvents` | unset | Optional state transitions to watch for bound PRs: `ci_failure`, `merge_conflict`, `head_change`, `state_change`. Requires `deliverToBoundSessions` |
|
||||
|
||||
Use `reasonFilter` to drop noisy notification classes such as `ci_activity` or `state_change`. Do not use `reasonFilter: ["mention"]` as a replacement for `groups.*.requireMention`: GitHub's `mention` reason is sticky at the thread level, so real new @mentions can arrive later under `comment`, `subscribed`, `author`, or other reasons and would be skipped.
|
||||
|
||||
@@ -125,7 +127,7 @@ The adapter uses GitHub's Notifications API as a wake-up signal:
|
||||
|
||||
The comment window is `(previousCursor, currentMaxUpdatedAt]`. Accepted, running, and failed tasks are stored under `~/.qwen/channels/<workspace-scope>/` with private file permissions. On restart, the channel recovers those tasks before polling GitHub again. Failed tasks are attempted up to three times, then become terminal; cancelled tasks are terminal and are not rerun. A task whose final reply was already posted, suppressed, or queued for definite no-write retry is not rerun.
|
||||
|
||||
The notification cursor does not advance while recoverable tasks remain, or when inbound task state cannot be read or written. This prevents a crash or agent failure from losing an accepted comment and preserves the deduplication keys needed to avoid a second dispatch from the notification feed.
|
||||
The notification cursor does not advance while recoverable tasks remain, or when inbound task state cannot be read or written. This prevents a crash or agent failure from losing an accepted comment and preserves the deduplication keys needed to avoid a second dispatch from the notification feed. There are two deliberate exceptions. **Bound-session backpressure** (see below): a deferred bound delivery has its own durable retry and a bounded deferral horizon, so it does not hold the shared cursor — its notification is marked read while the event stays recoverable in the task store. **Watch tasks while pending-event reconciliation is blocked** (for example an unreadable watch state file): they cannot make progress until a reconciliation succeeds, and watch events are detected from PR state rather than notifications, so they leave the cursor free as well — unrelated comments keep flowing, and the watch events deliver exactly once after the state file recovers.
|
||||
|
||||
Non-comment activity (push, label changes) bumps the notification's `updated_at` but produces zero new comments in the window, so re-fetched threads are skipped without triggering the agent.
|
||||
|
||||
@@ -145,6 +147,94 @@ corresponding inbound task remains in `reply_pending` state until that delivery
|
||||
succeeds or reaches a definite terminal failure. Ambiguous delivery failures are
|
||||
not retried automatically because GitHub may have created the comment.
|
||||
|
||||
## PR-Bound Session Delivery
|
||||
|
||||
By default every event is handled by a channel-owned session. With
|
||||
`deliverToBoundSessions: true`, events for a PR that is bound to a live session
|
||||
(the PR bindings shown in the Web Shell sidebar, registered when a session
|
||||
creates or reviews a PR) are delivered into that session as a prompt instead,
|
||||
so the session that follows the PR handles the event with its existing context.
|
||||
|
||||
- The injected prompt marks all GitHub content as untrusted event data. The
|
||||
session's reply stays in the session; it is **not** published to GitHub
|
||||
automatically, and the session may act on GitHub through its own tools.
|
||||
- Comment sender gates still apply before delivery. When several sessions are
|
||||
bound to the same PR, the most recently updated non-archived session wins;
|
||||
channel-owned sessions are never candidates.
|
||||
- When no bound session exists (or it cannot be restored), **comment** events
|
||||
fall back to the normal channel path and its public-reply contract — on a
|
||||
complete binding snapshot; while the snapshot is partial or its refresh is
|
||||
failing, the fallback defers until a complete snapshot establishes that no
|
||||
follower exists. A bound
|
||||
session that is dormant after a daemon restart is woken automatically for
|
||||
the delivery, so restarts do not divert events into public replies.
|
||||
**Watch events (below) are private-only**: with no bound target they are
|
||||
logged and retired, never published as PR comments.
|
||||
- A full prompt queue, a rate-limited daemon (HTTP 429), or a temporarily
|
||||
unavailable session defers the event without consuming the retry budget;
|
||||
the deferral is bounded (240 consecutive deferrals — ~4 hours at the
|
||||
default 60s poll, and proportionally longer with a larger `pollInterval`),
|
||||
and events that exhaust it are retired to a private dead-letter log
|
||||
(`~/.qwen/channels/<workspace-scope>/<channel>-<name-hash>-github-bound-deadletter.jsonl`)
|
||||
with a stderr alert rather than silently dropped. If the dead-letter write
|
||||
itself fails, the task is kept (not removed) so the event stays recoverable.
|
||||
Deferred bound events do not hold the shared notification cursor: their
|
||||
notifications are marked read while the durable task keeps retrying.
|
||||
- **Two operator-visible states stop bound delivery from processing events.**
|
||||
(1) _Cold start_: with `deliverToBoundSessions` on, the comment lane waits
|
||||
for the first successful binding lookup. While the lookup has never
|
||||
succeeded — or keeps answering an **empty partial** snapshot (a draining or
|
||||
unreadable workspace) — `pollOnce` returns before fetching notifications, so
|
||||
no GitHub event is processed; a throttled stderr line names the
|
||||
state. The empty-partial deferral is bounded (30 consecutive polls): after
|
||||
that the lane opens, and events whose PR is still absent from the incomplete
|
||||
snapshot defer per-event (backpressure above) instead of taking the public
|
||||
path — an incomplete scan never establishes that a PR is unbound. That
|
||||
per-event deferral is PR-scoped: bindings are PR-only, so once the lane is
|
||||
open an issue event is never deferred by it (its absence needs no snapshot)
|
||||
and routes publicly even under a permanently partial scan. A permanently
|
||||
partial snapshot has a steady-state cause besides a degraded workspace: the
|
||||
binding scan reads at most 100 sessions per page and flags any multi-page
|
||||
scan partial, so a workspace with more than 100 active sessions never
|
||||
produces a complete snapshot. In that state a comment on a PR that no
|
||||
session follows defers per-event until the dead-letter horizon (~4 hours at
|
||||
the default poll) rather than falling back to the channel; issue events are
|
||||
unaffected, since the per-event absence gate is PR-scoped. The lane-level
|
||||
cold start above still holds every notification, issue events included,
|
||||
until the first successful binding lookup. Once a complete snapshot lands,
|
||||
deferred events are re-resolved: delivered privately to a follower, or
|
||||
publicly once the complete snapshot establishes there is none. (2) _Backpressure_: a deferred bound event keeps its own durable
|
||||
retry (previous bullet) while the rest of the channel continues normally.
|
||||
- The injected prompt names the comment author and marks truncation
|
||||
explicitly at every level: comment content is capped at 6,000 code points
|
||||
with an `[prompt truncated: N more characters omitted]` notice, an
|
||||
aggregate batch that drops older comments says so, and each aggregated
|
||||
comment's 400-character display cap carries its own
|
||||
`[comment truncated: N more characters]` marker — no tail is silently
|
||||
consumed.
|
||||
- This feature requires a daemon-managed channel; standalone
|
||||
`qwen channel start` ignores the setting with a warning.
|
||||
|
||||
With `prWatchEvents`, the channel additionally watches bound PRs for state
|
||||
transitions the Notifications API cannot deliver, inside its existing poll
|
||||
cycle: `ci_failure` (checks turn red on a head, once per head — including
|
||||
`startup_failure`), `merge_conflict` (`mergeable_state` becomes `dirty`,
|
||||
edge-triggered: `dirty → clean → dirty` fires again), `head_change` (new commits
|
||||
pushed, including a force-push revisit of a recent SHA), and `state_change`
|
||||
(PR merged or closed; close → reopen → close fires again). Detection is
|
||||
API-level and spends no model tokens until a transition fires. A quiet PR with
|
||||
settled CI costs one API call per poll; a PR whose CI is still pending is
|
||||
re-checked every 5 minutes until it settles, and settled heads are re-checked
|
||||
every 10 minutes (GitHub does not advance a PR's `updated_at` on check
|
||||
re-runs, so the periodic re-check is what catches a re-run turning red). At
|
||||
most 30 bound PRs are actively polled. A credential failure or rate limit
|
||||
(401/403/429) aborts the whole watch pass for that poll — one shared failure
|
||||
must not issue one doomed request per watched PR — while a per-PR permanent
|
||||
status (404/410/451) only skips that PR. A failing Checks endpoint never
|
||||
suppresses the other three detections: their data comes from `pulls.get`,
|
||||
and the last known CI aggregate stands until a fetch succeeds again.
|
||||
Transitions are persisted before delivery, so restarts do not re-fire them.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **First start skips existing unread notifications.** The cursor initializes to "now" on first launch. Notifications created before the bot starts are not processed unless the thread receives new activity afterwards.
|
||||
|
||||
@@ -2831,12 +2831,13 @@ describe('createAcpSessionBridge', () => {
|
||||
// would lose it: the close it raced is about to complete. `closing` alone
|
||||
// does not cover this span, which is the whole reason the in-flight flag
|
||||
// is checked alongside it.
|
||||
await expect(
|
||||
// Admission failures throw synchronously (B-F1 contract).
|
||||
expect(() =>
|
||||
bridge.sendPrompt(session.sessionId, {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: 'text', text: 'race the teardown' }],
|
||||
}),
|
||||
).rejects.toThrow(/closing/);
|
||||
).toThrow(/closing/);
|
||||
await expect(
|
||||
bridge.rewindSession(session.sessionId, { promptId: 'whatever' }),
|
||||
).rejects.toThrow(/closing/);
|
||||
@@ -4058,9 +4059,13 @@ describe('createAcpSessionBridge', () => {
|
||||
prompt: [{ type: 'text' as const, text: 'hello' }],
|
||||
};
|
||||
|
||||
await expect(bridge.sendPrompt(sessionId, prompt)).rejects.toMatchObject({
|
||||
data: { errorKind: 'working_directory_missing' },
|
||||
});
|
||||
expect(() => bridge.sendPrompt(sessionId, prompt)).toThrow(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
errorKind: 'working_directory_missing',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await expect(bridge.getSessionArtifacts(sessionId)).rejects.toMatchObject(
|
||||
{
|
||||
data: { errorKind: 'working_directory_missing' },
|
||||
@@ -4075,9 +4080,13 @@ describe('createAcpSessionBridge', () => {
|
||||
managedRelocation: 'live-conversation',
|
||||
conversationDirectoryExpectation: expectation,
|
||||
});
|
||||
await expect(bridge.sendPrompt(sessionId, prompt)).rejects.toMatchObject({
|
||||
data: { errorKind: 'working_directory_missing' },
|
||||
});
|
||||
expect(() => bridge.sendPrompt(sessionId, prompt)).toThrow(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
errorKind: 'working_directory_missing',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await bridge.commitManagedConversationBinding(sessionId, expectation);
|
||||
expect(artifactRestores).toEqual([
|
||||
{ workspaceCwd: WS_A, workspaceAccess: 'metadata-only' },
|
||||
@@ -4107,9 +4116,13 @@ describe('createAcpSessionBridge', () => {
|
||||
},
|
||||
});
|
||||
expect(artifactUpsertWorkspaceRoots).toEqual([]);
|
||||
await expect(bridge.sendPrompt(sessionId, prompt)).rejects.toMatchObject({
|
||||
data: { errorKind: 'working_directory_missing' },
|
||||
});
|
||||
expect(() => bridge.sendPrompt(sessionId, prompt)).toThrow(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
errorKind: 'working_directory_missing',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await expect(bridge.getSessionArtifacts(sessionId)).rejects.toMatchObject(
|
||||
{
|
||||
data: { errorKind: 'working_directory_missing' },
|
||||
@@ -19358,7 +19371,7 @@ describe('createAcpSessionBridge', () => {
|
||||
const bridge = makeBridge({ channelFactory: factory });
|
||||
const admitted = vi.fn();
|
||||
|
||||
await expect(
|
||||
expect(() =>
|
||||
bridge.sendPrompt(
|
||||
'missing-session',
|
||||
{
|
||||
@@ -19368,7 +19381,7 @@ describe('createAcpSessionBridge', () => {
|
||||
undefined,
|
||||
{ onPromptAdmitted: admitted },
|
||||
),
|
||||
).rejects.toBeInstanceOf(SessionNotFoundError);
|
||||
).toThrow(SessionNotFoundError);
|
||||
expect(admitted).not.toHaveBeenCalled();
|
||||
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
@@ -19475,6 +19488,53 @@ describe('createAcpSessionBridge', () => {
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it('throws synchronously when sendPrompt targets an unknown or closing session (B-F1)', async () => {
|
||||
// Admission-phase failures must throw SYNCHRONOUSLY: a rejected
|
||||
// promise would let the HTTP prompt route return a phantom 202 for a
|
||||
// prompt that was never enqueued, and a channel relay would mark the
|
||||
// event delivered and lose it instead of waking the session.
|
||||
const closeResult = deferred<Record<string, unknown>>();
|
||||
const handle = makeChannel({
|
||||
extMethodImpl: (method) => {
|
||||
if (method === SERVE_CONTROL_EXT_METHODS.sessionClose) {
|
||||
return closeResult.promise;
|
||||
}
|
||||
throw new Error(`unexpected extMethod ${method}`);
|
||||
},
|
||||
});
|
||||
const bridge = makeBridge({ channelFactory: async () => handle.channel });
|
||||
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
|
||||
|
||||
expect(() =>
|
||||
bridge.sendPrompt('no-such-session', {
|
||||
sessionId: 'no-such-session',
|
||||
prompt: [{ type: 'text', text: 'hi' }],
|
||||
}),
|
||||
).toThrow(SessionNotFoundError);
|
||||
|
||||
const close = bridge.closeSession(session.sessionId, {
|
||||
clientId: session.clientId,
|
||||
});
|
||||
void close.catch(() => {});
|
||||
await vi.waitFor(() =>
|
||||
expect(handle.agent.extMethodCalls.length).toBeGreaterThan(0),
|
||||
);
|
||||
let thrown: unknown;
|
||||
try {
|
||||
bridge.sendPrompt(session.sessionId, {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: 'text', text: 'hi' }],
|
||||
});
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(SessionNotFoundError);
|
||||
expect((thrown as SessionNotFoundError).code).toBe('session_closing');
|
||||
closeResult.resolve({});
|
||||
await close.catch(() => {});
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
it.each([[0], [Infinity]])(
|
||||
'does not cap pending prompts when maxPendingPromptsPerSession is %s',
|
||||
async (maxPendingPromptsPerSession) => {
|
||||
@@ -20571,12 +20631,12 @@ describe('createAcpSessionBridge', () => {
|
||||
throw new Error('factory should not be called');
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
expect(() =>
|
||||
bridge.sendPrompt('unknown', {
|
||||
sessionId: 'unknown',
|
||||
prompt: [{ type: 'text', text: 'x' }],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(SessionNotFoundError);
|
||||
).toThrow(SessionNotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27266,14 +27326,18 @@ describe('createAcpSessionBridge', () => {
|
||||
[DAEMON_OWNED_STANDALONE_CREATION_KEY]: true,
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
expect(() =>
|
||||
bridge.sendPrompt(sessionId, {
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'must remain held' }],
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
data: { errorKind: 'working_directory_missing' },
|
||||
});
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
errorKind: 'working_directory_missing',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await bridge.shutdown();
|
||||
});
|
||||
|
||||
@@ -1268,6 +1268,15 @@ export interface BridgeSessionSummary {
|
||||
* session can produce several PRs (stacked or follow-up work).
|
||||
*/
|
||||
prs?: SessionPrInfo[];
|
||||
/**
|
||||
* True when the session's PR sidecar EXISTS but could not be read (I/O
|
||||
* error, invalid JSON, failed shape check): `prs` is then unknown, not
|
||||
* empty. Absent sidecars (never bound / last binding removed) leave both
|
||||
* fields unset — that is an authoritative "no bindings". Consumers that
|
||||
* project bindings into authoritative snapshots must treat this flag as
|
||||
* a degraded read, not an unbind.
|
||||
*/
|
||||
prsReadFailed?: true;
|
||||
}
|
||||
|
||||
/** Original event-bus cursor captured when a prompt id was first admitted. */
|
||||
@@ -2154,10 +2163,16 @@ export interface AcpSessionBridge extends WorkspaceEventBridge {
|
||||
* session FIFO-serialize through a per-session queue.
|
||||
*
|
||||
* Admission contract: implementations must not be `async`. Admission
|
||||
* failures such as `InvalidClientIdError`, `PromptQueueFullError`, and
|
||||
* pre-aborted signals throw synchronously so HTTP routes can reject
|
||||
* before returning 202. Deferred failures such as `SessionNotFoundError`
|
||||
* may be returned as rejected promises.
|
||||
* failures — `InvalidClientIdError`, `PromptQueueFullError`, pre-aborted
|
||||
* signals, `SessionNotFoundError` (unknown or closing session), and the
|
||||
* reserved standalone working-directory check — throw synchronously so
|
||||
* HTTP routes can reject before returning 202. A rejected promise
|
||||
* normally represents a turn that WAS admitted and then failed. The one
|
||||
* pre-enqueue check that still rejects deferred (prompt-shape validation,
|
||||
* which the HTTP routes pre-validate) is an admission failure too:
|
||||
* `onPromptAdmitted` has not fired for it, and callers that dedupe retries
|
||||
* must drop the idempotency key when a rejection arrives without the
|
||||
* admission signal.
|
||||
*
|
||||
* Reserved contract (not yet implemented): promptId admission dedup — a
|
||||
* `PromptIdConflictError` admission failure class, and a rule that a
|
||||
|
||||
@@ -11342,14 +11342,18 @@ export function createSessionControlPlane(
|
||||
const capturedContext = telemetry.captureContext();
|
||||
const queuedAt = Date.now();
|
||||
const entry = byId.get(sessionId);
|
||||
if (!entry) return Promise.reject(new SessionNotFoundError(sessionId));
|
||||
// Admission-phase failures throw SYNCHRONOUSLY (same contract as the
|
||||
// worktree-reset barrier below and the continue path's closing check):
|
||||
// a rejected promise would let the HTTP route return a phantom 202 for
|
||||
// a prompt that was never enqueued, and a channel relay would mark the
|
||||
// event delivered and lose it instead of waking the session and
|
||||
// retrying.
|
||||
if (!entry) throw new SessionNotFoundError(sessionId);
|
||||
if (isClosingOrAuthorizingClose(entry)) {
|
||||
return Promise.reject(
|
||||
new SessionNotFoundError(
|
||||
sessionId,
|
||||
'The session is closing; retry after close completes',
|
||||
'session_closing',
|
||||
),
|
||||
throw new SessionNotFoundError(
|
||||
sessionId,
|
||||
'The session is closing; retry after close completes',
|
||||
'session_closing',
|
||||
);
|
||||
}
|
||||
// Worktree-reset barrier: the transfer owns this session's worktree
|
||||
@@ -11365,7 +11369,11 @@ export function createSessionControlPlane(
|
||||
isReservedStandaloneSessionSourceType(entry.sourceType) &&
|
||||
entry.managedConversationBinding?.released !== true
|
||||
) {
|
||||
return Promise.reject(standaloneWorkingDirectoryMissingError());
|
||||
// Synchronous like every reachable sibling admission check: a
|
||||
// deferred rejection would let the prompt route return a phantom
|
||||
// 202 that the channel relay consumes as durable acceptance, losing
|
||||
// the event (the route's key-forget cannot un-send the 202).
|
||||
throw standaloneWorkingDirectoryMissingError();
|
||||
}
|
||||
if (!Array.isArray(req.prompt)) {
|
||||
return Promise.reject(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { basename, join } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type {
|
||||
ChannelBoundSessions,
|
||||
ChannelConfig,
|
||||
ChannelMemoryCallbacks,
|
||||
ChannelMemoryEntry,
|
||||
@@ -257,6 +258,12 @@ export interface ChannelBaseOptions {
|
||||
bridgeRecovery?: () => Promise<void> | undefined;
|
||||
groupHistoryPath?: string;
|
||||
loopController?: ChannelLoopController;
|
||||
/**
|
||||
* Daemon-backed delivery into PR-bound sessions the channel does not own.
|
||||
* Only the daemon channel worker provides it; adapters must treat its
|
||||
* absence as "feature unavailable" and keep channel-session routing.
|
||||
*/
|
||||
boundSessions?: ChannelBoundSessions;
|
||||
observedContacts?: {
|
||||
observe(
|
||||
channelName: string,
|
||||
@@ -493,6 +500,7 @@ export abstract class ChannelBase {
|
||||
// re-post once per still-pending request, but never per trigger.
|
||||
private readonly groupPairingNotified = new Map<string, string>();
|
||||
private readonly loopController?: ChannelLoopController;
|
||||
protected readonly boundSessions?: ChannelBoundSessions;
|
||||
private readonly observedContacts?: ChannelBaseOptions['observedContacts'];
|
||||
private readonly namedSessions?: NamedSessionManager;
|
||||
private readonly observedContactEnvelopes = new WeakSet<Envelope>();
|
||||
@@ -1440,6 +1448,7 @@ export abstract class ChannelBase {
|
||||
),
|
||||
);
|
||||
this.loopController = options?.loopController;
|
||||
this.boundSessions = options?.boundSessions;
|
||||
this.observedContacts = options?.observedContacts;
|
||||
this.bridgeRecovery = options?.bridgeRecovery;
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ function makeBridge(): ChannelAgentBridge {
|
||||
class TestPoller extends PollingChannelBase<TestCursor> {
|
||||
pollCount = 0;
|
||||
shouldThrow = false;
|
||||
errorMessage = 'poll failed';
|
||||
|
||||
protected createInitialCursor(): TestCursor {
|
||||
return { ts: '2026-01-01T00:00:00.000Z', count: 0 };
|
||||
@@ -71,7 +72,7 @@ class TestPoller extends PollingChannelBase<TestCursor> {
|
||||
|
||||
protected async pollOnce(): Promise<void> {
|
||||
this.pollCount++;
|
||||
if (this.shouldThrow) throw new Error('poll failed');
|
||||
if (this.shouldThrow) throw new Error(this.errorMessage);
|
||||
this.cursor.count++;
|
||||
this.cursor.ts = new Date().toISOString();
|
||||
}
|
||||
@@ -168,6 +169,35 @@ describe('PollingChannelBase', () => {
|
||||
stderrSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('sanitizes the poll-error diagnostic against log-line forgery (R12-F3)', async () => {
|
||||
const poller = new TestPoller('sanitize', makeConfig(), makeBridge());
|
||||
poller.shouldThrow = true;
|
||||
// A terminal poll error reaches the runLoop catch raw; an upstream/proxy
|
||||
// error body can carry separators/ANSI that forge extra log lines.
|
||||
poller.errorMessage =
|
||||
'upstream boom\u2028[Channel:sanitize] preflight rejected reason=sender_denied\u001b[31mX';
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
poller.connect();
|
||||
await vi.waitFor(() => {
|
||||
expect(stderrSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('backing off'),
|
||||
);
|
||||
});
|
||||
poller.disconnect();
|
||||
const line =
|
||||
stderrSpy.mock.calls
|
||||
.map((call) => String(call[0]))
|
||||
.find((s) => s.includes('poll error')) ?? '';
|
||||
stderrSpy.mockRestore();
|
||||
expect(line).toContain('upstream boom');
|
||||
// eslint-disable-next-line no-control-regex
|
||||
expect(line).not.toMatch(/[\u2028\u2029\u0085\u001b]/);
|
||||
expect(line.endsWith('\n')).toBe(true);
|
||||
expect(line.slice(0, -1)).not.toContain('\n');
|
||||
});
|
||||
|
||||
it('uses configured pollInterval from config', () => {
|
||||
const poller = new TestPoller(
|
||||
'interval',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ChannelBase } from './ChannelBase.js';
|
||||
import type { ChannelEventDetail } from './channel-events.js';
|
||||
import type { ChannelConfig } from './types.js';
|
||||
import { getGlobalQwenDir } from './paths.js';
|
||||
import { sanitizeLogText } from './sanitize.js';
|
||||
|
||||
const INITIAL_BACKOFF = 2_000;
|
||||
const MAX_BACKOFF = 30_000;
|
||||
@@ -127,14 +128,18 @@ export abstract class PollingChannelBase<Cursor> extends ChannelBase {
|
||||
);
|
||||
const detail = this.pollEventDetail;
|
||||
this.pollEventDetail = undefined;
|
||||
const message = sanitizeLogText(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
200,
|
||||
);
|
||||
process.stderr.write(
|
||||
`[Channel:${this.name}] poll error (attempt ${this.consecutiveErrors}), backing off ${backoff}ms: ${err}\n`,
|
||||
`[Channel:${this.name}] poll error (attempt ${this.consecutiveErrors}), backing off ${backoff}ms: ${message}\n`,
|
||||
);
|
||||
this.recordChannelEvent({
|
||||
type: 'poll',
|
||||
outcome: 'failed',
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
error: message,
|
||||
detail: {
|
||||
...(detail ?? {}),
|
||||
consecutiveErrors: this.consecutiveErrors,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Delivery failures for prompts injected into PR-bound sessions the channel
|
||||
* does not own. The cases drive different adapter behavior: a gone session
|
||||
* falls back to the normal channel path; a full queue or a temporarily
|
||||
* unavailable session/workspace is backpressure that defers the event
|
||||
* without consuming the failure budget.
|
||||
*/
|
||||
|
||||
export class BoundSessionGoneError extends Error {
|
||||
constructor(readonly sessionId: string) {
|
||||
super(`Bound session ${sessionId} no longer exists`);
|
||||
this.name = 'BoundSessionGoneError';
|
||||
}
|
||||
}
|
||||
|
||||
export class BoundSessionQueueFullError extends Error {
|
||||
constructor(readonly sessionId: string) {
|
||||
super(`Prompt queue of bound session ${sessionId} is full`);
|
||||
this.name = 'BoundSessionQueueFullError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The session or its owning workspace is temporarily unavailable (workspace
|
||||
* draining/restarting, session limit reached, restore in progress). Like a
|
||||
* full queue this is retry-later backpressure, not a delivery failure: the
|
||||
* daemon even sends Retry-After for it. Burning the attempt budget on these
|
||||
* steady-state conditions would silently lose the event after three polls.
|
||||
*/
|
||||
export class BoundSessionUnavailableError extends Error {
|
||||
constructor(
|
||||
readonly sessionId: string,
|
||||
detail?: string,
|
||||
) {
|
||||
super(
|
||||
`Bound session ${sessionId} is temporarily unavailable${detail ? `: ${detail}` : ''}`,
|
||||
);
|
||||
this.name = 'BoundSessionUnavailableError';
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,10 @@ export type {
|
||||
SessionDiedEvent,
|
||||
ToolCallEvent,
|
||||
} from './ChannelAgentBridge.js';
|
||||
export { CHANNEL_PROMPT_META_KEY } from './ChannelAgentBridge.js';
|
||||
export {
|
||||
CHANNEL_PROMPT_AUTHORIZATION_META_KEY,
|
||||
CHANNEL_PROMPT_META_KEY,
|
||||
} from './ChannelAgentBridge.js';
|
||||
export type { AcpBridgeOptions } from './AcpBridge.js';
|
||||
export { DaemonChannelBridge } from './DaemonChannelBridge.js';
|
||||
export type {
|
||||
@@ -61,6 +64,11 @@ export {
|
||||
isChannelProactiveDeliveryError,
|
||||
} from './ChannelProactiveDeliveryError.js';
|
||||
export type { ChannelProactiveDeliveryDisposition } from './ChannelProactiveDeliveryError.js';
|
||||
export {
|
||||
BoundSessionGoneError,
|
||||
BoundSessionQueueFullError,
|
||||
BoundSessionUnavailableError,
|
||||
} from './bound-session-delivery.js';
|
||||
export type {
|
||||
ChannelBaseOptions,
|
||||
ChannelMemoryRecallCacheStatus,
|
||||
@@ -137,6 +145,7 @@ export type {
|
||||
} from './background-output-coordinator.js';
|
||||
export type {
|
||||
Attachment,
|
||||
ChannelBoundSessions,
|
||||
ChannelConfig,
|
||||
ChannelConfigEnumFieldDescriptor,
|
||||
ChannelConfigFieldDescriptor,
|
||||
@@ -174,6 +183,9 @@ export type {
|
||||
Envelope,
|
||||
GroupConfig,
|
||||
GroupPolicy,
|
||||
PrBindingEntry,
|
||||
PrBindingsSnapshot,
|
||||
PrBindingSummary,
|
||||
PrivatePolicy,
|
||||
GroupSenderPolicy,
|
||||
ObservedChannelIdentity,
|
||||
|
||||
@@ -187,6 +187,73 @@ export interface SessionTarget {
|
||||
isGroup?: boolean;
|
||||
}
|
||||
|
||||
/** One PR binding mirrored from a daemon session's PR sidecar. */
|
||||
export interface PrBindingEntry {
|
||||
number: number;
|
||||
url: string;
|
||||
state?: 'open' | 'merged' | 'closed';
|
||||
}
|
||||
|
||||
/**
|
||||
* A daemon session with PR bindings, as returned by the daemon's
|
||||
* `GET /sessions/pr-bindings` reverse-lookup route.
|
||||
*/
|
||||
export interface PrBindingSummary {
|
||||
sessionId: string;
|
||||
workspaceCwd: string;
|
||||
sourceType?: string;
|
||||
updatedAt?: string;
|
||||
prs: PrBindingEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a PR-binding reverse lookup. `partial` means the daemon could
|
||||
* not enumerate every binding (a workspace failed to list or is not active,
|
||||
* a scan needed more than one page, or a scan cap was hit); consumers must
|
||||
* not treat a missing entry as an authoritative absence — in particular they
|
||||
* must not destroy state keyed by bindings.
|
||||
*/
|
||||
export interface PrBindingsSnapshot {
|
||||
bindings: PrBindingSummary[];
|
||||
partial: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Daemon-backed delivery into sessions the channel does not own. Provided by
|
||||
* the daemon channel worker; absent in standalone ACP mode, where adapters
|
||||
* must keep their normal channel-session behavior.
|
||||
*/
|
||||
export interface ChannelBoundSessions {
|
||||
listPrBindings(): Promise<PrBindingsSnapshot>;
|
||||
/**
|
||||
* Inject a prompt into an existing session. Resolves once the daemon
|
||||
* accepts the prompt into the session's queue; does not wait for the turn.
|
||||
* Throws `BoundSessionGoneError` when the session no longer exists,
|
||||
* `BoundSessionQueueFullError` when the session's prompt queue is full,
|
||||
* and `BoundSessionUnavailableError` when the session or its workspace is
|
||||
* temporarily unavailable (draining, session limit, restore in progress,
|
||||
* rate limited). The latter two are backpressure: defer without consuming
|
||||
* the failure budget.
|
||||
*
|
||||
* `idempotencyKey` identifies the delivery attempt's EVENT (the adapter
|
||||
* derives it from the source chat and message id, so it survives task
|
||||
* re-creation after a crash): the daemon deduplicates repeated admissions
|
||||
* of the same key, and a retry after a lost acceptance mark or a lost
|
||||
* response re-injects nothing within the daemon's lifetime. Dedupe is
|
||||
* best-effort under two FIFO caps — 128 keys per session and 1024
|
||||
* sessions — and a key can be evicted while its queue is still live (the
|
||||
* per-session cap bites in a single long-lived session, the ordinary
|
||||
* case), so a retry after such an eviction re-injects. The ring also dies
|
||||
* with the in-memory queue: a daemon restart loses both, making
|
||||
* re-injection the correct recovery there.
|
||||
*/
|
||||
deliverPrompt(
|
||||
sessionId: string,
|
||||
prompt: string,
|
||||
idempotencyKey?: string,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ObservedChannelIdentity {
|
||||
id: string;
|
||||
label: string;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { plugin } from './index.js';
|
||||
import { PR_WATCH_EVENTS } from './GithubAdapter.js';
|
||||
|
||||
function validate(overrides: Record<string, unknown>): string | undefined {
|
||||
return plugin.management?.validateConfig?.({
|
||||
token: 'test-token',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe('github plugin validateConfig', () => {
|
||||
it('accepts a minimal token config', () => {
|
||||
expect(validate({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('requires a token or local gh authentication', () => {
|
||||
expect(validate({ token: '' })).toContain(
|
||||
'requires a token or local GitHub CLI authentication',
|
||||
);
|
||||
expect(validate({ token: '', useLocalGh: true })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a non-boolean deliverToBoundSessions', () => {
|
||||
expect(validate({ deliverToBoundSessions: 'yes' })).toContain(
|
||||
'deliverToBoundSessions must be a boolean',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a non-array prWatchEvents', () => {
|
||||
expect(
|
||||
validate({ deliverToBoundSessions: true, prWatchEvents: 'ci_failure' }),
|
||||
).toContain('prWatchEvents');
|
||||
});
|
||||
|
||||
it('rejects unknown watch events', () => {
|
||||
expect(
|
||||
validate({
|
||||
deliverToBoundSessions: true,
|
||||
prWatchEvents: ['ci_failure', 'pushed'],
|
||||
}),
|
||||
).toContain('pushed');
|
||||
});
|
||||
|
||||
it('requires deliverToBoundSessions for watch events', () => {
|
||||
expect(validate({ prWatchEvents: ['ci_failure'] })).toContain(
|
||||
'requires deliverToBoundSessions',
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts the full watch vocabulary, case-insensitively', () => {
|
||||
expect(
|
||||
validate({
|
||||
deliverToBoundSessions: true,
|
||||
prWatchEvents: [
|
||||
'CI_FAILURE',
|
||||
' merge_conflict ',
|
||||
'head_change',
|
||||
'state_change',
|
||||
],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts an empty watch list without delivery', () => {
|
||||
expect(validate({ prWatchEvents: [] })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('builds the prWatchEvents UI options from the adapter vocabulary (R2-18)', () => {
|
||||
const field = plugin.management?.fields.find(
|
||||
(candidate) => candidate.key === 'prWatchEvents',
|
||||
);
|
||||
// The UI option list must equal the exported constant the validator and
|
||||
// emit path use, so editing either literal alone goes red here.
|
||||
expect(field?.options?.map((option) => option.value)).toEqual([
|
||||
...PR_WATCH_EVENTS,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { ChannelPlugin } from '@qwen-code/channel-base';
|
||||
import { GithubChannel } from './GithubAdapter.js';
|
||||
import {
|
||||
GithubChannel,
|
||||
validateBoundSessionConfig,
|
||||
PR_WATCH_EVENTS,
|
||||
} from './GithubAdapter.js';
|
||||
|
||||
export { GithubChannel };
|
||||
|
||||
@@ -53,8 +57,8 @@ export const plugin: ChannelPlugin = {
|
||||
label: 'Reason Filter',
|
||||
kind: 'string-list',
|
||||
description:
|
||||
'Optional. Comma-separated notification reasons to process. ' +
|
||||
'Leave empty to process all.',
|
||||
'Optional. Only notifications with a selected reason are processed. ' +
|
||||
'Leave every reason unselected to process all of them.',
|
||||
options: [
|
||||
{ value: 'mention', label: 'mention' },
|
||||
{ value: 'review_requested', label: 'review_requested' },
|
||||
@@ -79,6 +83,24 @@ export const plugin: ChannelPlugin = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'deliverToBoundSessions',
|
||||
label: 'Deliver To PR-Bound Sessions',
|
||||
kind: 'boolean',
|
||||
description:
|
||||
'Deliver events for PRs bound to a live session into that session as a prompt, instead of a channel-owned session. Requires a daemon-managed channel.',
|
||||
default: 'false',
|
||||
},
|
||||
{
|
||||
key: 'prWatchEvents',
|
||||
label: 'PR Watch Events',
|
||||
kind: 'string-list',
|
||||
description:
|
||||
'Optional. State transitions to watch for bound PRs (requires Deliver To PR-Bound Sessions). Leave empty to disable.',
|
||||
// Built from the adapter's exported vocabulary so the UI options can
|
||||
// never drift from the validator/emit path (R2-18).
|
||||
options: PR_WATCH_EVENTS.map((value) => ({ value, label: value })),
|
||||
},
|
||||
],
|
||||
validateConfig: (config) => {
|
||||
const token =
|
||||
@@ -86,7 +108,7 @@ export const plugin: ChannelPlugin = {
|
||||
if (!token && config['useLocalGh'] !== true) {
|
||||
return 'Channel requires a token or local GitHub CLI authentication (useLocalGh).';
|
||||
}
|
||||
return undefined;
|
||||
return validateBoundSessionConfig(config, 'github');
|
||||
},
|
||||
},
|
||||
createChannel: (name, config, bridge, options) =>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -19,6 +19,11 @@ import { loadSettings } from '../../config/settings.js';
|
||||
import { resolveLanguage, resolveLanguageSetting } from '../../i18n/index.js';
|
||||
import { scrubAndReportInheritedLoaderEnv } from '../../config/shared-env-keys.js';
|
||||
import {
|
||||
BoundSessionGoneError,
|
||||
BoundSessionQueueFullError,
|
||||
BoundSessionUnavailableError,
|
||||
CHANNEL_PROMPT_AUTHORIZATION_META_KEY,
|
||||
CHANNEL_PROMPT_META_KEY,
|
||||
ChannelLoopScheduler,
|
||||
ChannelLoopStore,
|
||||
DaemonChannelBridge,
|
||||
@@ -29,6 +34,7 @@ import {
|
||||
import type {
|
||||
ChannelAgentBridge,
|
||||
ChannelBase,
|
||||
ChannelBoundSessions,
|
||||
ChannelLoopRunner,
|
||||
ChannelWebhookRunOptions,
|
||||
ChannelWebhookTask,
|
||||
@@ -36,6 +42,8 @@ import type {
|
||||
DaemonChannelSessionClient,
|
||||
DaemonChannelSessionFactory,
|
||||
DaemonChannelSessionFactoryRequest,
|
||||
PrBindingsSnapshot,
|
||||
PrBindingSummary,
|
||||
} from '@qwen-code/channel-base';
|
||||
import type { ServeFeature } from '../../serve/capabilities.js';
|
||||
import type { ServeChannelSelection } from '../../serve/types.js';
|
||||
@@ -63,6 +71,7 @@ import {
|
||||
type ChannelDeliveryRequest,
|
||||
} from '../../runtime/channel-delivery-ipc.js';
|
||||
import { sanitizeWorkerDiagnostic } from '../../serve/channel-worker-diagnostics.js';
|
||||
import { CHANNEL_BOUND_RELAY_META_KEY } from '../../serve/channel-worker-prompt-authorization.js';
|
||||
import {
|
||||
isChannelStartupReportAckMessage,
|
||||
MAX_CHANNEL_STARTUP_FAILURES,
|
||||
@@ -154,6 +163,7 @@ interface DaemonCapabilitiesLike {
|
||||
|
||||
interface DaemonClientLike {
|
||||
capabilities(): Promise<DaemonCapabilitiesLike>;
|
||||
requestRest(path: string, init?: RequestInit): Promise<Response>;
|
||||
workspaceByCwd?(cwd: string): {
|
||||
deleteSessionsData(sessionIds: string[]): Promise<{
|
||||
removed: string[];
|
||||
@@ -296,6 +306,358 @@ export function createDaemonSessionFactory({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Upper bound on the accessor's session→cwd wake cache. Complete snapshots
|
||||
* rebuild it and Gone verdicts evict entries, but a permanent partial never
|
||||
* clears it; the cap bounds churn-driven growth during such a window.
|
||||
*/
|
||||
const MAX_WAKE_CWD_CACHE = 1024;
|
||||
|
||||
/**
|
||||
* Daemon-backed delivery into PR-bound sessions the channel does not own.
|
||||
* Acceptance into the session's prompt queue counts as delivery; the turn
|
||||
* itself runs independently of the channel worker.
|
||||
*
|
||||
* `wake` restores a dormant (persisted but not live) session, which the
|
||||
* prompt route rejects with 404, and returns a release callback for the
|
||||
* client registration the restore minted. The accessor invokes that
|
||||
* release once the retried prompt settles: a non-empty client registration
|
||||
* blocks the daemon's prompt-settled idle close, so discarding it without
|
||||
* detaching would pin the woken session live until the TTL reaper.
|
||||
*
|
||||
* Error classification: a 404 (session or workspace truly gone) becomes
|
||||
* `BoundSessionGoneError` so the adapter falls back to the channel path —
|
||||
* except a 404 on the retry AFTER a successful wake, which is reversible
|
||||
* backpressure (the owner may have begun draining) and defers one cycle;
|
||||
* queue-full and temporarily-unavailable conditions (503, 429, other 5xx,
|
||||
* restore conflicts, session limits, draining workspaces) are backpressure
|
||||
* that defers the event without consuming the failure budget; everything
|
||||
* else propagates as a transient delivery error for the adapter to retry.
|
||||
*
|
||||
* Every POST carries the caller's idempotency key (a stable event identity
|
||||
* derived from the source chat and message id) and the bound-relay marker:
|
||||
* the daemon deduplicates repeated admissions of the same key (a lost
|
||||
* acceptance mark or a lost 202 must not re-inject the event), and
|
||||
* cross-workspace channel classification requires the marker in addition to
|
||||
* the worker token.
|
||||
*/
|
||||
export function createBoundSessionsAccessor(
|
||||
client: DaemonClientLike,
|
||||
wake?: (
|
||||
sessionId: string,
|
||||
workspaceCwd: string,
|
||||
) => Promise<(() => Promise<void>) | undefined>,
|
||||
promptAuthorization?: string,
|
||||
): ChannelBoundSessions {
|
||||
const cwdBySession = new Map<string, string>();
|
||||
// requestRest's timer covers the connection through response headers
|
||||
// only; the signal also bounds body consumption so a half-open connection
|
||||
// cannot hang the poll loop indefinitely.
|
||||
const requestSignal = (): AbortSignal => AbortSignal.timeout(30_000);
|
||||
const discardBody = (res: Response): Promise<void> =>
|
||||
res.body?.cancel().catch(() => {}) ?? Promise.resolve();
|
||||
const postPrompt = (
|
||||
sessionId: string,
|
||||
prompt: string,
|
||||
idempotencyKey?: string,
|
||||
): Promise<Response> =>
|
||||
client.requestRest(`/session/${encodeURIComponent(sessionId)}/prompt`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: requestSignal(),
|
||||
// Same classification the channel bridge rides: without the marker
|
||||
// and worker authorization the daemon treats the relay as an
|
||||
// interactive user prompt (loop-detected rejection instead of the
|
||||
// graceful channel end-turn, and live PR-binding backfill on relayed
|
||||
// external text). Admission strips the marker for unauthorized
|
||||
// callers, so both keys must ride together. The bound-relay marker
|
||||
// additionally scopes the process-level worker credential to its
|
||||
// reason: cross-workspace channel classification requires it.
|
||||
body: JSON.stringify({
|
||||
prompt: [{ type: 'text', text: prompt }],
|
||||
// The stable event identity (source chat + message id): the daemon
|
||||
// deduplicates repeated admissions of the same key, so a retry
|
||||
// after a lost acceptance mark or a lost 202 response does not
|
||||
// re-inject the event.
|
||||
...(idempotencyKey !== undefined ? { idempotencyKey } : {}),
|
||||
_meta: {
|
||||
[CHANNEL_PROMPT_META_KEY]: true,
|
||||
[CHANNEL_BOUND_RELAY_META_KEY]: true,
|
||||
...(promptAuthorization
|
||||
? { [CHANNEL_PROMPT_AUTHORIZATION_META_KEY]: promptAuthorization }
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
const listPrBindings = async (): Promise<PrBindingsSnapshot> => {
|
||||
const res = await client.requestRest('/sessions/pr-bindings', {
|
||||
signal: requestSignal(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
await discardBody(res);
|
||||
throw new Error(`GET /sessions/pr-bindings failed: HTTP ${res.status}`);
|
||||
}
|
||||
const body = (await res.json()) as {
|
||||
bindings?: unknown;
|
||||
partial?: unknown;
|
||||
};
|
||||
let partial = body.partial === true;
|
||||
// A malformed 200 is not an authoritative empty snapshot: throwing
|
||||
// routes it into the adapter's fail-closed refresh handling (keep the
|
||||
// previous map, skip the watch pass) instead of wiping watch state.
|
||||
if (!Array.isArray(body.bindings)) {
|
||||
throw new Error(
|
||||
'GET /sessions/pr-bindings returned malformed bindings (HTTP 200)',
|
||||
);
|
||||
}
|
||||
const summaries: PrBindingSummary[] = [];
|
||||
const validatedCwds = new Map<string, string>();
|
||||
for (const entry of body.bindings) {
|
||||
if (entry === null || typeof entry !== 'object') {
|
||||
// A dropped session row is a degraded answer too: its bindings
|
||||
// are unknown, so the snapshot must not claim completeness.
|
||||
partial = true;
|
||||
continue;
|
||||
}
|
||||
const record = entry as Record<string, unknown>;
|
||||
const { sessionId, workspaceCwd, prs } = record;
|
||||
if (
|
||||
typeof sessionId !== 'string' ||
|
||||
typeof workspaceCwd !== 'string' ||
|
||||
!Array.isArray(prs)
|
||||
) {
|
||||
partial = true;
|
||||
continue;
|
||||
}
|
||||
const entries: PrBindingSummary['prs'] = [];
|
||||
for (const pr of prs) {
|
||||
if (pr === null || typeof pr !== 'object') continue;
|
||||
const { number, url, state } = pr as Record<string, unknown>;
|
||||
if (
|
||||
typeof number !== 'number' ||
|
||||
!Number.isSafeInteger(number) ||
|
||||
number <= 0 ||
|
||||
typeof url !== 'string'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
entries.push({
|
||||
number,
|
||||
url,
|
||||
...(state === 'open' || state === 'merged' || state === 'closed'
|
||||
? { state }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
if (entries.length < prs.length) {
|
||||
// ANY malformed PR entry makes this a degraded answer: a partial
|
||||
// projection must not masquerade as a complete one, or the adapter
|
||||
// would trust its absence semantics for the dropped PRs.
|
||||
partial = true;
|
||||
}
|
||||
if (entries.length === 0 && prs.length > 0) {
|
||||
// A listed session whose PR entries ALL failed shape validation is
|
||||
// a degraded answer, not an unbind: skip the empty projection so
|
||||
// the adapter retains known bindings instead of trusting it.
|
||||
continue;
|
||||
}
|
||||
// An empty prs row is a TOMBSTONE and must pass through: the adapter
|
||||
// keys retention on the listed session set, so "scanned, no bindings"
|
||||
// (an observed unbind) has to reach it even on a partial answer.
|
||||
const { sourceType, updatedAt } = record;
|
||||
validatedCwds.set(sessionId, workspaceCwd);
|
||||
summaries.push({
|
||||
sessionId,
|
||||
workspaceCwd,
|
||||
...(typeof sourceType === 'string' ? { sourceType } : {}),
|
||||
...(typeof updatedAt === 'string' ? { updatedAt } : {}),
|
||||
prs: entries,
|
||||
});
|
||||
}
|
||||
// Rebuild (not accumulate) on complete answers so sessions that left
|
||||
// the bindings drop out of the wake cache. On a partial answer keep
|
||||
// the known cwds: an incomplete enumeration must not turn a queued
|
||||
// dormant delivery into a gone-session public fallback. The decision
|
||||
// uses the FINAL partial verdict: row-level degradation upgrades a
|
||||
// nominally complete answer, and must not have cleared the cache first.
|
||||
if (!partial) cwdBySession.clear();
|
||||
for (const [sessionId, workspaceCwd] of validatedCwds) {
|
||||
// Bounded: under a permanent partial the cache is never cleared, so
|
||||
// session churn during the partial window must not grow it without
|
||||
// limit. FIFO eviction only drops the oldest wake hints; a dropped
|
||||
// entry is re-fetched from the bindings route on the next 404.
|
||||
if (
|
||||
cwdBySession.size >= MAX_WAKE_CWD_CACHE &&
|
||||
!cwdBySession.has(sessionId)
|
||||
) {
|
||||
const oldest = cwdBySession.keys().next().value;
|
||||
if (oldest !== undefined) cwdBySession.delete(oldest);
|
||||
}
|
||||
cwdBySession.set(sessionId, workspaceCwd);
|
||||
}
|
||||
return { bindings: summaries, partial };
|
||||
};
|
||||
return {
|
||||
listPrBindings,
|
||||
deliverPrompt: async (sessionId, prompt, idempotencyKey) => {
|
||||
let res = await postPrompt(sessionId, prompt, idempotencyKey);
|
||||
let releaseWake: (() => Promise<void>) | undefined;
|
||||
let wokeSession = false;
|
||||
try {
|
||||
if (res.status === 404 && wake) {
|
||||
// Dormant session: the bindings route lists persisted active
|
||||
// sessions, but the prompt route only serves live ones. Wake it
|
||||
// and retry once. A refresh failure here is transient and must
|
||||
// propagate, not degrade into a gone-session public fallback.
|
||||
let cwd = cwdBySession.get(sessionId);
|
||||
if (cwd === undefined) {
|
||||
const snapshot = await listPrBindings();
|
||||
// Read the target's cwd from the FRESH FULL snapshot, not the
|
||||
// capped hint cache: a complete snapshot with more than
|
||||
// MAX_WAKE_CWD_CACHE rows truncates the cache (FIFO), so a
|
||||
// listed, live, dormant session beyond the cap is still present
|
||||
// in snapshot.bindings and must be woken — reading the capped
|
||||
// cache here would miss it and misreport it as gone (an
|
||||
// irreversible public reply on a complete snapshot). The hint
|
||||
// cache stays the pre-refresh fast path above; the cap still
|
||||
// bounds it.
|
||||
cwd = snapshot.bindings.find(
|
||||
(binding) => binding.sessionId === sessionId,
|
||||
)?.workspaceCwd;
|
||||
if (cwd === undefined && snapshot.partial) {
|
||||
// Cold cache meets an incomplete enumeration: absence from a
|
||||
// partial snapshot is not evidence of deletion. Defer to the
|
||||
// adapter's backpressure retry instead of the public
|
||||
// gone-session fallback — and instead of the failure budget,
|
||||
// which a generic Error would consume (the message promises
|
||||
// deferral; the class must match).
|
||||
throw new BoundSessionUnavailableError(
|
||||
sessionId,
|
||||
'absent from a partial bindings snapshot; deferring delivery',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (cwd !== undefined) {
|
||||
releaseWake = await wake(sessionId, cwd);
|
||||
wokeSession = true;
|
||||
await discardBody(res);
|
||||
// Same idempotency key: the first POST was a 404 (never
|
||||
// admitted), and a response-loss retry must dedupe too.
|
||||
res = await postPrompt(sessionId, prompt, idempotencyKey);
|
||||
}
|
||||
}
|
||||
if (res.status === 404) {
|
||||
if (wokeSession) {
|
||||
// A 404 AFTER a successful wake is more likely a reversible
|
||||
// lifecycle transition — the owning workspace began draining
|
||||
// between the resume and this retry, and the prompt route answers
|
||||
// this same 404 for a non-active owner — than permanent deletion.
|
||||
// Defer one cycle: the next wake's resume reports a permanent
|
||||
// 404/409 if the session is truly gone, so the fallback is only
|
||||
// delayed, never lost to the failure budget.
|
||||
throw new BoundSessionUnavailableError(
|
||||
sessionId,
|
||||
'post-wake retry 404 (reversible lifecycle state)',
|
||||
);
|
||||
}
|
||||
throw new BoundSessionGoneError(sessionId);
|
||||
}
|
||||
if (res.status === 503) {
|
||||
const body = (await res.json().catch(() => ({}))) as {
|
||||
code?: unknown;
|
||||
};
|
||||
if (body.code === 'prompt_queue_full') {
|
||||
throw new BoundSessionQueueFullError(sessionId);
|
||||
}
|
||||
// Every other 503 (workspace draining/restarting, session limit,
|
||||
// runtime starting) is a retry-later condition — the daemon even
|
||||
// sends Retry-After. Burning the attempt budget on these
|
||||
// steady states would silently lose the event after three polls.
|
||||
throw new BoundSessionUnavailableError(
|
||||
sessionId,
|
||||
`HTTP 503 (${String(body.code)})`,
|
||||
);
|
||||
}
|
||||
if (res.status === 409) {
|
||||
const body = (await res.json().catch(() => ({}))) as {
|
||||
code?: unknown;
|
||||
};
|
||||
if (
|
||||
body.code === 'session_archived' ||
|
||||
body.code === 'worktree_session_superseded'
|
||||
) {
|
||||
// Permanent states: an archived or superseded session is never
|
||||
// restored (same classification as the wake path). Fall back to
|
||||
// the channel path rather than burning the failure budget.
|
||||
throw new BoundSessionGoneError(sessionId);
|
||||
}
|
||||
if (
|
||||
body.code === 'restore_in_progress' ||
|
||||
body.code === 'session_busy' ||
|
||||
body.code === 'worktree_reset_active' ||
|
||||
body.code === 'working_directory_missing'
|
||||
) {
|
||||
// Reversible barriers: a restore in flight, a busy session, an
|
||||
// armed worktree-reset prompt barrier (the reset flow tells
|
||||
// channel-worker callers to retry), or a reserved standalone
|
||||
// session whose managed conversation binding has not released
|
||||
// yet (the daemon marks it retryable with Retry-After). Defer,
|
||||
// don't burn the budget.
|
||||
throw new BoundSessionUnavailableError(
|
||||
sessionId,
|
||||
`HTTP 409 (${String(body.code)})`,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`POST /session/:id/prompt failed: HTTP 409 (${String(body.code)})`,
|
||||
);
|
||||
}
|
||||
if (res.status === 429 || res.status >= 500) {
|
||||
// The daemon's own rate limiter answers 429 rate_limit_exceeded
|
||||
// (prompt tier, keyed on the worker's client id — one bucket for
|
||||
// every bound delivery of the channel), and other 5xx are daemon
|
||||
// transient states. Both are daemon-signalled backpressure: like
|
||||
// the sibling 503s they must defer, not consume the failure budget
|
||||
// (three consecutive 429s would otherwise dead-letter a watch
|
||||
// event irrecoverably). The retries themselves keep the bucket
|
||||
// saturated, so the deferral horizon is the escape.
|
||||
throw new BoundSessionUnavailableError(
|
||||
sessionId,
|
||||
`HTTP ${res.status} (daemon backpressure)`,
|
||||
);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`POST /session/:id/prompt failed: HTTP ${res.status}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// An authoritative Gone is durable evidence: evict the dead session's
|
||||
// cached cwd so a permanent partial snapshot cannot make every later
|
||||
// delivery pay a doomed resume round-trip (and the map grow without
|
||||
// bound). A post-wake retry 404 is NOT Gone (it classifies as
|
||||
// Unavailable), so a possibly-reversible lifecycle state never
|
||||
// evicts a live session's wake cwd.
|
||||
if (err instanceof BoundSessionGoneError) {
|
||||
cwdBySession.delete(sessionId);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
// Never leave a response body unconsumed: an unread undici body
|
||||
// holds its connection until GC, leaking sockets in this
|
||||
// long-lived worker on every delivery, probe and failure.
|
||||
await discardBody(res);
|
||||
// Release the wake registration once the retried prompt settled
|
||||
// (accepted or not). Detaching before the retry could close the
|
||||
// session ahead of the POST and misclassify the 404 as gone.
|
||||
if (releaseWake) {
|
||||
await releaseWake().catch(() => {});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createDaemonChannelBridgeFacade(
|
||||
bridge: ChannelAgentBridge,
|
||||
opts: { exposeBtw: boolean; exposeShellCommand: boolean },
|
||||
@@ -658,6 +1020,69 @@ export async function runChannelDaemonWorker(
|
||||
SESSION_SHELL_COMMAND_FEATURE,
|
||||
),
|
||||
});
|
||||
const boundSessions = createBoundSessionsAccessor(
|
||||
client,
|
||||
async (sessionId, workspaceCwd) => {
|
||||
// Same restore path the channel bridge uses for its own dormant
|
||||
// sessions. The restore mints a client registration; return its
|
||||
// release so the accessor can detach once the retried prompt
|
||||
// settles — a non-empty registration blocks the daemon's
|
||||
// prompt-settled idle close and would pin the session live until
|
||||
// the TTL reaper (or forever with the reaper disabled).
|
||||
try {
|
||||
const restored = await sdk.DaemonSessionClient.resume(
|
||||
client,
|
||||
sessionId,
|
||||
{ workspaceCwd, sessionScope: 'thread' },
|
||||
`qwen-channel-worker:${process.pid}`,
|
||||
);
|
||||
return restored.detach?.bind(restored);
|
||||
} catch (err) {
|
||||
const status = (err as { status?: unknown }).status;
|
||||
const code = (err as { body?: { code?: unknown } }).body?.code;
|
||||
// A missing session (404) is gone. An archived session is not
|
||||
// restorable either (409 session_archived) and is excluded from
|
||||
// the candidate set by contract, so it takes the same fallback.
|
||||
// A superseded session (409 worktree_session_superseded) is never
|
||||
// restored again — the worktree reset severed it permanently. The
|
||||
// interrupted-worktree shapes (409 worktree_marker_missing /
|
||||
// worktree_reset_interrupted) do not converge under a RESUME retry
|
||||
// — the repair is a worktree-reset retry — so deferring them would
|
||||
// dead-letter after the horizon without ever falling back. All of
|
||||
// them are gone for delivery purposes, not retry-later backpressure.
|
||||
if (
|
||||
status === 404 ||
|
||||
(status === 409 &&
|
||||
(code === 'session_archived' ||
|
||||
code === 'worktree_session_superseded' ||
|
||||
code === 'worktree_marker_missing' ||
|
||||
code === 'worktree_reset_interrupted'))
|
||||
) {
|
||||
throw new BoundSessionGoneError(sessionId);
|
||||
}
|
||||
// Draining/transitioning workspaces (400 workspace_mismatch,
|
||||
// 503 unavailable), restore conflicts and session limits are
|
||||
// retry-later backpressure, not delivery failures: burning the
|
||||
// attempt budget on them would silently lose the event. The
|
||||
// mutation rate limit (429) and spawn/handshake jitter (any
|
||||
// 5xx) are equally transient — a poll waking many dormant
|
||||
// sessions can hit the resume rate limit in one burst.
|
||||
if (
|
||||
status === 429 ||
|
||||
(typeof status === 'number' && status >= 500) ||
|
||||
status === 409 ||
|
||||
(status === 400 && code === 'workspace_mismatch')
|
||||
) {
|
||||
throw new BoundSessionUnavailableError(
|
||||
sessionId,
|
||||
`wake HTTP ${String(status)} (${String(code)})`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
opts.promptAuthorization,
|
||||
);
|
||||
const createdRouter = new SessionRouter(
|
||||
bridgeFacade,
|
||||
daemonWorkspace,
|
||||
@@ -689,6 +1114,7 @@ export async function runChannelDaemonWorker(
|
||||
...(proxy ? { proxy } : {}),
|
||||
...(displayLanguage ? { displayLanguage } : {}),
|
||||
router: createdRouter,
|
||||
boundSessions,
|
||||
stateDir: daemonChannelStateDir(daemonWorkspace, name),
|
||||
channelMemory: {
|
||||
readChannelMemory,
|
||||
|
||||
@@ -3,6 +3,19 @@ const workspacesByToken = new Map<string, string>();
|
||||
export const CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY =
|
||||
'qwen.daemon.channelPromptAuthorization';
|
||||
|
||||
/**
|
||||
* Explicit bound-relay intent marker. Cross-workspace channel classification
|
||||
* requires it IN ADDITION to a registered worker token and a trusted target:
|
||||
* the process-level credential exists for `createBoundSessionsAccessor`
|
||||
* relays, not for any prompt a worker happens to address at another
|
||||
* workspace. A plain (non-relay) prompt carrying a foreign worker token is
|
||||
* classified as an ordinary interactive prompt. The marker is intent, not
|
||||
* authentication — a token holder can set it; the security boundary remains
|
||||
* token possession plus the target's trust (documented in the design's
|
||||
* Constraints and risks).
|
||||
*/
|
||||
export const CHANNEL_BOUND_RELAY_META_KEY = 'qwen.daemon.channelBoundRelay';
|
||||
|
||||
export function registerChannelWorkerPromptAuthorization(
|
||||
token: string,
|
||||
workspaceCwd: string,
|
||||
@@ -22,3 +35,18 @@ export function isChannelWorkerPromptAuthorized(
|
||||
typeof token === 'string' && workspacesByToken.get(token) === workspaceCwd
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process-level relay check: the token belongs to a registered channel
|
||||
* worker, regardless of which workspace it was launched for. Bound delivery
|
||||
* is cross-workspace by design (the bindings route is process-global and
|
||||
* lists every served workspace). The prompt route uses this ONLY together
|
||||
* with the explicit CHANNEL_BOUND_RELAY_META_KEY marker and the target
|
||||
* runtime's trust (primary or trusted); a plain prompt carrying a foreign
|
||||
* worker token gets no channel classification. The per-workspace check
|
||||
* above stays the narrow observable for the supervisor's home-workspace
|
||||
* registration and for same-workspace channel turns.
|
||||
*/
|
||||
export function isChannelWorkerPromptToken(token: unknown): boolean {
|
||||
return typeof token === 'string' && workspacesByToken.has(token);
|
||||
}
|
||||
@@ -3194,9 +3194,11 @@ describe('multi-workspace session dispatch', () => {
|
||||
.set('Host', host())
|
||||
.send({ cwd: UNKNOWN_CWD });
|
||||
|
||||
expect(unknownRes.status).toBe(400);
|
||||
expect(unknownRes.body.code).toBe('workspace_mismatch');
|
||||
expect(unknownRes.body.workspaceCount).toBe(2);
|
||||
// An explicit cwd with no registry entry is a permanent absence: 404
|
||||
// workspace_not_found (the channel wake path keys Gone on it), not the
|
||||
// reversible-state 400 workspace_mismatch.
|
||||
expect(unknownRes.status).toBe(404);
|
||||
expect(unknownRes.body.code).toBe('workspace_not_found');
|
||||
expect(unknownRes.body.boundWorkspace).toBe(PRIMARY_CWD);
|
||||
expect(unknownRes.body.requestedWorkspace).toBe(UNKNOWN_CWD);
|
||||
expect(unknown.primaryBridge.restoreCalls).toEqual([]);
|
||||
|
||||
@@ -0,0 +1,731 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { ListWorkspaceSessionsResult } from '../server/session-list.js';
|
||||
import type {
|
||||
WorkspaceEntry,
|
||||
WorkspaceEntryState,
|
||||
WorkspaceRuntime,
|
||||
} from '../workspace-registry.js';
|
||||
import { createWorkspaceGenerationGuard } from '../workspace-registry.js';
|
||||
import { registerSessionPrBindingsRoutes } from './session-pr-bindings.js';
|
||||
|
||||
const sessionListMocks = vi.hoisted(() => ({
|
||||
listForResponse: vi.fn<
|
||||
(
|
||||
bridge: unknown,
|
||||
workspaceCwd: string,
|
||||
options?: { archiveState?: string; size?: number; cursor?: string },
|
||||
readOptions?: {
|
||||
mergeLive?: boolean;
|
||||
paginateMerged?: boolean;
|
||||
runtimeBaseDir?: string;
|
||||
},
|
||||
) => Promise<ListWorkspaceSessionsResult>
|
||||
>(),
|
||||
}));
|
||||
vi.mock('../server/session-list.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../server/session-list.js')>()),
|
||||
listWorkspaceSessionsForResponse: sessionListMocks.listForResponse,
|
||||
}));
|
||||
|
||||
function makeRuntime(
|
||||
overrides: Partial<{
|
||||
trusted: boolean;
|
||||
primary: boolean;
|
||||
workspaceCwd: string;
|
||||
}> = {},
|
||||
): WorkspaceRuntime {
|
||||
return {
|
||||
trusted: true,
|
||||
primary: false,
|
||||
workspaceCwd: '/ws/a',
|
||||
sessionRuntimeBaseDir: '/tmp/runtime-a',
|
||||
bridge: {},
|
||||
...overrides,
|
||||
} as unknown as WorkspaceRuntime;
|
||||
}
|
||||
|
||||
function makeEntry(
|
||||
runtime?: WorkspaceRuntime,
|
||||
state: WorkspaceEntryState = 'active',
|
||||
): WorkspaceEntry {
|
||||
return {
|
||||
workspaceId: runtime?.workspaceCwd ?? 'ws-absent',
|
||||
workspaceCwd: runtime?.workspaceCwd ?? '/ws/absent',
|
||||
primary: false,
|
||||
removable: true,
|
||||
internal: false,
|
||||
registrationIds: [],
|
||||
lastGenerationId: 1,
|
||||
state,
|
||||
...(runtime
|
||||
? {
|
||||
current: {
|
||||
generationId: 1,
|
||||
policyRevision: 'rev-1',
|
||||
runtime,
|
||||
guard: createWorkspaceGenerationGuard(),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
configuredRevision: 'rev-1',
|
||||
appliedRevision: 'rev-1',
|
||||
};
|
||||
}
|
||||
|
||||
function page(
|
||||
sessions: Array<Record<string, unknown>>,
|
||||
nextCursor?: string,
|
||||
): ListWorkspaceSessionsResult {
|
||||
return {
|
||||
sessions: sessions as unknown as ListWorkspaceSessionsResult['sessions'],
|
||||
...(nextCursor !== undefined ? { nextCursor } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function appHarness(opts: {
|
||||
entries: WorkspaceEntry[];
|
||||
listSessions?: (
|
||||
runtime: WorkspaceRuntime,
|
||||
cursor: string | undefined,
|
||||
) => Promise<ListWorkspaceSessionsResult>;
|
||||
registryError?: Error;
|
||||
}) {
|
||||
const app = express();
|
||||
const sendBridgeError = vi.fn(
|
||||
(res: express.Response, err: unknown, meta: { route: string }) => {
|
||||
res.status(500).json({ error: String(err), route: meta.route });
|
||||
},
|
||||
);
|
||||
// No seam injected means the route uses the real session list path
|
||||
// (module-mocked above); the real-path test relies on that fallback.
|
||||
registerSessionPrBindingsRoutes(app, {
|
||||
workspaceRegistry: {
|
||||
listAllEntries: () => {
|
||||
if (opts.registryError) throw opts.registryError;
|
||||
return opts.entries;
|
||||
},
|
||||
} as never,
|
||||
sendBridgeError: sendBridgeError as never,
|
||||
listSessions: opts.listSessions,
|
||||
});
|
||||
return { app, listSessions: opts.listSessions, sendBridgeError };
|
||||
}
|
||||
|
||||
const BOUND_SESSION = {
|
||||
sessionId: 'session-1',
|
||||
updatedAt: '2026-11-01T10:00:00.000Z',
|
||||
sourceType: 'web-shell',
|
||||
prs: [
|
||||
{
|
||||
number: 101,
|
||||
url: 'https://github.com/acme/widgets/pull/101',
|
||||
state: 'open',
|
||||
createdAt: '2026-11-01T09:00:00.000Z',
|
||||
source: 'create',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('GET /sessions/pr-bindings', () => {
|
||||
it('projects bound sessions with their PR entries', async () => {
|
||||
const h = appHarness({
|
||||
entries: [makeEntry(makeRuntime())],
|
||||
listSessions: async () =>
|
||||
page([
|
||||
BOUND_SESSION,
|
||||
{ sessionId: 'session-2', prs: [] },
|
||||
// Real sidecar-less sessions have no prs key at all.
|
||||
{ sessionId: 'session-no-prs' },
|
||||
]),
|
||||
});
|
||||
|
||||
const res = await request(h.app).get('/sessions/pr-bindings');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Every successfully scanned session gets a row — an empty one is a
|
||||
// TOMBSTONE (W2/R1-8): callers must be able to tell "scanned, no
|
||||
// bindings" (an observed unbind) from "not enumerated by this answer".
|
||||
expect(res.body).toEqual({
|
||||
bindings: [
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
workspaceCwd: '/ws/a',
|
||||
sourceType: 'web-shell',
|
||||
updatedAt: '2026-11-01T10:00:00.000Z',
|
||||
prs: [
|
||||
{
|
||||
number: 101,
|
||||
url: 'https://github.com/acme/widgets/pull/101',
|
||||
state: 'open',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ sessionId: 'session-2', workspaceCwd: '/ws/a', prs: [] },
|
||||
{ sessionId: 'session-no-prs', workspaceCwd: '/ws/a', prs: [] },
|
||||
],
|
||||
partial: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('flags an unreadable PR sidecar as partial instead of an unbind (W2, R1-8)', async () => {
|
||||
const h = appHarness({
|
||||
entries: [makeEntry(makeRuntime())],
|
||||
listSessions: async () =>
|
||||
page([
|
||||
{ sessionId: 'session-unbound', prs: [] },
|
||||
// The projection sets prsReadFailed when the sidecar EXISTS but
|
||||
// could not be read (I/O error, corrupt JSON, invalid shape).
|
||||
{ sessionId: 'session-corrupt', prsReadFailed: true },
|
||||
]),
|
||||
});
|
||||
|
||||
const res = await request(h.app).get('/sessions/pr-bindings');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// The unreadable session's bindings are UNKNOWN: omitting it without
|
||||
// partial would advertise an authoritative absence over bindings that
|
||||
// may still exist, degrading private delivery to the public path.
|
||||
expect(res.body.partial).toBe(true);
|
||||
expect(res.body.bindings).toEqual([
|
||||
{ sessionId: 'session-unbound', workspaceCwd: '/ws/a', prs: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to createdAt when a live summary lacks updatedAt', async () => {
|
||||
const h = appHarness({
|
||||
entries: [makeEntry(makeRuntime())],
|
||||
listSessions: async () =>
|
||||
page([
|
||||
{
|
||||
sessionId: 'live-session',
|
||||
createdAt: '2026-11-02T08:28:06.000Z',
|
||||
prs: [
|
||||
{
|
||||
number: 101,
|
||||
url: 'https://github.com/acme/widgets/pull/101',
|
||||
state: 'open',
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
const res = await request(h.app).get('/sessions/pr-bindings');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.bindings[0].updatedAt).toBe('2026-11-02T08:28:06.000Z');
|
||||
});
|
||||
|
||||
it('skips untrusted workspaces entirely', async () => {
|
||||
const listSessions = vi.fn(async () => page([BOUND_SESSION]));
|
||||
const h = appHarness({
|
||||
entries: [makeEntry(makeRuntime({ trusted: false }))],
|
||||
listSessions,
|
||||
});
|
||||
|
||||
const res = await request(h.app).get('/sessions/pr-bindings');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ bindings: [], partial: false });
|
||||
expect(listSessions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('follows pagination cursors', async () => {
|
||||
const listSessions = vi.fn(
|
||||
async (_runtime: WorkspaceRuntime, cursor: string | undefined) =>
|
||||
cursor === undefined
|
||||
? page([BOUND_SESSION], 'cursor-2')
|
||||
: page([
|
||||
{
|
||||
...BOUND_SESSION,
|
||||
sessionId: 'session-3',
|
||||
},
|
||||
]),
|
||||
);
|
||||
const h = appHarness({ entries: [makeEntry(makeRuntime())], listSessions });
|
||||
|
||||
const res = await request(h.app).get('/sessions/pr-bindings');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(listSessions).toHaveBeenCalledTimes(2);
|
||||
expect(listSessions).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.anything(),
|
||||
'cursor-2',
|
||||
);
|
||||
expect(
|
||||
res.body.bindings.map((b: { sessionId: string }) => b.sessionId),
|
||||
).toEqual(['session-1', 'session-3']);
|
||||
// Activity-ordered pagination is not snapshot-isolated: any multi-page
|
||||
// scan is non-authoritative even when every page succeeds cleanly.
|
||||
expect(res.body.partial).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps other workspaces when one fails to list', async () => {
|
||||
const entries = [
|
||||
makeEntry(makeRuntime({ workspaceCwd: '/ws/broken' })),
|
||||
makeEntry(makeRuntime({ workspaceCwd: '/ws/ok' })),
|
||||
];
|
||||
const h = appHarness({
|
||||
entries,
|
||||
listSessions: async (runtime) => {
|
||||
if (runtime.workspaceCwd === '/ws/broken') {
|
||||
throw new Error('disk on fire');
|
||||
}
|
||||
return page([BOUND_SESSION]);
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(h.app).get('/sessions/pr-bindings');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.bindings).toHaveLength(1);
|
||||
expect(res.body.bindings[0].workspaceCwd).toBe('/ws/ok');
|
||||
expect(res.body.partial).toBe(true);
|
||||
});
|
||||
|
||||
it('flags partial when the page cap is exhausted with cursors remaining', async () => {
|
||||
const listSessions = vi.fn(async () => page([BOUND_SESSION], 'more'));
|
||||
const h = appHarness({ entries: [makeEntry(makeRuntime())], listSessions });
|
||||
|
||||
const res = await request(h.app).get('/sessions/pr-bindings');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.partial).toBe(true);
|
||||
expect(listSessions).toHaveBeenCalledTimes(50);
|
||||
});
|
||||
|
||||
it('propagates page-level truncation as partial', async () => {
|
||||
const listSessions = vi.fn(async () => ({
|
||||
...page([BOUND_SESSION]),
|
||||
truncated: true,
|
||||
}));
|
||||
const h = appHarness({ entries: [makeEntry(makeRuntime())], listSessions });
|
||||
|
||||
const res = await request(h.app).get('/sessions/pr-bindings');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.partial).toBe(true);
|
||||
});
|
||||
|
||||
it('flags partial on a live-merge degraded success', async () => {
|
||||
const listSessions = vi.fn(async () => ({
|
||||
...page([BOUND_SESSION]),
|
||||
liveMergeFailed: true,
|
||||
}));
|
||||
const h = appHarness({ entries: [makeEntry(makeRuntime())], listSessions });
|
||||
|
||||
const res = await request(h.app).get('/sessions/pr-bindings');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Degraded success keeps the data it could read but stays
|
||||
// non-authoritative even when sessions are non-empty.
|
||||
expect(
|
||||
res.body.bindings.map((b: { sessionId: string }) => b.sessionId),
|
||||
).toEqual(['session-1']);
|
||||
expect(res.body.partial).toBe(true);
|
||||
});
|
||||
|
||||
it('routes registry failures through sendBridgeError', async () => {
|
||||
const h = appHarness({
|
||||
entries: [],
|
||||
registryError: new Error('registry closed'),
|
||||
});
|
||||
|
||||
const res = await request(h.app).get('/sessions/pr-bindings');
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(h.sendBridgeError).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.any(Error),
|
||||
{ route: 'GET /sessions/pr-bindings' },
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['draining', 'transitioning', 'blocked'] as const)(
|
||||
'flags partial when a trusted workspace is %s',
|
||||
async (state) => {
|
||||
const listSessions = vi.fn(async (_runtime: WorkspaceRuntime) =>
|
||||
page([BOUND_SESSION]),
|
||||
);
|
||||
const h = appHarness({
|
||||
entries: [
|
||||
makeEntry(makeRuntime({ workspaceCwd: '/ws/lifecycle' }), state),
|
||||
makeEntry(makeRuntime({ workspaceCwd: '/ws/active' })),
|
||||
],
|
||||
listSessions,
|
||||
});
|
||||
|
||||
const res = await request(h.app).get('/sessions/pr-bindings');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// The non-active workspace still serves sessions; its absence is not
|
||||
// an authoritative "no bindings".
|
||||
expect(res.body.partial).toBe(true);
|
||||
expect(res.body.bindings).toHaveLength(1);
|
||||
expect(res.body.bindings[0].sessionId).toBe('session-1');
|
||||
expect(res.body.bindings[0].workspaceCwd).toBe('/ws/active');
|
||||
expect(listSessions).toHaveBeenCalledTimes(1);
|
||||
expect(listSessions.mock.calls[0][0].workspaceCwd).toBe('/ws/active');
|
||||
},
|
||||
);
|
||||
|
||||
it('flags partial when an entry has no current runtime', async () => {
|
||||
const h = appHarness({
|
||||
entries: [makeEntry(undefined), makeEntry(makeRuntime())],
|
||||
listSessions: async () => page([BOUND_SESSION]),
|
||||
});
|
||||
|
||||
const res = await request(h.app).get('/sessions/pr-bindings');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.partial).toBe(true);
|
||||
expect(res.body.bindings).toHaveLength(1);
|
||||
expect(res.body.bindings[0].sessionId).toBe('session-1');
|
||||
});
|
||||
|
||||
it('resets the pagination cursor per workspace', async () => {
|
||||
const listSessions = vi.fn(
|
||||
async (runtime: WorkspaceRuntime, cursor: string | undefined) => {
|
||||
const label = runtime.workspaceCwd === '/ws/a' ? 'a' : 'b';
|
||||
return cursor === undefined
|
||||
? page(
|
||||
[{ ...BOUND_SESSION, sessionId: `${label}-1` }],
|
||||
`cursor-${label}-2`,
|
||||
)
|
||||
: page([{ ...BOUND_SESSION, sessionId: `${label}-2` }]);
|
||||
},
|
||||
);
|
||||
const h = appHarness({
|
||||
entries: [
|
||||
makeEntry(makeRuntime({ workspaceCwd: '/ws/a' })),
|
||||
makeEntry(makeRuntime({ workspaceCwd: '/ws/b' })),
|
||||
],
|
||||
listSessions,
|
||||
});
|
||||
|
||||
const res = await request(h.app).get('/sessions/pr-bindings');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(listSessions).toHaveBeenCalledTimes(4);
|
||||
// Each workspace's FIRST call must start without a cursor, and its
|
||||
// second call must forward its own cursor, not the previous
|
||||
// workspace's residual one.
|
||||
expect(listSessions.mock.calls[0][1]).toBeUndefined();
|
||||
expect(listSessions).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.anything(),
|
||||
'cursor-a-2',
|
||||
);
|
||||
expect(listSessions).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.anything(),
|
||||
undefined,
|
||||
);
|
||||
expect(listSessions).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
expect.anything(),
|
||||
'cursor-b-2',
|
||||
);
|
||||
expect(
|
||||
res.body.bindings.map((b: { sessionId: string }) => b.sessionId),
|
||||
).toEqual(['a-1', 'a-2', 'b-1', 'b-2']);
|
||||
});
|
||||
|
||||
it('keeps the page cap per workspace', async () => {
|
||||
const pagesByWorkspace = new Map<string, number>();
|
||||
const listSessions = vi.fn(async (runtime: WorkspaceRuntime) => {
|
||||
const n = (pagesByWorkspace.get(runtime.workspaceCwd) ?? 0) + 1;
|
||||
pagesByWorkspace.set(runtime.workspaceCwd, n);
|
||||
const session = {
|
||||
...BOUND_SESSION,
|
||||
sessionId: `${runtime.workspaceCwd}#${n}`,
|
||||
};
|
||||
return n < 30 ? page([session], `cursor-${n}`) : page([session]);
|
||||
});
|
||||
const h = appHarness({
|
||||
entries: [
|
||||
makeEntry(makeRuntime({ workspaceCwd: '/ws/a' })),
|
||||
makeEntry(makeRuntime({ workspaceCwd: '/ws/b' })),
|
||||
],
|
||||
listSessions,
|
||||
});
|
||||
|
||||
const res = await request(h.app).get('/sessions/pr-bindings');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// 30 pages per workspace (60 total > MAX_PAGES): each workspace gets
|
||||
// its own full budget, so both tail sessions must appear.
|
||||
expect(listSessions).toHaveBeenCalledTimes(60);
|
||||
expect(res.body.bindings).toHaveLength(60);
|
||||
const ids = res.body.bindings.map(
|
||||
(b: { sessionId: string }) => b.sessionId,
|
||||
);
|
||||
expect(ids).toContain('/ws/a#30');
|
||||
expect(ids).toContain('/ws/b#30');
|
||||
// Multi-page scans are non-authoritative regardless of the cap.
|
||||
expect(res.body.partial).toBe(true);
|
||||
});
|
||||
|
||||
it('skips untrusted workspaces but keeps scanning trusted ones', async () => {
|
||||
const listSessions = vi.fn(async (_runtime: WorkspaceRuntime) =>
|
||||
page([BOUND_SESSION]),
|
||||
);
|
||||
const h = appHarness({
|
||||
entries: [
|
||||
makeEntry(
|
||||
makeRuntime({ trusted: false, workspaceCwd: '/ws/untrusted' }),
|
||||
),
|
||||
makeEntry(makeRuntime({ workspaceCwd: '/ws/trusted' })),
|
||||
],
|
||||
listSessions,
|
||||
});
|
||||
|
||||
const res = await request(h.app).get('/sessions/pr-bindings');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.partial).toBe(false);
|
||||
expect(res.body.bindings).toHaveLength(1);
|
||||
expect(res.body.bindings[0].sessionId).toBe('session-1');
|
||||
expect(res.body.bindings[0].workspaceCwd).toBe('/ws/trusted');
|
||||
expect(listSessions).toHaveBeenCalledTimes(1);
|
||||
expect(listSessions.mock.calls[0][0].workspaceCwd).toBe('/ws/trusted');
|
||||
});
|
||||
|
||||
it('aggregates bindings across multiple healthy trusted workspaces', async () => {
|
||||
const h = appHarness({
|
||||
entries: [
|
||||
makeEntry(makeRuntime({ workspaceCwd: '/ws/a' })),
|
||||
makeEntry(makeRuntime({ workspaceCwd: '/ws/b' })),
|
||||
],
|
||||
listSessions: async (runtime) =>
|
||||
page([
|
||||
{
|
||||
...BOUND_SESSION,
|
||||
sessionId:
|
||||
runtime.workspaceCwd === '/ws/a' ? 'session-a' : 'session-b',
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
const res = await request(h.app).get('/sessions/pr-bindings');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.partial).toBe(false);
|
||||
expect(res.body.bindings).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: 'session-a',
|
||||
workspaceCwd: '/ws/a',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
sessionId: 'session-b',
|
||||
workspaceCwd: '/ws/b',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('retains already-listed pages when a later page fails', async () => {
|
||||
const listSessions = vi.fn(
|
||||
async (_runtime: WorkspaceRuntime, cursor: string | undefined) => {
|
||||
if (cursor === undefined) return page([BOUND_SESSION], 'cursor-2');
|
||||
throw new Error('bridge hiccup');
|
||||
},
|
||||
);
|
||||
const h = appHarness({ entries: [makeEntry(makeRuntime())], listSessions });
|
||||
|
||||
const res = await request(h.app).get('/sessions/pr-bindings');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(
|
||||
res.body.bindings.map((b: { sessionId: string }) => b.sessionId),
|
||||
).toEqual(['session-1']);
|
||||
expect(res.body.partial).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves merged and closed PR states verbatim', async () => {
|
||||
const h = appHarness({
|
||||
entries: [makeEntry(makeRuntime())],
|
||||
listSessions: async () =>
|
||||
page([
|
||||
{
|
||||
sessionId: 'session-terminal',
|
||||
prs: [
|
||||
{
|
||||
number: 101,
|
||||
url: 'https://github.com/acme/widgets/pull/101',
|
||||
state: 'merged',
|
||||
},
|
||||
{
|
||||
number: 102,
|
||||
url: 'https://github.com/acme/widgets/pull/102',
|
||||
state: 'closed',
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
const res = await request(h.app).get('/sessions/pr-bindings');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.partial).toBe(false);
|
||||
expect(res.body.bindings).toHaveLength(1);
|
||||
expect(res.body.bindings[0].sessionId).toBe('session-terminal');
|
||||
// Terminal states are load-bearing for downstream retention; they must
|
||||
// survive the projection unchanged.
|
||||
expect(res.body.bindings[0].prs).toEqual([
|
||||
{
|
||||
number: 101,
|
||||
url: 'https://github.com/acme/widgets/pull/101',
|
||||
state: 'merged',
|
||||
},
|
||||
{
|
||||
number: 102,
|
||||
url: 'https://github.com/acme/widgets/pull/102',
|
||||
state: 'closed',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('flags partial when skipping an untrusted primary workspace', async () => {
|
||||
const listSessions = vi.fn(async () => page([BOUND_SESSION]));
|
||||
const h = appHarness({
|
||||
entries: [makeEntry(makeRuntime({ trusted: false, primary: true }))],
|
||||
listSessions,
|
||||
});
|
||||
|
||||
const res = await request(h.app).get('/sessions/pr-bindings');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Session creation/restore exempts primary from the trust gate, so an
|
||||
// untrusted primary still serves bound sessions: skipping it must not
|
||||
// hand callers an authoritative absence.
|
||||
expect(res.body).toEqual({ bindings: [], partial: true });
|
||||
expect(listSessions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips an untrusted non-primary workspace without forcing partial', async () => {
|
||||
const listSessions = vi.fn(async () => page([BOUND_SESSION]));
|
||||
const h = appHarness({
|
||||
entries: [makeEntry(makeRuntime({ trusted: false, primary: false }))],
|
||||
listSessions,
|
||||
});
|
||||
|
||||
const res = await request(h.app).get('/sessions/pr-bindings');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Non-primary untrusted workspaces cannot create or restore sessions,
|
||||
// so their absence is legitimate and the answer stays authoritative.
|
||||
expect(res.body).toEqual({ bindings: [], partial: false });
|
||||
expect(listSessions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scans active sessions with the real list path options', async () => {
|
||||
sessionListMocks.listForResponse
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce({ sessions: [], nextCursor: 'cursor-2' })
|
||||
.mockResolvedValueOnce({ sessions: [] });
|
||||
// No listSessions seam: exercise the real listActiveSessionsPage path.
|
||||
const h = appHarness({ entries: [makeEntry(makeRuntime())] });
|
||||
|
||||
const res = await request(h.app).get('/sessions/pr-bindings');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ bindings: [], partial: true });
|
||||
expect(sessionListMocks.listForResponse).toHaveBeenCalledTimes(2);
|
||||
const firstCall = sessionListMocks.listForResponse.mock.calls[0];
|
||||
expect(firstCall[1]).toBe('/ws/a');
|
||||
// The reverse lookup must only ever see active sessions, at the full
|
||||
// page size the scan budget assumes.
|
||||
expect(firstCall[2]).toEqual({ archiveState: 'active', size: 100 });
|
||||
expect(firstCall[3]).toEqual({
|
||||
mergeLive: true,
|
||||
paginateMerged: true,
|
||||
runtimeBaseDir: '/tmp/runtime-a',
|
||||
});
|
||||
expect(sessionListMocks.listForResponse.mock.calls[1][2]).toEqual({
|
||||
archiveState: 'active',
|
||||
size: 100,
|
||||
cursor: 'cursor-2',
|
||||
});
|
||||
});
|
||||
|
||||
it('includes the cap-hit page bindings when the page cap is exhausted', async () => {
|
||||
let pages = 0;
|
||||
const listSessions = vi.fn(async () => {
|
||||
pages += 1;
|
||||
return page(
|
||||
[{ ...BOUND_SESSION, sessionId: `session-${pages}` }],
|
||||
`cursor-${pages + 1}`,
|
||||
);
|
||||
});
|
||||
const h = appHarness({ entries: [makeEntry(makeRuntime())], listSessions });
|
||||
|
||||
const res = await request(h.app).get('/sessions/pr-bindings');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(listSessions).toHaveBeenCalledTimes(50);
|
||||
expect(res.body.partial).toBe(true);
|
||||
const ids = res.body.bindings.map(
|
||||
(b: { sessionId: string }) => b.sessionId,
|
||||
);
|
||||
expect(ids).toHaveLength(50);
|
||||
// The cap-hit (50th) page is fetched and projected before the loop
|
||||
// breaks; dropping its data would silently lose the newest bindings.
|
||||
expect(ids[0]).toBe('session-1');
|
||||
expect(ids[49]).toBe('session-50');
|
||||
});
|
||||
|
||||
it('treats a complete empty scan as an authoritative absence', async () => {
|
||||
const h = appHarness({
|
||||
entries: [makeEntry(makeRuntime())],
|
||||
listSessions: async () => page([]),
|
||||
});
|
||||
|
||||
const res = await request(h.app).get('/sessions/pr-bindings');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// A complete scan that found nothing is the one authoritative empty
|
||||
// answer: callers may prune watch state on it (adapter cold start).
|
||||
expect(res.body).toEqual({ bindings: [], partial: false });
|
||||
});
|
||||
|
||||
it('logs the failing workspace cwd to stderr and flags partial', async () => {
|
||||
const stderrWrites: string[] = [];
|
||||
const stderr = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation((chunk) => {
|
||||
stderrWrites.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
try {
|
||||
const h = appHarness({
|
||||
entries: [makeEntry(makeRuntime({ workspaceCwd: '/ws/broken' }))],
|
||||
listSessions: async () => {
|
||||
throw new Error('disk on fire');
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(h.app).get('/sessions/pr-bindings');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ bindings: [], partial: true });
|
||||
// A persistent partial must name its source, or the channel stalls
|
||||
// with zero log lines pointing at the unreadable workspace.
|
||||
const logged = stderrWrites.join('');
|
||||
expect(logged).toContain('PR bindings scan failed for workspace');
|
||||
expect(logged).toContain('/ws/broken');
|
||||
expect(logged).toContain('disk on fire');
|
||||
} finally {
|
||||
stderr.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Application } from 'express';
|
||||
import { sanitizeLogText } from '@qwen-code/channel-base';
|
||||
import type { SendBridgeError } from '../server/error-response.js';
|
||||
import {
|
||||
listWorkspaceSessionsForResponse,
|
||||
type ListWorkspaceSessionsResult,
|
||||
} from '../server/session-list.js';
|
||||
import { runWithWorkspaceRuntimeStorage } from '../workspace-runtime-storage.js';
|
||||
import type {
|
||||
WorkspaceRegistry,
|
||||
WorkspaceRuntime,
|
||||
} from '../workspace-registry.js';
|
||||
|
||||
/**
|
||||
* Process-global, read-only reverse lookup: every active session that has PR
|
||||
* bindings, across trusted workspaces. Channel workers use it to route GitHub
|
||||
* events into the session that follows a PR instead of a channel-owned
|
||||
* session. Same ownership class as `POST /sessions/backfill-prs`.
|
||||
*/
|
||||
|
||||
export interface SessionPrBindingEntry {
|
||||
number: number;
|
||||
url: string;
|
||||
state?: 'open' | 'merged' | 'closed';
|
||||
}
|
||||
|
||||
export interface SessionPrBindingSummary {
|
||||
sessionId: string;
|
||||
workspaceCwd: string;
|
||||
sourceType?: string;
|
||||
updatedAt?: string;
|
||||
prs: SessionPrBindingEntry[];
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
// Bounds one workspace's scan so a pathological catalog cannot pin the route.
|
||||
const MAX_PAGES = 50;
|
||||
|
||||
export interface SessionPrBindingsRouteDeps {
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
sendBridgeError: SendBridgeError;
|
||||
/** Test seam; the daemon uses the real session list. */
|
||||
listSessions?: (
|
||||
runtime: WorkspaceRuntime,
|
||||
cursor: string | undefined,
|
||||
) => Promise<ListWorkspaceSessionsResult>;
|
||||
}
|
||||
|
||||
async function listActiveSessionsPage(
|
||||
runtime: WorkspaceRuntime,
|
||||
cursor: string | undefined,
|
||||
): Promise<ListWorkspaceSessionsResult> {
|
||||
return runWithWorkspaceRuntimeStorage(runtime, () =>
|
||||
listWorkspaceSessionsForResponse(
|
||||
runtime.bridge,
|
||||
runtime.workspaceCwd,
|
||||
{
|
||||
archiveState: 'active',
|
||||
size: PAGE_SIZE,
|
||||
...(cursor !== undefined ? { cursor } : {}),
|
||||
},
|
||||
{
|
||||
mergeLive: true,
|
||||
paginateMerged: true,
|
||||
runtimeBaseDir: runtime.sessionRuntimeBaseDir,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function registerSessionPrBindingsRoutes(
|
||||
app: Application,
|
||||
deps: SessionPrBindingsRouteDeps,
|
||||
): void {
|
||||
app.get('/sessions/pr-bindings', async (_req, res) => {
|
||||
const route = 'GET /sessions/pr-bindings';
|
||||
try {
|
||||
const listPage = deps.listSessions ?? listActiveSessionsPage;
|
||||
const bindings: SessionPrBindingSummary[] = [];
|
||||
// A missing binding is only an authoritative absence when the scan
|
||||
// was complete; callers hold state keyed by bindings and must not
|
||||
// destroy it on a partial answer.
|
||||
let partial = false;
|
||||
for (const entry of deps.workspaceRegistry.listAllEntries()) {
|
||||
const runtime = entry.current?.runtime;
|
||||
if (!runtime || entry.state !== 'active') {
|
||||
// A workspace that is draining/transitioning/blocked still serves
|
||||
// sessions but cannot be enumerated here. Its absence is NOT an
|
||||
// authoritative "no bindings": flag the answer incomplete so
|
||||
// callers retain known bindings and skip watch-state pruning.
|
||||
// listAllEntries() already excludes removed entries (legitimate
|
||||
// absence); untrusted workspaces are skipped by contract below.
|
||||
partial = true;
|
||||
continue;
|
||||
}
|
||||
if (!runtime.trusted) {
|
||||
// Session creation/restore exempts primary from the trust gate, so
|
||||
// an untrusted PRIMARY still serves bound sessions. Skipping it
|
||||
// without flagging partial would hand callers an authoritative
|
||||
// absence over bindings that still exist, degrading private
|
||||
// delivery to public (and pruning watch state on a trusted→
|
||||
// untrusted flip). Non-primary untrusted workspaces cannot create
|
||||
// or restore sessions, so their absence is legitimate.
|
||||
if (runtime.primary) partial = true;
|
||||
continue;
|
||||
}
|
||||
let cursor: string | undefined;
|
||||
try {
|
||||
let pages = 0;
|
||||
for (;;) {
|
||||
const result = await listPage(runtime, cursor);
|
||||
// A degraded success (live-merge failure) is as incomplete as a
|
||||
// truncated scan: callers must not treat it as authoritative.
|
||||
if (result.truncated || result.liveMergeFailed) partial = true;
|
||||
for (const session of result.sessions) {
|
||||
if (session.prsReadFailed) {
|
||||
// The sidecar exists but could not be read: this session's
|
||||
// bindings are UNKNOWN, not empty. Omitting it without
|
||||
// flagging would advertise an authoritative absence over
|
||||
// bindings that may still exist.
|
||||
partial = true;
|
||||
continue;
|
||||
}
|
||||
// Every successfully scanned session gets a row — including
|
||||
// an EMPTY one (a tombstone). "Scanned, no bindings" (an
|
||||
// observed unbind) must stay distinguishable from "this
|
||||
// answer did not enumerate the session": callers key
|
||||
// retention on the listed session set, and a dropped empty
|
||||
// row would let any partial answer revive the stale binding
|
||||
// and keep delivering privately after an explicit unbind.
|
||||
const prs = session.prs ?? [];
|
||||
bindings.push({
|
||||
sessionId: session.sessionId,
|
||||
workspaceCwd: runtime.workspaceCwd,
|
||||
...(session.sourceType !== undefined
|
||||
? { sourceType: session.sourceType }
|
||||
: {}),
|
||||
// Live-only summaries may lack updatedAt; createdAt is
|
||||
// always present and keeps fresh live sessions from losing
|
||||
// the recency comparison to stale persisted ones.
|
||||
...((session.updatedAt ?? session.createdAt) !== undefined
|
||||
? { updatedAt: session.updatedAt ?? session.createdAt }
|
||||
: {}),
|
||||
prs: prs.map((pr) => ({
|
||||
number: pr.number,
|
||||
url: pr.url,
|
||||
...(pr.state !== undefined ? { state: pr.state } : {}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
pages += 1;
|
||||
if (!result.nextCursor) break;
|
||||
// Activity-ordered pagination is not snapshot-isolated: a live
|
||||
// session whose activity advances between page fetches can be
|
||||
// skipped on every page while the scan still finishes clean.
|
||||
// Any multi-page scan is therefore non-authoritative.
|
||||
partial = true;
|
||||
if (pages >= MAX_PAGES) break;
|
||||
cursor = result.nextCursor;
|
||||
}
|
||||
} catch (err) {
|
||||
// One unreadable workspace must not hide the others' bindings;
|
||||
// the partial flag tells callers the answer is incomplete. Name
|
||||
// the workspace and cause: a persistent partial otherwise stalls
|
||||
// the channel with zero log lines pointing at the source.
|
||||
process.stderr.write(
|
||||
`[serve] PR bindings scan failed for workspace ${runtime.workspaceCwd}, answer partial: ${sanitizeLogText(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
200,
|
||||
)}\n`,
|
||||
);
|
||||
partial = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
res.status(200).json({ bindings, partial });
|
||||
} catch (err) {
|
||||
deps.sendBridgeError(res, err, { route });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -282,8 +282,10 @@ import {
|
||||
} from '../branch-worktree-preparation.js';
|
||||
import type { ChannelDeliveryAuthorizationStore } from '../channel-delivery-authorization.js';
|
||||
import {
|
||||
CHANNEL_BOUND_RELAY_META_KEY,
|
||||
CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY,
|
||||
isChannelWorkerPromptAuthorized,
|
||||
isChannelWorkerPromptToken,
|
||||
} from '../channel-worker-prompt-authorization.js';
|
||||
import {
|
||||
createRequestedSessionIdAdmission,
|
||||
@@ -376,6 +378,47 @@ const ARTIFACT_LIBRARY_CONCURRENCY = 4;
|
||||
// Keep a single request from expanding an unbounded number of content blocks.
|
||||
const MEDIA_CONTENT_MAX_BLOCKS = 256;
|
||||
|
||||
// Admission-side idempotency for POST /session/:id/prompt. A channel relay
|
||||
// marks acceptance durably AFTER the 202; a crash or write failure in that
|
||||
// gap (or a lost response) makes it retry, and the retry must not enqueue
|
||||
// the event twice. The ring maps a caller-supplied key to the promptId of
|
||||
// its first admission; a repeat returns that admission without re-enqueueing.
|
||||
// In-memory on purpose: the prompt queue it protects is in-memory too, so a
|
||||
// daemon restart loses both together and re-injection becomes the correct
|
||||
// recovery. Bounded per session and across sessions (FIFO eviction).
|
||||
const PROMPT_IDEMPOTENCY_KEY_MAX_LENGTH = 200;
|
||||
const PROMPT_IDEMPOTENCY_KEYS_PER_SESSION = 128;
|
||||
const PROMPT_IDEMPOTENCY_MAX_SESSIONS = 1024;
|
||||
const promptIdempotencyBySession = new Map<string, Map<string, string>>();
|
||||
|
||||
function rememberPromptIdempotencyKey(
|
||||
sessionId: string,
|
||||
key: string,
|
||||
promptId: string,
|
||||
): void {
|
||||
let keys = promptIdempotencyBySession.get(sessionId);
|
||||
if (!keys) {
|
||||
keys = new Map<string, string>();
|
||||
promptIdempotencyBySession.set(sessionId, keys);
|
||||
if (promptIdempotencyBySession.size > PROMPT_IDEMPOTENCY_MAX_SESSIONS) {
|
||||
const oldest = promptIdempotencyBySession.keys().next().value;
|
||||
if (oldest !== undefined) promptIdempotencyBySession.delete(oldest);
|
||||
}
|
||||
}
|
||||
keys.set(key, promptId);
|
||||
if (keys.size > PROMPT_IDEMPOTENCY_KEYS_PER_SESSION) {
|
||||
const oldest = keys.keys().next().value;
|
||||
if (oldest !== undefined) keys.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function forgetPromptIdempotencyKey(sessionId: string, key: string): void {
|
||||
const keys = promptIdempotencyBySession.get(sessionId);
|
||||
if (!keys) return;
|
||||
keys.delete(key);
|
||||
if (keys.size === 0) promptIdempotencyBySession.delete(sessionId);
|
||||
}
|
||||
|
||||
// SVG is allowed as an ordinary file resource but never as an inline image.
|
||||
// Compare the normalized media type so spelling variants cannot bypass the
|
||||
// image-block check.
|
||||
@@ -1129,6 +1172,24 @@ export function registerSessionRoutes(
|
||||
});
|
||||
};
|
||||
|
||||
// A workspace with no registry entry at all (removed, or never registered)
|
||||
// is a PERMANENT absence, unlike the reversible draining/transitioning
|
||||
// states that still have an entry and answer workspace_mismatch /
|
||||
// workspace_runtime_unavailable. Callers (e.g. the channel worker's
|
||||
// bound-session wake) may classify this 404 as gone without risking the
|
||||
// reversible states.
|
||||
const sendWorkspaceNotFound = (
|
||||
res: Response,
|
||||
requestedWorkspace: string,
|
||||
): void => {
|
||||
res.status(404).json({
|
||||
error: `Workspace not found: "${requestedWorkspace}" is not registered with this daemon.`,
|
||||
code: 'workspace_not_found',
|
||||
boundWorkspace,
|
||||
requestedWorkspace,
|
||||
});
|
||||
};
|
||||
|
||||
const resolveRuntimeForSessionCreation = (
|
||||
body: Record<string, unknown>,
|
||||
res: Response,
|
||||
@@ -1857,6 +1918,20 @@ export function registerSessionRoutes(
|
||||
? managedEntry.current?.runtime
|
||||
: workspaceRegistry.resolveWorkspaceCwd('cwd' in body ? key : undefined);
|
||||
if (!runtime) {
|
||||
// Distinguish permanent absence (no registry entry at all) from the
|
||||
// reversible non-active states (entry exists but is draining /
|
||||
// transitioning / blocked): only the former is a gone workspace.
|
||||
const entry =
|
||||
'cwd' in body
|
||||
? workspaceRegistry.getEntryByWorkspaceCwd(key)
|
||||
: undefined;
|
||||
if ('cwd' in body && !entry) {
|
||||
logSessionRoutingFailure(route, 'workspace_not_found', {
|
||||
requestedWorkspace: key,
|
||||
});
|
||||
sendWorkspaceNotFound(res, key);
|
||||
return undefined;
|
||||
}
|
||||
logSessionRoutingFailure(route, 'workspace_mismatch', {
|
||||
requestedWorkspace: key,
|
||||
});
|
||||
@@ -8467,6 +8542,22 @@ export function registerSessionRoutes(
|
||||
}
|
||||
requestDeadlineMs = rawRequestDeadline;
|
||||
}
|
||||
const rawIdempotencyKey = body['idempotencyKey'];
|
||||
let idempotencyKey: string | undefined;
|
||||
if (rawIdempotencyKey !== undefined && rawIdempotencyKey !== null) {
|
||||
if (
|
||||
typeof rawIdempotencyKey !== 'string' ||
|
||||
rawIdempotencyKey.length === 0 ||
|
||||
rawIdempotencyKey.length > PROMPT_IDEMPOTENCY_KEY_MAX_LENGTH
|
||||
) {
|
||||
res.status(400).json({
|
||||
error: `\`idempotencyKey\` must be a non-empty string of at most ${PROMPT_IDEMPOTENCY_KEY_MAX_LENGTH} characters`,
|
||||
code: 'invalid_idempotency_key',
|
||||
});
|
||||
return;
|
||||
}
|
||||
idempotencyKey = rawIdempotencyKey;
|
||||
}
|
||||
const clientId = parseClientIdHeader(req, res);
|
||||
if (clientId === null) return;
|
||||
|
||||
@@ -8482,6 +8573,26 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
const promptId = crypto.randomUUID();
|
||||
if (idempotencyKey !== undefined) {
|
||||
const admittedPromptId = promptIdempotencyBySession
|
||||
.get(sessionId)
|
||||
?.get(idempotencyKey);
|
||||
if (admittedPromptId !== undefined) {
|
||||
// A retry of an already-admitted event (lost acceptance mark or
|
||||
// lost 202 on the caller side): return the original admission
|
||||
// without enqueueing a second turn — and before any delivery
|
||||
// authorization is minted for the unused new promptId. The
|
||||
// cursor/epoch are fresh so the caller can still seed SSE
|
||||
// resume from this response.
|
||||
res.status(202).json({
|
||||
promptId: admittedPromptId,
|
||||
lastEventId: ownerBridge.getSessionLastEventId(sessionId),
|
||||
eventEpoch: ownerBridge.getSessionEventEpoch(sessionId),
|
||||
deduplicated: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (delivery && deps.channelDeliveryAuthorizations) {
|
||||
deps.channelDeliveryAuthorizations.authorizePrompt(
|
||||
runtime.workspaceCwd,
|
||||
@@ -8495,6 +8606,7 @@ export function registerSessionRoutes(
|
||||
const forwardedBody = { ...body };
|
||||
delete forwardedBody['deadlineMs'];
|
||||
delete forwardedBody['delivery'];
|
||||
delete forwardedBody['idempotencyKey'];
|
||||
const forwardedMeta =
|
||||
typeof forwardedBody['_meta'] === 'object' &&
|
||||
forwardedBody['_meta'] !== null &&
|
||||
@@ -8507,22 +8619,37 @@ export function registerSessionRoutes(
|
||||
const promptDisplayText =
|
||||
forwardedMeta?.[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY];
|
||||
const channelPrompt = forwardedMeta?.[CHANNEL_PROMPT_META_KEY];
|
||||
const boundRelay = forwardedMeta?.[CHANNEL_BOUND_RELAY_META_KEY];
|
||||
if (forwardedMeta) {
|
||||
delete forwardedMeta[CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY];
|
||||
delete forwardedMeta[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY];
|
||||
delete forwardedMeta[SUBMITTED_PROMPT_META_KEY];
|
||||
delete forwardedMeta[DAEMON_SUBMITTED_PROMPT_META_KEY];
|
||||
delete forwardedMeta[CHANNEL_PROMPT_META_KEY];
|
||||
delete forwardedMeta[CHANNEL_BOUND_RELAY_META_KEY];
|
||||
if (Object.keys(forwardedMeta).length > 0) {
|
||||
forwardedBody['_meta'] = forwardedMeta;
|
||||
} else {
|
||||
delete forwardedBody['_meta'];
|
||||
}
|
||||
}
|
||||
const channelWorkerAuthorized = isChannelWorkerPromptAuthorized(
|
||||
promptAuthorization,
|
||||
runtime.workspaceCwd,
|
||||
);
|
||||
// Home-workspace channel turns ride the per-workspace worker
|
||||
// registration (the pre-relay predicate). Cross-workspace bound
|
||||
// delivery additionally requires the explicit bound-relay marker:
|
||||
// the process-level credential exists for createBoundSessionsAccessor
|
||||
// relays, so a PLAIN prompt carrying a foreign worker token gets no
|
||||
// channel classification (and no caller-supplied display projection).
|
||||
// The target must still be primary or trusted — the same trust model
|
||||
// the bindings route enumerates. A forged token is dropped here and
|
||||
// again at the bridge admission strip.
|
||||
const channelWorkerAuthorized =
|
||||
isChannelWorkerPromptAuthorized(
|
||||
promptAuthorization,
|
||||
runtime.workspaceCwd,
|
||||
) ||
|
||||
(boundRelay === true &&
|
||||
isChannelWorkerPromptToken(promptAuthorization) &&
|
||||
(runtime.primary || runtime.trusted));
|
||||
const trustedPromptDisplayText =
|
||||
typeof promptDisplayText === 'string' && channelWorkerAuthorized
|
||||
? promptDisplayText
|
||||
@@ -8564,6 +8691,13 @@ export function registerSessionRoutes(
|
||||
);
|
||||
|
||||
let promptPromise: ReturnType<AcpSessionBridge['sendPrompt']>;
|
||||
// Tracks whether the bridge actually enqueued the prompt. The
|
||||
// admission contract throws every reachable pre-enqueue failure
|
||||
// synchronously; the admission signal remains the type-agnostic
|
||||
// defense-in-depth way to tell a non-admission apart from a turn
|
||||
// that was admitted and then failed (a custom bridge could still
|
||||
// reject deferred; prompt shape validation is pre-validated here).
|
||||
let promptAdmitted = false;
|
||||
try {
|
||||
promptPromise = ownerBridge.sendPrompt(
|
||||
sessionId,
|
||||
@@ -8597,7 +8731,10 @@ export function registerSessionRoutes(
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(onPromptAdmitted !== undefined ? { onPromptAdmitted } : {}),
|
||||
onPromptAdmitted: () => {
|
||||
promptAdmitted = true;
|
||||
onPromptAdmitted?.();
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -8631,6 +8768,14 @@ export function registerSessionRoutes(
|
||||
return;
|
||||
}
|
||||
res.off('close', onResClose);
|
||||
if (idempotencyKey !== undefined) {
|
||||
// sendPrompt returned without a synchronous admission throw:
|
||||
// remember the key BEFORE any await so a concurrent retry cannot
|
||||
// slip past the check above (the whole check-admit-remember
|
||||
// sequence is synchronous). A deferred pre-admission rejection
|
||||
// forgets it again in the failure handler below.
|
||||
rememberPromptIdempotencyKey(sessionId, idempotencyKey, promptId);
|
||||
}
|
||||
|
||||
// A `/review` or `/verify-pr` prompt binds the PR to this session as
|
||||
// soon as the turn is admitted, instead of waiting for an on-demand
|
||||
@@ -8706,6 +8851,22 @@ export function registerSessionRoutes(
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
// A rejection WITHOUT the admission signal means the prompt
|
||||
// was never enqueued (a deferred pre-admission check rejected
|
||||
// it). The remembered key must be removed, or a retry would
|
||||
// dedupe into a phantom admission and silently lose the
|
||||
// event. Turn failures (the prompt WAS admitted) keep their
|
||||
// key. Every reachable pre-admission check in the bridge now
|
||||
// throws synchronously (the reserved standalone
|
||||
// working-directory check included), so the route answers
|
||||
// non-202 before the relay can consume a phantom acceptance;
|
||||
// this forget stays as defense-in-depth for a bridge that
|
||||
// still rejects deferred, where the CURRENT delivery is
|
||||
// already consumed by the caller and only subsequent retries
|
||||
// can be protected.
|
||||
if (idempotencyKey !== undefined && !promptAdmitted) {
|
||||
forgetPromptIdempotencyKey(sessionId, idempotencyKey);
|
||||
}
|
||||
if (daemonLog) {
|
||||
daemonLog.warn(
|
||||
`prompt turn failed: ${describePromptTurnFailure(err)}`,
|
||||
|
||||
@@ -62,6 +62,7 @@ import { ChannelDeliveryAuthorizationStore } from './channel-delivery-authorizat
|
||||
import { tagListener } from './local-control/index.js';
|
||||
import {
|
||||
CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY,
|
||||
CHANNEL_BOUND_RELAY_META_KEY,
|
||||
registerChannelWorkerPromptAuthorization,
|
||||
revokeChannelWorkerPromptAuthorization,
|
||||
} from './channel-worker-prompt-authorization.js';
|
||||
@@ -95,6 +96,7 @@ import type {
|
||||
SetSessionModelRequest,
|
||||
SetSessionModelResponse,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import { RequestError } from '@agentclientprotocol/sdk';
|
||||
import {
|
||||
ApprovalMode,
|
||||
AuthType,
|
||||
@@ -18053,7 +18055,7 @@ describe('createServeApp', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('400s unknown explicit cwd before touching the bridge', async () => {
|
||||
it('404s unknown explicit cwd before touching the bridge', async () => {
|
||||
const missingCwd = path.join(
|
||||
os.tmpdir(),
|
||||
`qwen-missing-workspace-${Date.now()}`,
|
||||
@@ -18066,8 +18068,12 @@ describe('createServeApp', () => {
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({ cwd: missingCwd });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('workspace_mismatch');
|
||||
// An explicit cwd with no registry entry is a PERMANENT absence
|
||||
// (404 workspace_not_found), distinct from the reversible
|
||||
// non-active states (400 workspace_mismatch). The channel wake path
|
||||
// keys its Gone classification on this 404.
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.code).toBe('workspace_not_found');
|
||||
expect(res.body.boundWorkspace).toBe(process.cwd());
|
||||
expect(res.body.requestedWorkspace).toBe(missingCwd);
|
||||
expect(bridge.loadCalls).toHaveLength(0);
|
||||
@@ -18075,6 +18081,64 @@ describe('createServeApp', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['draining', 'transitioning', 'blocked'] as const)(
|
||||
'400s workspace_mismatch (not 404) for a %s workspace on load and resume',
|
||||
async (state) => {
|
||||
// G14: an entry that EXISTS but is reversibly non-active must stay
|
||||
// out of the permanent-absence (404 workspace_not_found) branch.
|
||||
// Only a cwd with NO registry entry is a gone workspace; draining /
|
||||
// transitioning / blocked still have an entry and answer 400
|
||||
// workspace_mismatch, so the channel wake path must not classify
|
||||
// them as Gone.
|
||||
for (const action of ['load', 'resume'] as const) {
|
||||
const primaryBridge = fakeBridge();
|
||||
const secondaryBridge = fakeBridge();
|
||||
const secondary = makeWorkspaceRuntimeForTest({
|
||||
workspaceId: 'ws-secondary',
|
||||
workspaceCwd: WS_DIFFERENT,
|
||||
primary: false,
|
||||
bridge: secondaryBridge,
|
||||
});
|
||||
const registry = createWorkspaceRegistry([
|
||||
makeWorkspaceRuntimeForTest({
|
||||
workspaceId: 'ws-primary',
|
||||
workspaceCwd: WS_BOUND,
|
||||
primary: true,
|
||||
bridge: primaryBridge,
|
||||
}),
|
||||
secondary,
|
||||
]);
|
||||
const app = createServeApp(
|
||||
{ ...baseOpts, workspace: WS_BOUND },
|
||||
undefined,
|
||||
{ bridge: primaryBridge, workspaceRegistry: registry },
|
||||
);
|
||||
const entry = registry.getEntryByWorkspaceCwd(WS_DIFFERENT);
|
||||
expect(entry).toBeDefined();
|
||||
if (state === 'draining') {
|
||||
expect(registry.beginDrain(secondary)).toBe(true);
|
||||
} else if (state === 'transitioning') {
|
||||
expect(registry.beginReplacement(entry!, 'rev-2')).toBe(true);
|
||||
} else {
|
||||
registry.blockReplacement(entry!, 'apply failed');
|
||||
}
|
||||
expect(entry!.state).toBe(state);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/session/reversible-${state}/${action}`)
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({ cwd: WS_DIFFERENT });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('workspace_mismatch');
|
||||
expect(primaryBridge.loadCalls).toHaveLength(0);
|
||||
expect(primaryBridge.resumeCalls).toHaveLength(0);
|
||||
expect(secondaryBridge.loadCalls).toHaveLength(0);
|
||||
expect(secondaryBridge.resumeCalls).toHaveLength(0);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('400s a non-string cwd before touching the bridge', async () => {
|
||||
// Mirrors the `POST /session` malformed-`cwd`-shape test: a
|
||||
// client/orchestrator serialization bug (`cwd: null`,
|
||||
@@ -18248,7 +18312,11 @@ describe('createServeApp', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('400 workspace_mismatch before touching the bridge for non-primary cwd', async () => {
|
||||
it('404s workspace_not_found before touching the bridge for an unregistered non-primary cwd', async () => {
|
||||
// The single-workspace daemon has no registry entry for WS_DIFFERENT,
|
||||
// so an explicit cwd naming it is a PERMANENT absence (404
|
||||
// workspace_not_found), not the reversible workspace_mismatch (400).
|
||||
// The resolver must reject before the bridge is ever consulted.
|
||||
const bridge = fakeBridge({
|
||||
loadImpl: async () => {
|
||||
throw new WorkspaceMismatchError(WS_BOUND, WS_DIFFERENT);
|
||||
@@ -18264,9 +18332,9 @@ describe('createServeApp', () => {
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({ cwd: WS_DIFFERENT });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toMatchObject({
|
||||
code: 'workspace_mismatch',
|
||||
code: 'workspace_not_found',
|
||||
boundWorkspace: WS_BOUND,
|
||||
requestedWorkspace: WS_DIFFERENT,
|
||||
});
|
||||
@@ -20702,6 +20770,348 @@ describe('createServeApp', () => {
|
||||
expect(res.body.eventEpoch).toBe('epoch-abc');
|
||||
});
|
||||
|
||||
it('deduplicates a repeated idempotency key into the original admission (W4)', async () => {
|
||||
// A channel relay marks acceptance durably AFTER the 202; a crash or
|
||||
// write failure in that gap (or a lost response) makes it retry with
|
||||
// the same key. The retry must return the original admission without
|
||||
// enqueueing a second turn.
|
||||
const bridge = fakeBridge();
|
||||
const app = createServeApp(baseOpts, undefined, { bridge });
|
||||
const first = await request(app)
|
||||
.post('/session/session-A/prompt')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({
|
||||
prompt: [{ type: 'text', text: 'event' }],
|
||||
idempotencyKey: 'task-42',
|
||||
});
|
||||
expect(first.status).toBe(202);
|
||||
|
||||
const retry = await request(app)
|
||||
.post('/session/session-A/prompt')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({
|
||||
prompt: [{ type: 'text', text: 'event' }],
|
||||
idempotencyKey: 'task-42',
|
||||
});
|
||||
expect(retry.status).toBe(202);
|
||||
expect(retry.body.promptId).toBe(first.body.promptId);
|
||||
expect(retry.body.deduplicated).toBe(true);
|
||||
expect(bridge.promptCalls).toHaveLength(1);
|
||||
|
||||
// A different event admits normally.
|
||||
const other = await request(app)
|
||||
.post('/session/session-A/prompt')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({
|
||||
prompt: [{ type: 'text', text: 'another event' }],
|
||||
idempotencyKey: 'task-43',
|
||||
});
|
||||
expect(other.status).toBe(202);
|
||||
expect(other.body.promptId).not.toBe(first.body.promptId);
|
||||
expect(other.body.deduplicated).toBeUndefined();
|
||||
expect(bridge.promptCalls).toHaveLength(2);
|
||||
// The key is route-level admission metadata, never prompt content.
|
||||
expect(bridge.promptCalls[0]?.req).not.toHaveProperty('idempotencyKey');
|
||||
});
|
||||
|
||||
it('scopes idempotency keys per session (W4)', async () => {
|
||||
const bridge = fakeBridge();
|
||||
const app = createServeApp(baseOpts, undefined, { bridge });
|
||||
const a = await request(app)
|
||||
.post('/session/session-A/prompt')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({
|
||||
prompt: [{ type: 'text', text: 'event' }],
|
||||
idempotencyKey: 'same-key',
|
||||
});
|
||||
const b = await request(app)
|
||||
.post('/session/session-B/prompt')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({
|
||||
prompt: [{ type: 'text', text: 'event' }],
|
||||
idempotencyKey: 'same-key',
|
||||
});
|
||||
expect(a.status).toBe(202);
|
||||
expect(b.status).toBe(202);
|
||||
expect(b.body.promptId).not.toBe(a.body.promptId);
|
||||
expect(bridge.promptCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('evicts the oldest key past the per-session idempotency ring bound (R2-4)', async () => {
|
||||
// The ring keeps the newest 128 keys per session. Evicting a live
|
||||
// key is the documented trade-off for bounding daemon-lifetime
|
||||
// growth: the evicted event re-admits instead of deduping forever.
|
||||
const bridge = fakeBridge();
|
||||
const app = createServeApp(baseOpts, undefined, { bridge });
|
||||
const post = (key: string) =>
|
||||
request(app)
|
||||
.post('/session/ring-key-bound-session/prompt')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({
|
||||
prompt: [{ type: 'text', text: 'event' }],
|
||||
idempotencyKey: key,
|
||||
});
|
||||
const admitted: string[] = [];
|
||||
for (let i = 0; i < 129; i++) {
|
||||
const res = await post(`ring-key-${i}`);
|
||||
expect(res.status).toBe(202);
|
||||
expect(res.body.deduplicated).toBeUndefined();
|
||||
admitted.push(res.body.promptId);
|
||||
}
|
||||
// The first key fell out of the 128-key window: it re-admits fresh.
|
||||
const evicted = await post('ring-key-0');
|
||||
expect(evicted.status).toBe(202);
|
||||
expect(evicted.body.deduplicated).toBeUndefined();
|
||||
expect(evicted.body.promptId).not.toBe(admitted[0]);
|
||||
// The newest keys still dedupe into their original admissions.
|
||||
for (const i of [127, 128]) {
|
||||
const retry = await post(`ring-key-${i}`);
|
||||
expect(retry.status).toBe(202);
|
||||
expect(retry.body.deduplicated).toBe(true);
|
||||
expect(retry.body.promptId).toBe(admitted[i]);
|
||||
}
|
||||
// Re-admitting ring-key-0 pushed ring-key-1 out: FIFO, not LRU.
|
||||
const nextEvicted = await post('ring-key-1');
|
||||
expect(nextEvicted.status).toBe(202);
|
||||
expect(nextEvicted.body.deduplicated).toBeUndefined();
|
||||
expect(nextEvicted.body.promptId).not.toBe(admitted[1]);
|
||||
// 129 admissions + 2 re-admissions; deduped retries never reach the
|
||||
// bridge.
|
||||
expect(bridge.promptCalls).toHaveLength(131);
|
||||
});
|
||||
|
||||
// 1027 sequential HTTP round-trips: above the I/O-bound default.
|
||||
it('evicts the oldest session past the idempotency session bound (R2-4)', async () => {
|
||||
// The ring spans at most 1024 sessions; admitting a new session
|
||||
// evicts the oldest one's keys (the documented trade-off), so its
|
||||
// retry re-admits instead of deduping into an evicted admission.
|
||||
const bridge = fakeBridge();
|
||||
const app = createServeApp(baseOpts, undefined, { bridge });
|
||||
const post = (sessionId: string) =>
|
||||
request(app)
|
||||
.post(`/session/${sessionId}/prompt`)
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({
|
||||
prompt: [{ type: 'text', text: 'event' }],
|
||||
idempotencyKey: 'ring-shared-key',
|
||||
});
|
||||
const first = await post('ring-session-0');
|
||||
expect(first.status).toBe(202);
|
||||
expect(first.body.deduplicated).toBeUndefined();
|
||||
let newestPromptId = '';
|
||||
for (let i = 1; i <= 1024; i++) {
|
||||
const res = await post(`ring-session-${i}`);
|
||||
expect(res.status).toBe(202);
|
||||
expect(res.body.deduplicated).toBeUndefined();
|
||||
if (i === 1024) newestPromptId = res.body.promptId;
|
||||
}
|
||||
// The oldest session was evicted: its key re-admits fresh.
|
||||
const evicted = await post('ring-session-0');
|
||||
expect(evicted.status).toBe(202);
|
||||
expect(evicted.body.deduplicated).toBeUndefined();
|
||||
expect(evicted.body.promptId).not.toBe(first.body.promptId);
|
||||
// The newest session still dedupes into its original admission.
|
||||
const newest = await post('ring-session-1024');
|
||||
expect(newest.status).toBe(202);
|
||||
expect(newest.body.deduplicated).toBe(true);
|
||||
expect(newest.body.promptId).toBe(newestPromptId);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects a malformed idempotency key (W4)', async () => {
|
||||
const bridge = fakeBridge();
|
||||
const app = createServeApp(baseOpts, undefined, { bridge });
|
||||
for (const key of ['', 42, 'x'.repeat(201)]) {
|
||||
const res = await request(app)
|
||||
.post('/session/session-A/prompt')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({
|
||||
prompt: [{ type: 'text', text: 'event' }],
|
||||
idempotencyKey: key,
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('invalid_idempotency_key');
|
||||
}
|
||||
expect(bridge.promptCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('forgets the idempotency key when admission rejects deferred (B-F1)', async () => {
|
||||
// A bridge that rejects AFTER returning (the legacy closing-session
|
||||
// shape) never enqueued the prompt. The remembered key must be
|
||||
// removed, or a retry would dedupe into a phantom admission and the
|
||||
// event would be silently lost instead of waking the session.
|
||||
const bridge = fakeBridge({
|
||||
promptImpl: () =>
|
||||
Promise.reject(
|
||||
new SessionNotFoundError(
|
||||
'session-A',
|
||||
'The session is closing; retry after close completes',
|
||||
'session_closing',
|
||||
),
|
||||
),
|
||||
});
|
||||
const app = createServeApp(baseOpts, undefined, { bridge });
|
||||
const post = () =>
|
||||
request(app)
|
||||
.post('/session/session-A/prompt')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({
|
||||
prompt: [{ type: 'text', text: 'event' }],
|
||||
idempotencyKey: 'phantom-key',
|
||||
});
|
||||
const first = await post();
|
||||
expect(first.status).toBe(202);
|
||||
// Retry until the deferred rejection handler forgets the key: a fixed
|
||||
// sleep flakes under load. Deduped retries never reach the bridge, so
|
||||
// promptCalls still ends at 2; a broken forget exhausts the loop and
|
||||
// fails the assertion below.
|
||||
let retry = await post();
|
||||
for (let i = 0; i < 100 && retry.body.deduplicated === true; i++) {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
retry = await post();
|
||||
}
|
||||
// The retry must reach admission again, not dedupe into the phantom.
|
||||
expect(retry.status).toBe(202);
|
||||
expect(retry.body.deduplicated).toBeUndefined();
|
||||
expect(bridge.promptCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('rejects a closing session synchronously without remembering the key (B-F1)', async () => {
|
||||
const bridge = fakeBridge({
|
||||
promptImpl: () => {
|
||||
throw new SessionNotFoundError(
|
||||
'session-A',
|
||||
'The session is closing; retry after close completes',
|
||||
'session_closing',
|
||||
);
|
||||
},
|
||||
});
|
||||
const app = createServeApp(baseOpts, undefined, { bridge });
|
||||
const first = await request(app)
|
||||
.post('/session/session-A/prompt')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({
|
||||
prompt: [{ type: 'text', text: 'event' }],
|
||||
idempotencyKey: 'closing-key',
|
||||
});
|
||||
// Synchronous admission failure: 404 BEFORE any 202, so the relay
|
||||
// wakes the session and retries instead of marking a phantom delivery.
|
||||
expect(first.status).toBe(404);
|
||||
const retry = await request(app)
|
||||
.post('/session/session-A/prompt')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({
|
||||
prompt: [{ type: 'text', text: 'event' }],
|
||||
idempotencyKey: 'closing-key',
|
||||
});
|
||||
expect(retry.status).toBe(404);
|
||||
expect(retry.body.deduplicated).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects an unreleased standalone binding synchronously with 409 before any 202 (R9-B1)', async () => {
|
||||
// The bridge's reserved-standalone working-directory check throws
|
||||
// synchronously (admission contract): the route must answer 409
|
||||
// working_directory_missing BEFORE any 202, so the channel relay
|
||||
// defers instead of consuming a phantom acceptance, and no
|
||||
// idempotency key is remembered.
|
||||
const bridge = fakeBridge({
|
||||
promptImpl: () => {
|
||||
throw new RequestError(
|
||||
-32004,
|
||||
'The standalone working directory is missing.',
|
||||
{ errorKind: 'working_directory_missing' },
|
||||
);
|
||||
},
|
||||
});
|
||||
const app = createServeApp(baseOpts, undefined, { bridge });
|
||||
const first = await request(app)
|
||||
.post('/session/session-A/prompt')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({
|
||||
prompt: [{ type: 'text', text: 'event' }],
|
||||
idempotencyKey: 'standalone-sync-key',
|
||||
});
|
||||
expect(first.status).toBe(409);
|
||||
expect(first.body.code).toBe('working_directory_missing');
|
||||
expect(first.body.retryable).toBe(true);
|
||||
const retry = await request(app)
|
||||
.post('/session/session-A/prompt')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({
|
||||
prompt: [{ type: 'text', text: 'event' }],
|
||||
idempotencyKey: 'standalone-sync-key',
|
||||
});
|
||||
expect(retry.status).toBe(409);
|
||||
expect(retry.body.deduplicated).toBeUndefined();
|
||||
expect(bridge.promptCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('forgets the idempotency key on any pre-admission deferred rejection (R2-A2)', async () => {
|
||||
// Defense-in-depth: a custom bridge can still reject deferred with a
|
||||
// plain Error (the built-in standalone working-directory check now
|
||||
// throws synchronously). The forget must key off the admission
|
||||
// signal, not the error type, or the phantom key survives and a
|
||||
// retry dedupes into a non-admission.
|
||||
const bridge = fakeBridge({
|
||||
promptImpl: () =>
|
||||
Promise.reject(
|
||||
new Error('The standalone working directory is missing.'),
|
||||
),
|
||||
});
|
||||
const app = createServeApp(baseOpts, undefined, { bridge });
|
||||
const post = () =>
|
||||
request(app)
|
||||
.post('/session/session-A/prompt')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({
|
||||
prompt: [{ type: 'text', text: 'event' }],
|
||||
idempotencyKey: 'standalone-key',
|
||||
});
|
||||
const first = await post();
|
||||
expect(first.status).toBe(202);
|
||||
// Retry until the deferred rejection handler forgets the key (a fixed
|
||||
// sleep flakes under load); a broken forget exhausts the loop.
|
||||
let retry = await post();
|
||||
for (let i = 0; i < 100 && retry.body.deduplicated === true; i++) {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
retry = await post();
|
||||
}
|
||||
expect(retry.status).toBe(202);
|
||||
expect(retry.body.deduplicated).toBeUndefined();
|
||||
expect(bridge.promptCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps the idempotency key when an admitted turn fails (R2-A2)', async () => {
|
||||
// The admission signal fired: the prompt WAS enqueued and the turn
|
||||
// then failed. Forgetting the key here would let a retry re-inject
|
||||
// the same event as a second turn.
|
||||
const bridge = fakeBridge({
|
||||
promptImpl: (_sessionId, _req, _signal, context) => {
|
||||
context?.onPromptAdmitted?.();
|
||||
return Promise.reject(new Error('turn failed after admission'));
|
||||
},
|
||||
});
|
||||
const app = createServeApp(baseOpts, undefined, { bridge });
|
||||
const first = await request(app)
|
||||
.post('/session/session-A/prompt')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({
|
||||
prompt: [{ type: 'text', text: 'event' }],
|
||||
idempotencyKey: 'turn-failure-key',
|
||||
});
|
||||
expect(first.status).toBe(202);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const retry = await request(app)
|
||||
.post('/session/session-A/prompt')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({
|
||||
prompt: [{ type: 'text', text: 'event' }],
|
||||
idempotencyKey: 'turn-failure-key',
|
||||
});
|
||||
expect(retry.status).toBe(202);
|
||||
expect(retry.body.deduplicated).toBe(true);
|
||||
expect(bridge.promptCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('passes client identity and promptId context into bridge.sendPrompt', async () => {
|
||||
const bridge = fakeBridge();
|
||||
const app = createServeApp(baseOpts, undefined, { bridge });
|
||||
@@ -20878,6 +21288,193 @@ describe('createServeApp', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('admits a foreign worker token only for an explicit bound relay (G3, R1-14)', async () => {
|
||||
// G3: bound delivery is cross-workspace, so the prompt route uses the
|
||||
// process-level relay check (token registered for ANY workspace) gated
|
||||
// on the target runtime's trust — not the old home===target equality.
|
||||
// R1-14: the widening is scoped to its reason — cross-workspace
|
||||
// classification additionally requires the bound-relay marker, so a
|
||||
// PLAIN prompt carrying a foreign worker token stays an ordinary
|
||||
// interactive prompt. A forged token must not classify either way.
|
||||
const bridge = fakeBridge();
|
||||
const app = createServeApp(baseOpts, undefined, { bridge });
|
||||
const token = 'channel-worker-cross-workspace-token';
|
||||
registerChannelWorkerPromptAuthorization(token, '/work/foreign-home');
|
||||
try {
|
||||
const forged = await request(app)
|
||||
.post('/session/session-A/prompt')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({
|
||||
prompt: [{ type: 'text', text: 'hi' }],
|
||||
_meta: {
|
||||
[CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY]: 'forged',
|
||||
[CHANNEL_PROMPT_META_KEY]: true,
|
||||
},
|
||||
});
|
||||
const plainForeign = await request(app)
|
||||
.post('/session/session-A/prompt')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({
|
||||
prompt: [{ type: 'text', text: 'hi' }],
|
||||
_meta: {
|
||||
[CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY]: token,
|
||||
[CHANNEL_PROMPT_META_KEY]: true,
|
||||
[CHANNEL_PROMPT_DISPLAY_TEXT_META_KEY]: 'benign transcript text',
|
||||
},
|
||||
});
|
||||
const relay = await request(app)
|
||||
.post('/session/session-A/prompt')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({
|
||||
prompt: [{ type: 'text', text: 'hi' }],
|
||||
_meta: {
|
||||
[CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY]: token,
|
||||
[CHANNEL_PROMPT_META_KEY]: true,
|
||||
[CHANNEL_BOUND_RELAY_META_KEY]: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(forged.status).toBe(202);
|
||||
expect(plainForeign.status).toBe(202);
|
||||
expect(relay.status).toBe(202);
|
||||
expect(bridge.promptCalls[0]?.context?.channelPrompt).toBeUndefined();
|
||||
// The plain foreign prompt gets no channel classification and no
|
||||
// caller-supplied display projection.
|
||||
expect(bridge.promptCalls[1]?.context?.channelPrompt).toBeUndefined();
|
||||
expect(
|
||||
bridge.promptCalls[1]?.context?.promptDisplayText,
|
||||
).toBeUndefined();
|
||||
expect(bridge.promptCalls[2]?.context?.channelPrompt).toBe(true);
|
||||
for (const call of bridge.promptCalls) {
|
||||
expect(call.req._meta ?? {}).not.toHaveProperty(
|
||||
CHANNEL_BOUND_RELAY_META_KEY,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
revokeChannelWorkerPromptAuthorization(token);
|
||||
}
|
||||
});
|
||||
|
||||
it('classifies a channel prompt delivered into a different trusted workspace', async () => {
|
||||
// G3 end-to-end: the worker's home is the primary workspace, but the
|
||||
// bound session lives in a secondary trusted workspace. The relay
|
||||
// credential must survive the cross-workspace hop and classify the
|
||||
// turn on the owning (secondary) bridge, not the home bridge.
|
||||
const primaryBridge = fakeBridge();
|
||||
const secondaryBridge = fakeBridge({
|
||||
summaryImpl: (sessionId) => ({
|
||||
sessionId,
|
||||
workspaceCwd: WS_DIFFERENT,
|
||||
createdAt: '2026-05-17T12:00:00.000Z',
|
||||
clientCount: 1,
|
||||
hasActivePrompt: false,
|
||||
}),
|
||||
});
|
||||
const registry = createWorkspaceRegistry([
|
||||
makeWorkspaceRuntimeForTest({
|
||||
workspaceId: 'ws-primary',
|
||||
workspaceCwd: WS_BOUND,
|
||||
primary: true,
|
||||
bridge: primaryBridge,
|
||||
}),
|
||||
makeWorkspaceRuntimeForTest({
|
||||
workspaceId: 'ws-secondary',
|
||||
workspaceCwd: WS_DIFFERENT,
|
||||
primary: false,
|
||||
bridge: secondaryBridge,
|
||||
}),
|
||||
]);
|
||||
const app = createServeApp(
|
||||
{ ...baseOpts, workspace: WS_BOUND },
|
||||
undefined,
|
||||
{ bridge: primaryBridge, workspaceRegistry: registry },
|
||||
);
|
||||
const token = 'channel-worker-cross-workspace-delivery-token';
|
||||
registerChannelWorkerPromptAuthorization(token, WS_BOUND);
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/session/session-cross-ws/prompt')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({
|
||||
prompt: [{ type: 'text', text: 'hi' }],
|
||||
_meta: {
|
||||
[CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY]: token,
|
||||
[CHANNEL_PROMPT_META_KEY]: true,
|
||||
[CHANNEL_BOUND_RELAY_META_KEY]: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(202);
|
||||
expect(primaryBridge.promptCalls).toHaveLength(0);
|
||||
expect(secondaryBridge.promptCalls).toHaveLength(1);
|
||||
expect(secondaryBridge.promptCalls[0]?.context?.channelPrompt).toBe(
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
revokeChannelWorkerPromptAuthorization(token);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects prompts to an untrusted non-primary workspace at the routing gate', async () => {
|
||||
// Regression guard for the PRE-EXISTING routing trust gate (not the
|
||||
// PR's classification gate): an untrusted non-primary owner is
|
||||
// rejected before classification runs, so the classification's own
|
||||
// trust conjunct is unreachable defense at this layer and cannot be
|
||||
// pinned by a route-level test. The relay-marker negative is pinned
|
||||
// by 'admits a foreign worker token only for an explicit bound relay'.
|
||||
const primaryBridge = fakeBridge();
|
||||
const secondaryBridge = fakeBridge({
|
||||
summaryImpl: (sessionId) => ({
|
||||
sessionId,
|
||||
workspaceCwd: WS_DIFFERENT,
|
||||
createdAt: '2026-05-17T12:00:00.000Z',
|
||||
clientCount: 1,
|
||||
hasActivePrompt: false,
|
||||
}),
|
||||
});
|
||||
const registry = createWorkspaceRegistry([
|
||||
makeWorkspaceRuntimeForTest({
|
||||
workspaceId: 'ws-primary',
|
||||
workspaceCwd: WS_BOUND,
|
||||
primary: true,
|
||||
bridge: primaryBridge,
|
||||
}),
|
||||
makeWorkspaceRuntimeForTest({
|
||||
workspaceId: 'ws-secondary',
|
||||
workspaceCwd: WS_DIFFERENT,
|
||||
primary: false,
|
||||
trusted: false,
|
||||
bridge: secondaryBridge,
|
||||
}),
|
||||
]);
|
||||
const app = createServeApp(
|
||||
{ ...baseOpts, workspace: WS_BOUND },
|
||||
undefined,
|
||||
{ bridge: primaryBridge, workspaceRegistry: registry },
|
||||
);
|
||||
const token = 'channel-worker-untrusted-target-token';
|
||||
registerChannelWorkerPromptAuthorization(token, WS_BOUND);
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/session/session-untrusted/prompt')
|
||||
.set('Host', `127.0.0.1:${baseOpts.port}`)
|
||||
.send({
|
||||
prompt: [{ type: 'text', text: 'hi' }],
|
||||
_meta: {
|
||||
[CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY]: token,
|
||||
[CHANNEL_PROMPT_META_KEY]: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('untrusted_workspace');
|
||||
expect(primaryBridge.promptCalls).toHaveLength(0);
|
||||
expect(secondaryBridge.promptCalls).toHaveLength(0);
|
||||
} finally {
|
||||
revokeChannelWorkerPromptAuthorization(token);
|
||||
}
|
||||
});
|
||||
|
||||
it('validates delivery and forwards it only through trusted prompt context', async () => {
|
||||
const bridge = fakeBridge();
|
||||
const channelDeliveryAuthorizations =
|
||||
|
||||
@@ -155,6 +155,7 @@ import { registerPermissionRoutes } from './routes/permission.js';
|
||||
import { registerSessionRoutes } from './routes/session.js';
|
||||
import { registerSessionCatalogRoutes } from './routes/session-catalog.js';
|
||||
import { registerSessionPrBackfillRoutes } from './routes/session-pr-backfill.js';
|
||||
import { registerSessionPrBindingsRoutes } from './routes/session-pr-bindings.js';
|
||||
import { registerStandaloneSessionRoutes } from './routes/standalone-sessions.js';
|
||||
import {
|
||||
createRequestedSessionIdAdmission,
|
||||
@@ -3057,6 +3058,12 @@ export function createServeApp(
|
||||
mutate,
|
||||
archiveCoordinator,
|
||||
});
|
||||
// Read-only reverse lookup (PR bindings → sessions) for channel workers
|
||||
// that deliver GitHub events into the session following a PR.
|
||||
registerSessionPrBindingsRoutes(app, {
|
||||
workspaceRegistry,
|
||||
sendBridgeError,
|
||||
});
|
||||
|
||||
// Workspace memory + agents CRUD routes.
|
||||
mountMemoryImportRoutes(app, { workspaceRegistry, mutate });
|
||||
|
||||
@@ -421,4 +421,82 @@ describe('merged session catalog pagination', () => {
|
||||
);
|
||||
expect(result.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('PR sidecar read failures (R2-3)', () => {
|
||||
async function writePrSidecar(
|
||||
session: BridgeSessionSummary,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const chats = path.join(
|
||||
new Storage(workspaceCwd, runtimeBaseDir).getProjectDir(),
|
||||
'chats',
|
||||
);
|
||||
await fs.mkdir(chats, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(chats, `${session.sessionId}.pr.json`),
|
||||
content,
|
||||
);
|
||||
}
|
||||
|
||||
// Both corruption forms must read as UNKNOWN bindings: broken JSON and
|
||||
// a shape-check failure. A well-formed empty list is NOT corruption —
|
||||
// it is an authoritative clear and must keep reading as prs: [].
|
||||
it.each(['{not json', '{"prs":[{"number":1}]}'])(
|
||||
'flags a corrupt persisted PR sidecar as prsReadFailed, not an authoritative clear: %s',
|
||||
async (content) => {
|
||||
const corrupt = summary(0);
|
||||
const cleared = summary(1);
|
||||
const unbound = summary(2);
|
||||
await Promise.all([
|
||||
persist(corrupt),
|
||||
persist(cleared),
|
||||
persist(unbound),
|
||||
]);
|
||||
await writePrSidecar(corrupt, content);
|
||||
await writePrSidecar(cleared, JSON.stringify({ prs: [] }));
|
||||
const { bridge } = bridgeFor([]);
|
||||
|
||||
const result = await listWorkspaceSessionsForResponse(
|
||||
bridge,
|
||||
workspaceCwd,
|
||||
{},
|
||||
{ runtimeBaseDir },
|
||||
);
|
||||
|
||||
const byId = new Map(
|
||||
result.sessions.map((session) => [session.sessionId, session]),
|
||||
);
|
||||
// A corrupt sidecar must not emit an authoritative tombstone
|
||||
// (prs: [] without the flag): the PR-bindings snapshot would prune
|
||||
// the binding and misroute private delivery to the public path.
|
||||
expect(byId.get(corrupt.sessionId)?.prsReadFailed).toBe(true);
|
||||
expect(byId.get(corrupt.sessionId)?.prs).toBeUndefined();
|
||||
expect(byId.get(cleared.sessionId)?.prs).toEqual([]);
|
||||
expect(byId.get(cleared.sessionId)?.prsReadFailed).toBeUndefined();
|
||||
expect(byId.get(unbound.sessionId)?.prs).toBeUndefined();
|
||||
expect(byId.get(unbound.sessionId)?.prsReadFailed).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['{not json', '{"prs":[{"number":1}]}'])(
|
||||
'flags a corrupt PR sidecar on a live-only session as prsReadFailed: %s',
|
||||
async (content) => {
|
||||
const live = summary(7);
|
||||
await writePrSidecar(live, content);
|
||||
const { bridge } = bridgeFor([live]);
|
||||
|
||||
const result = await listWorkspaceSessionsForResponse(
|
||||
bridge,
|
||||
workspaceCwd,
|
||||
{},
|
||||
{ runtimeBaseDir },
|
||||
);
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0]?.sessionId).toBe(live.sessionId);
|
||||
expect(result.sessions[0]?.prsReadFailed).toBe(true);
|
||||
expect(result.sessions[0]?.prs).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
readWorktreeSession,
|
||||
readWorktreeSessionMarker,
|
||||
canonicalSessionPrUrl,
|
||||
readSessionPrs,
|
||||
readSessionPrsDetailed,
|
||||
toSessionPrInfo,
|
||||
type SessionArchiveState,
|
||||
type SessionGroupPresetColor,
|
||||
@@ -485,24 +485,29 @@ async function enrichPrSidecars(
|
||||
): Promise<void> {
|
||||
for (const [sessionId, summary] of bySessionId) {
|
||||
signal?.throwIfAborted();
|
||||
let sidecar: Awaited<ReturnType<typeof readSessionPrs>>;
|
||||
let result: Awaited<ReturnType<typeof readSessionPrsDetailed>>;
|
||||
try {
|
||||
const sidecarPath = sessionService.getPrSessionPathForArchiveState(
|
||||
sessionId,
|
||||
archiveState,
|
||||
);
|
||||
sidecar = signal
|
||||
? await readSessionPrs(sidecarPath, { signal })
|
||||
: await readSessionPrs(sidecarPath);
|
||||
result = signal
|
||||
? await readSessionPrsDetailed(sidecarPath, { signal })
|
||||
: await readSessionPrsDetailed(sidecarPath);
|
||||
} catch {
|
||||
signal?.throwIfAborted();
|
||||
sidecar = null;
|
||||
result = { status: 'invalid' };
|
||||
}
|
||||
if (sidecar) {
|
||||
if (result.status === 'ok') {
|
||||
bySessionId.set(sessionId, {
|
||||
...summary,
|
||||
prs: sidecarToPrInfos(sidecar),
|
||||
prs: sidecarToPrInfos(result.prs),
|
||||
});
|
||||
} else if (result.status === 'invalid') {
|
||||
// An unreadable sidecar is UNKNOWN bindings, not an unbind: flag it
|
||||
// so authoritative consumers (the PR-bindings snapshot) degrade to
|
||||
// partial instead of silently dropping the session.
|
||||
bySessionId.set(sessionId, { ...summary, prsReadFailed: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -685,21 +690,25 @@ async function liveOnlySummary(
|
||||
hasRunningBackgroundTasks: live.hasRunningBackgroundTasks,
|
||||
isArchived: false,
|
||||
};
|
||||
let sidecar: Awaited<ReturnType<typeof readSessionPrs>>;
|
||||
let result: Awaited<ReturnType<typeof readSessionPrsDetailed>>;
|
||||
try {
|
||||
const sidecarPath = sessionService.getPrSessionPathForArchiveState(
|
||||
live.sessionId,
|
||||
'active',
|
||||
);
|
||||
sidecar = signal
|
||||
? await readSessionPrs(sidecarPath, { signal })
|
||||
: await readSessionPrs(sidecarPath);
|
||||
result = signal
|
||||
? await readSessionPrsDetailed(sidecarPath, { signal })
|
||||
: await readSessionPrsDetailed(sidecarPath);
|
||||
} catch {
|
||||
signal?.throwIfAborted();
|
||||
sidecar = null;
|
||||
result = { status: 'invalid' };
|
||||
}
|
||||
if (sidecar) {
|
||||
summary.prs = mergeSummaryPrs(sidecarToPrInfos(sidecar), live.prs);
|
||||
if (result.status === 'ok') {
|
||||
summary.prs = mergeSummaryPrs(sidecarToPrInfos(result.prs), live.prs);
|
||||
} else if (result.status === 'invalid') {
|
||||
// Unknown bindings, not an unbind; the live entry's own bindings (if
|
||||
// any) survive via the spread above.
|
||||
summary.prsReadFailed = true;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
mergeSessionPrLists,
|
||||
moveSessionPrSidecar,
|
||||
readSessionPrs,
|
||||
readSessionPrsDetailed,
|
||||
replaceSessionPrs,
|
||||
updateSessionPrStates,
|
||||
upsertSessionPr,
|
||||
@@ -125,6 +126,19 @@ describe('readSessionPrs', () => {
|
||||
expect(await read()).toBeNull();
|
||||
});
|
||||
|
||||
it('reads a well-formed empty list as an authoritative clear (R9-B2)', async () => {
|
||||
// replaceSessionPrs's explicit empty plan writes {prs: []}; that clear
|
||||
// must read back as "no bindings" (ok/[]), NOT as corruption — an
|
||||
// invalid verdict would degrade authoritative snapshots to permanent
|
||||
// partial and shadow the observed unbind.
|
||||
await writeSessionPrs(filePath, []);
|
||||
expect(await read()).toEqual([]);
|
||||
expect(await readSessionPrsDetailed(filePath)).toEqual({
|
||||
status: 'ok',
|
||||
prs: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for invalid JSON', async () => {
|
||||
await fs.writeFile(filePath, '{not json', 'utf-8');
|
||||
expect(await read()).toBeNull();
|
||||
@@ -132,7 +146,6 @@ describe('readSessionPrs', () => {
|
||||
|
||||
it.each([
|
||||
['bare object (legacy single shape)', entry(1)],
|
||||
['empty list', { prs: [] }],
|
||||
['entry missing url', { prs: [{ number: 1, createdAt: 'x' }] }],
|
||||
['entry non-integer number', { prs: [{ ...entry(1), number: 1.5 }] }],
|
||||
['entry non-positive number', { prs: [entry(0)] }],
|
||||
@@ -775,8 +788,9 @@ describe('replaceSessionPrs', () => {
|
||||
existing.filter((p) => p.number !== 100),
|
||||
);
|
||||
expect(persisted).toEqual([]);
|
||||
// An empty list reads back as null (isValidSessionPrList rejects it).
|
||||
expect(await read()).toBeNull();
|
||||
// The explicit empty plan clears the bindings: the well-formed empty
|
||||
// list reads back as an authoritative "no bindings", not as corruption.
|
||||
expect(await read()).toEqual([]);
|
||||
});
|
||||
|
||||
it('declines entries the reader would reject instead of poisoning the list', async () => {
|
||||
|
||||
@@ -142,21 +142,35 @@ function isValidSessionPr(value: unknown): value is SessionPr {
|
||||
/**
|
||||
* Runtime shape check for a parsed sidecar object. Guards against partial
|
||||
* writes and manual edits (same rationale as the worktree sidecar check).
|
||||
* An EMPTY list is a valid shape: replaceSessionPrs's explicit empty plan
|
||||
* clears the bindings by writing {prs: []}, and that clear must read back
|
||||
* as an authoritative "no bindings", not as corruption.
|
||||
*/
|
||||
function isValidSessionPrList(value: unknown): value is SessionPrList {
|
||||
if (value === null || typeof value !== 'object') return false;
|
||||
const prs = (value as Record<string, unknown>)['prs'];
|
||||
return Array.isArray(prs) && prs.length > 0 && prs.every(isValidSessionPr);
|
||||
return Array.isArray(prs) && prs.every(isValidSessionPr);
|
||||
}
|
||||
|
||||
export type SessionPrsReadResult =
|
||||
| { status: 'ok'; prs: SessionPr[] }
|
||||
| { status: 'absent' }
|
||||
| { status: 'invalid' };
|
||||
|
||||
/**
|
||||
* Read the sidecar. Returns null when the file does not exist, is invalid
|
||||
* JSON, or fails the shape check. Throws only on unexpected I/O errors.
|
||||
* Read the sidecar, distinguishing an ABSENT file (the session was never
|
||||
* bound, or its last binding was removed — an authoritative "no bindings")
|
||||
* from an INVALID one (bad JSON or failed shape check — the bindings are
|
||||
* unknown, not empty). A well-formed EMPTY list is a valid clear, not
|
||||
* corruption: it reads back as ok with no entries, the same authoritative
|
||||
* "no bindings" as an absent file. Callers that project bindings into
|
||||
* authoritative snapshots need the distinction; a corrupt sidecar must not
|
||||
* read as an unbind. Throws only on unexpected I/O errors.
|
||||
*/
|
||||
export async function readSessionPrs(
|
||||
export async function readSessionPrsDetailed(
|
||||
filePath: string,
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<SessionPr[] | null> {
|
||||
): Promise<SessionPrsReadResult> {
|
||||
let raw: string;
|
||||
try {
|
||||
options.signal?.throwIfAborted();
|
||||
@@ -168,7 +182,9 @@ export async function readSessionPrs(
|
||||
: await fs.readFile(filePath, 'utf-8');
|
||||
} catch (error) {
|
||||
options.signal?.throwIfAborted();
|
||||
if (isNodeError(error) && error.code === 'ENOENT') return null;
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
return { status: 'absent' };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
options.signal?.throwIfAborted();
|
||||
@@ -176,11 +192,23 @@ export async function readSessionPrs(
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
return { status: 'invalid' };
|
||||
}
|
||||
options.signal?.throwIfAborted();
|
||||
if (!isValidSessionPrList(parsed)) return null;
|
||||
return parsed.prs;
|
||||
if (!isValidSessionPrList(parsed)) return { status: 'invalid' };
|
||||
return { status: 'ok', prs: parsed.prs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the sidecar. Returns null when the file does not exist, is invalid
|
||||
* JSON, or fails the shape check. Throws only on unexpected I/O errors.
|
||||
*/
|
||||
export async function readSessionPrs(
|
||||
filePath: string,
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<SessionPr[] | null> {
|
||||
const result = await readSessionPrsDetailed(filePath, options);
|
||||
return result.status === 'ok' ? result.prs : null;
|
||||
}
|
||||
|
||||
/** Writes the PR sidecar via `atomicWriteJSON`. */
|
||||
|
||||
@@ -97,6 +97,35 @@ const DINGTALK_WITH_ACCESS: DaemonChannelTypeDescriptor = {
|
||||
],
|
||||
};
|
||||
|
||||
// Same field keys as the real GitHub descriptor; labels/descriptions
|
||||
// intentionally differ from the i18n values so a missing FIELD_LABEL_KEYS
|
||||
// entry or i18n key surfaces the untranslated descriptor fallback.
|
||||
const GITHUB_BOUND: DaemonChannelTypeDescriptor = {
|
||||
type: 'github',
|
||||
displayName: 'GitHub',
|
||||
manageable: true,
|
||||
fields: [
|
||||
{ key: 'token', label: 'Token (descriptor)', kind: 'secret' },
|
||||
{
|
||||
key: 'deliverToBoundSessions',
|
||||
label: 'Bound sessions (descriptor)',
|
||||
kind: 'boolean',
|
||||
default: 'false',
|
||||
description: 'Descriptor description for bound sessions',
|
||||
},
|
||||
{
|
||||
key: 'prWatchEvents',
|
||||
label: 'Watch events (descriptor)',
|
||||
kind: 'string-list',
|
||||
description: 'Descriptor description for watch events',
|
||||
options: [
|
||||
{ value: 'ci_failure', label: 'ci_failure' },
|
||||
{ value: 'merge_conflict', label: 'merge_conflict' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const OPTIONAL_SECRET: DaemonChannelTypeDescriptor = {
|
||||
...DINGTALK,
|
||||
fields: DINGTALK.fields.map((field) =>
|
||||
@@ -771,6 +800,53 @@ describe('ChannelEditorDialog', () => {
|
||||
expect(description).not.toContain('DESCRIPTOR FALLBACK COPY');
|
||||
});
|
||||
|
||||
it('renders the bound-session fields with translated EN labels', async () => {
|
||||
await renderDialog({ descriptor: GITHUB_BOUND });
|
||||
const labels = Array.from(document.querySelectorAll('label')).map(
|
||||
(l) => l.textContent ?? '',
|
||||
);
|
||||
expect(labels.some((t) => t.includes('Deliver To PR-Bound Sessions'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(labels.some((t) => t.includes('PR Watch Events'))).toBe(true);
|
||||
// No descriptor fallback may leak through for these keys.
|
||||
expect(labels.some((t) => t.includes('(descriptor)'))).toBe(false);
|
||||
// fieldDescription falls back to field.description whenever the
|
||||
// `<labelKey>.description` key is missing, so label-only assertions would
|
||||
// stay green with the raw descriptor copy rendered under each field.
|
||||
expect(document.body.textContent ?? '').not.toContain(
|
||||
'Descriptor description',
|
||||
);
|
||||
expect(
|
||||
descriptionOf(fieldByLabel('Deliver To PR-Bound Sessions')),
|
||||
).toContain('instead of a channel-owned session');
|
||||
expect(descriptionOf(fieldByLabel('PR Watch Events'))).toContain(
|
||||
'Leave empty to disable',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the bound-session fields with translated zh-CN labels', async () => {
|
||||
await renderDialog({ descriptor: GITHUB_BOUND, language: 'zh-CN' });
|
||||
const labels = Array.from(document.querySelectorAll('label')).map(
|
||||
(l) => l.textContent ?? '',
|
||||
);
|
||||
expect(labels.some((t) => t.includes('投递到 PR 绑定会话'))).toBe(true);
|
||||
expect(labels.some((t) => t.includes('PR 状态监视事件'))).toBe(true);
|
||||
expect(labels.some((t) => t.includes('(descriptor)'))).toBe(false);
|
||||
// With the `.description` keys dropped from BOTH locale maps the
|
||||
// translator falls through to field.description, so pin the rendered
|
||||
// description copy too — the ZH substrings occur in no other locale.
|
||||
expect(document.body.textContent ?? '').not.toContain(
|
||||
'Descriptor description',
|
||||
);
|
||||
expect(descriptionOf(fieldByLabel('投递到 PR 绑定会话'))).toContain(
|
||||
'以 prompt 形式投递进该会话,而不是频道自有会话',
|
||||
);
|
||||
expect(descriptionOf(fieldByLabel('PR 状态监视事件'))).toContain(
|
||||
'为绑定 PR 监视的状态转换',
|
||||
);
|
||||
});
|
||||
|
||||
it('localizes the instructions copy for zh-CN operators', async () => {
|
||||
await renderDialog({
|
||||
descriptor: MULTILINE_INSTRUCTIONS,
|
||||
|
||||
@@ -96,6 +96,9 @@ const FIELD_LABEL_KEYS: Record<string, Record<string, string>> = {
|
||||
baseUrl: 'channels.editor.field.github.baseUrl',
|
||||
groupPolicy: 'channels.editor.field.github.groupPolicy',
|
||||
reasonFilter: 'channels.editor.field.github.reasonFilter',
|
||||
deliverToBoundSessions:
|
||||
'channels.editor.field.github.deliverToBoundSessions',
|
||||
prWatchEvents: 'channels.editor.field.github.prWatchEvents',
|
||||
},
|
||||
gitlab: {
|
||||
token: 'channels.editor.field.gitlab.token',
|
||||
|
||||
@@ -4538,6 +4538,21 @@ const EN: Messages = {
|
||||
'member_feature_requested — organization members asked to enable a feature such as Copilot',
|
||||
'channels.editor.field.github.reasonFilter.option.security_advisory_credit':
|
||||
'security_advisory_credit — the bot was credited for contributing to a security advisory',
|
||||
'channels.editor.field.github.deliverToBoundSessions':
|
||||
'Deliver To PR-Bound Sessions',
|
||||
'channels.editor.field.github.deliverToBoundSessions.description':
|
||||
'Deliver events for PRs bound to a live session into that session as a prompt, instead of a channel-owned session. Requires a daemon-managed channel.',
|
||||
'channels.editor.field.github.prWatchEvents': 'PR Watch Events',
|
||||
'channels.editor.field.github.prWatchEvents.description':
|
||||
'Optional. State transitions to watch for bound PRs (requires Deliver To PR-Bound Sessions). Leave empty to disable.',
|
||||
'channels.editor.field.github.prWatchEvents.option.ci_failure':
|
||||
'ci_failure — CI checks turn red on a head commit',
|
||||
'channels.editor.field.github.prWatchEvents.option.merge_conflict':
|
||||
'merge_conflict — the PR develops a merge conflict',
|
||||
'channels.editor.field.github.prWatchEvents.option.head_change':
|
||||
'head_change — the PR’s head commit changes',
|
||||
'channels.editor.field.github.prWatchEvents.option.state_change':
|
||||
'state_change — the PR is merged or closed',
|
||||
'channels.editor.field.gitlab.token': 'Personal Access Token',
|
||||
'channels.editor.field.gitlab.token.description':
|
||||
'PAT with "read_api" + "api" scopes',
|
||||
@@ -8965,6 +8980,20 @@ const ZH: Messages = {
|
||||
'member_feature_requested — 组织成员请求启用 Copilot 等功能',
|
||||
'channels.editor.field.github.reasonFilter.option.security_advisory_credit':
|
||||
'security_advisory_credit — 机器人因参与安全公告而被致谢',
|
||||
'channels.editor.field.github.deliverToBoundSessions': '投递到 PR 绑定会话',
|
||||
'channels.editor.field.github.deliverToBoundSessions.description':
|
||||
'将已绑定到活跃会话的 PR 事件以 prompt 形式投递进该会话,而不是频道自有会话。需要 daemon 托管的频道。',
|
||||
'channels.editor.field.github.prWatchEvents': 'PR 状态监视事件',
|
||||
'channels.editor.field.github.prWatchEvents.description':
|
||||
'可选。为绑定 PR 监视的状态转换(需开启"投递到 PR 绑定会话")。留空则禁用。',
|
||||
'channels.editor.field.github.prWatchEvents.option.ci_failure':
|
||||
'ci_failure — CI 检查在 head 提交上变红',
|
||||
'channels.editor.field.github.prWatchEvents.option.merge_conflict':
|
||||
'merge_conflict — PR 出现合并冲突',
|
||||
'channels.editor.field.github.prWatchEvents.option.head_change':
|
||||
'head_change — PR 的 head 提交变更',
|
||||
'channels.editor.field.github.prWatchEvents.option.state_change':
|
||||
'state_change — PR 被合并或关闭',
|
||||
'channels.editor.field.gitlab.token': '个人访问令牌',
|
||||
'channels.editor.field.gitlab.token.description':
|
||||
'需要 "read_api" + "api" 权限的 PAT',
|
||||
|
||||
Reference in new issue
Block a user