refactor(serve): share the prompt admission with in-process callers

Move the admission core of POST /session/:id/prompt (the idempotency
ring, the caller classification, the enqueue with its admission signal,
the PR-binding backfill and the turn logging) out of the route into
routes/session-prompt-admission.ts, so the daemon's PR-state watch can
admit a prompt in-process instead of over HTTP (PR A of
docs/design/2026-11-04-daemon-pr-state-transitions.md, the shared
admission).

classifyPromptRequest strips the request-envelope fields and the
classification markers and accepts each marker only from the caller it
is meant for, as the route did. admitSessionPrompt stays synchronous:
the dedupe check, the enqueue and remembering the key still run with no
await in between, the dedupe answer still comes before any delivery
authorization is minted, and a failed enqueue still revokes it and is
thrown back for the route to answer. The authorization is now minted
after the cursor reads rather than before them, so a session that closes
between the lookup and the enqueue no longer leaves one outstanding, and
an empty key or one longer than the route's 200-character limit throws,
since an in-process caller does not pass through the route's validation.
The route keeps the body validation, the abort wiring, the span
attribute (set through a callback at the same point) and the 202.

The backfill now also skips a turn classified as a trusted channel
prompt, which an in-process caller passes without a request marker; a
request's own marker still skips it as before, so the route behaves as
it did. The design doc says so rather than keying the skip on the
trusted classification alone, which would have started backfilling
text relayed by a worker without the credential.
describePromptTurnFailure moves with the module and is re-exported from
routes/session.ts.
This commit is contained in:
wenshao committed 2026-11-07 21:35:03 +08:00
1 parent 4c8ebeab4d
commit ce843b86fc
5 files changed
+1061 -385

No files matched your search

@@ -109,7 +109,7 @@ Each transition's key is `pr-state:<owner/repo>#<number>:<type>:<headSha or prev
- **Role-aware actions.** With `sessionPrWatch.actions` set, delivery follows the channel's role-aware mode (#16258) restricted to the four transitions. Keys are `<type>`, `<role>:<type>`, `<role>:default` and `default`, with `<role>` one of `review`, `verify`, `fix`; values are instruction templates, non-empty, trimmed and at most 2,000 code points as in the channel, with the channel's variables (`%repo%`, `%pr%`, `%url%`, `%title%`, `%event%`, `%sender%`, `%role%`): `%repo%` is `owner/repo` as the group winner's bound URL spells it at delivery time, keeping its case as the channel's does, `%pr%` is its number, `%url%` is that URL's origin with `www.` stripped and its port kept, followed by `/<owner/repo>/pull/<number>` (the watch file's key, lowercased and without a port, is not used for display), `%title%` comes from the last view, and `%sender%` is `GitHub`, as for the channel's watch events; every value is sanitised, and `%title%` and `%sender%` are also capped at 200 code points with a visible marker, as in the channel. Each role group — review, verify, fix and untyped, by the binding's `role` in the sidecar — resolves `<role>:<type>` → `<role>:default` → `<type>` → `default`, untyped bindings resolve only the bare chain, and no match skips that group for the transition. A group is matched when its chain resolves a template; a matched group with no eligible binder on a complete scan is skipped for the transition, and on a partial scan it waits (Scan completeness). Each matched group has its own winner (the most recently active binder with that role), its own admission under the group-suffixed key, and a prompt carrying the rendered instruction in the channel's labelled trusted-instruction block, under the channel's untrusted-data notice that extends to the GitHub-derived values substituted into it; at most four deliveries per transition. Each group's admission records the group in the entry's `served` list and the group-suffixed key in `delivered`, in one write, and a drain skips a served group, so a group is never served twice while another group's winner is still dormant; the entry leaves `pending` once every matched group is served. Without `actions`, delivery is the single winner and the generic prompt. The key and template validation, the chain resolution and the rendering are not reimplemented: PR A moves the channel's own code for them out of `GithubAdapter.ts` into `@qwen-code/channel-base`, with the event vocabulary and the comment-lane class as parameters and the renderer taking the variable values instead of a channel envelope — the channel passes `watch:<type>`, its notification reasons and, for comment events, the `comment` lane (so `<role>:comment` and `comment` still catch them), and derives the values from its envelope as today; the daemon passes the four bare transition types, no lane and the values above, so `fix:ci_failure` is a valid key here and a notification reason such as `mention` is not — and the adapter keeps using it for comment events and, until PR B, for watch events, so `%%` renders a literal `%` and an unknown `%foo%` stays literal exactly as in the channel. The move fixes two quirks of the channel's renderer: a `%name%` naming an `Object.prototype` member, such as `%constructor%`, rendered that member instead of staying literal, and only `%title%` and `%sender%` were sanitised. `%event%` renders the bare transition type (`ci_failure`); under the alias (§7) a template written for the channel, where it rendered `watch:ci_failure`, therefore renders the bare type.
- **Scan completeness.** The sweep calls the scan in-process for its view set and its recipients. On this tree `partial` means something could not be read — a registered workspace with no runtime, a chats directory that cannot be listed, an unreadable sidecar or transcript, a live session list that cannot be fetched — never a page boundary: the scan is one directory listing per workspace (#16241). A workspace the scan cannot enumerate contributes no views and no newly listed recipients this tick — its sessions' retained rows still compete (Target) — and nothing else depends on it, because no watch state lives in its sidecars; `partial` defers every negative decision: deleting a watch file (§2; under partial scans only the 7-day `lastBoundAt` rule applies), skipping a role group for lack of an eligible binder, and dropping a transition because no candidate remains (below) are decided only on a complete scan; on a partial one the entry stays in `pending` for a later drain, within the 16-entry cap and the 7-day age limit (§5). The slim-listing `state` refresh is unaffected.
- **Archive and restore.** Archiving a session changes nothing in the PR's watch file: the session only leaves the recipient candidates, and its sidecar keeps its top-level `state`, still refreshed. A session restored from the archive rejoins them. If another binder kept the PR watched, the file is current and the restored session receives transitions from then on; if nobody followed the PR, its file was deleted (§2) and the PR initialises silently, so what changed while the session was archived is not replayed into it — the restore is a user action, and the refreshed top-level `state` already shows it in the sidebar. A restore within 7 days of the last binder leaving, while scans stay partial, resumes from the last observation and delivers a transition in the gap once.
- **Mechanism.** PR A extracts the prompt admission from the route handler into a function the route and the refresh both call — the idempotency ring, the classification and the backfill skip move with it, the skip keyed on the trusted classification instead of on the request's own marker — and the refresh calls it in-process, together with the code behind the resume route. The refresh rides the channel bound relay's classification end to end instead of adding a grant: it passes `context.channelPrompt`, which the bridge turns into `qwen.channel.prompt` only from trusted context and the session child honours only from a trusted private parent, and no PR-binding backfill runs on the injected text. The turn carries every effect that classification has for the bound relay in the same sessions today (`Session.ts`), and accepts them: it skips the loop-detected rejection, records no branch checkpoint (it is not a rewind point), runs with the repeated-tool-failure guard off, does not count as a user submission turn, cannot have its model response cut short by queued or send-now user input, and its Stop hook is not interruptible. The transition key (group-suffixed with role-aware actions) is the `idempotencyKey`, so a retry to the same session within the daemon's lifetime deduplicates into the original admission. No display text is set: like the channel's bound relay today, the injected prompt appears in the session's transcript as sent, untrusted-data notice included, so the session's user sees exactly what the model received (the bridge keeps display texts only for channel-owned sessions). No new marker is introduced, so no strip list changes; a route test pins that a client body carrying `qwen.channel.prompt` without the worker credential gets no classification.
- **Mechanism.** PR A extracts the prompt admission from the route handler into a function the route and the refresh both call — the idempotency ring, the classification and the backfill skip move with it, the skip honouring the trusted classification as well as the request's own marker (an in-process caller has no request marker; a request's own marker, even without the worker credential, still keeps relayed text out of the backfill and opts only its sender out) — and the refresh calls it in-process, together with the code behind the resume route. The refresh rides the channel bound relay's classification end to end instead of adding a grant: it passes `context.channelPrompt`, which the bridge turns into `qwen.channel.prompt` only from trusted context and the session child honours only from a trusted private parent, and no PR-binding backfill runs on the injected text. The turn carries every effect that classification has for the bound relay in the same sessions today (`Session.ts`), and accepts them: it skips the loop-detected rejection, records no branch checkpoint (it is not a rewind point), runs with the repeated-tool-failure guard off, does not count as a user submission turn, cannot have its model response cut short by queued or send-now user input, and its Stop hook is not interruptible. The transition key (group-suffixed with role-aware actions) is the `idempotencyKey`, so a retry to the same session within the daemon's lifetime deduplicates into the original admission. No display text is set: like the channel's bound relay today, the injected prompt appears in the session's transcript as sent, untrusted-data notice included, so the session's user sees exactly what the model received (the bridge keeps display texts only for channel-owned sessions). No new marker is introduced, so no strip list changes; a route test pins that a client body carrying `qwen.channel.prompt` without the worker credential gets no classification.
- **Dormant sessions.** A bound session that is not live is restored through the resume path the channel accessor uses — PR A extracts the restore from the route's `restoreSessionHandler` into a function the route and the refresh share — then delivered to, and the refresh releases the client registration the restore mints once the delivered prompt settles, as the channel accessor does, so a woken session can still close when idle. Restores never run on the tick: the drain starts a dormant recipient's restore and admission without waiting for them (at most four at once; the rest wait for a later drain), the key stays in flight until they settle (§5), and a restore timeout therefore never stretches a tick. `wakeDormant` is on by default so a daemon restart does not strand events in `pending`; with `wakeDormant: false` the transition stays in `pending` and is delivered on the session's next live turn (the drain also runs on resume, through a hook PR A adds to that shared restore), and the 16-per-PR cap and the 7-day age limit (§5) apply with a log line, so nothing vanishes silently. Restore failures are split as the channel splits `BoundSessionGoneError` from `BoundSessionUnavailableError`. Only a permanent outcome — the channel's Gone set: the session record is gone (404), or a 409 `session_archived`, `worktree_session_superseded`, `worktree_marker_missing` or `worktree_reset_interrupted` — excludes the session; the drain then re-resolves the recipient among the remaining binders, within its role group with role-aware actions, and drops the transition with a log line only when a complete scan leaves no candidate. A temporary outcome — the restore timeout, a 503 (session limit, draining), a 429, a 409 `restore_in_progress` or `session_busy`, or `session_writer_conflict` — leaves the entry pending for the same recipient at the next drain. A restore returns `session_writer_conflict` while another daemon holds the session only when the session writer lease is on (`experimental.sessionWriterLease`, off by default for ordinary workspace sessions), and the lease covers daemon and ACP writers only: interactive and headless `qwen` CLI sessions stay outside it. A session another process writes live — a CLI session in a terminal, or a session another daemon over the same runtime base holds without the lease — can therefore be restored by the owner as a second writer, and the event lands in a copy nobody is using; with the lease on, the owner's copy can also fail after admission (`session_transcript_changed`, `session_writer_lost`), which counts as delivery. This is the exposure the channel's bound delivery has today, since it wakes sessions through the same restore path. Fencing interactive writers is out of scope, and operators who run several daemons over one runtime base turn the lease on. An excluded session rejoins the candidates when the owner's own unarchive, restore or re-bind path names it, and in any case after one hour. Nothing is ever published anywhere.
- **Prompt text.** The channel's bound prompt shape and its hygiene: an event header, the untrusted-event-data notice, PR identity (repo, number, the title from the last view, URL), an `Event:` line and the summary (`ci_failure` names up to five failed checks from the rollup). Every line built from GitHub data — title, check names, branch names, the summary — passes through `sanitizePromptText` from `@qwen-code/channel-base` exactly as `buildBoundSessionPrompt` does (it folds CR/LF/TAB and the other C0/DEL controls and peels start-of-line tags, so a check-run `name:` taken from the PR branch's own workflow YAML cannot open a prompt line outside the `Event:` attribution), and the assembled body keeps the channel's 6,000-code-point cap with its visible truncation marker. The envelope is not reimplemented either: the header line, the untrusted-data and not-published notices in both variants, the labelled trusted-instruction block, the sanitised data lines, the `Role:` line and the capped body move with the template contract into `@qwen-code/channel-base` (`buildBoundEventPrompt`), which the adapter's `buildBoundSessionPrompt` now calls and the daemon calls with its own provenance line, so a later change to any of them reaches both. The move also gives the trusted block a fixed label, `[Operator instruction — trusted]`, with the source on a `Source:` line under it: the content of the channel's old label, `[Operator instruction — trusted, from this channel's configuration]`, was 65 characters, one over the 64 that `sanitizePromptText` peels from a start-of-line tag, so an event body that began with a copy of it kept the brackets. The reply stays in the session.
@@ -152,7 +152,7 @@ The first minor release after B rejects `prWatchEvents` and the `watch:*` keys a
| daemon | `packages/cli/src/commands/channel/pidfile.ts`, `packages/cli/src/serve/serve-app-lifecycle.ts` | the identity-qualified record and exclusive create generalised to a caller-chosen path; the close stops the PR watch before the owner record is removed |
| daemon | `packages/cli/src/serve/server/session-pr-refresh.ts` | per-PR observation by URL over the scan's bindings (cap 30, rotating GitHub offset, tick-level deadline, rotating workspace order, allowed hosts, workspace environment, daemon-private working directory, closed PRs outside the listing, untrusted primary view-only), transition detection against the watch file, file deletion, the drain with in-flight keys, delivery through the shared admission and restore with the registration released and restores off the tick, role-aware actions, the failed-view warning, the internal switch (off in PR A); the aliases (PR B) |
| daemon | `packages/cli/src/serve/routes/session-pr-bindings.ts` | the in-process scan entry point for the refresh |
| daemon | `packages/cli/src/serve/routes/session.ts` | extract the prompt admission (idempotency ring, classification, backfill skip on the trusted classification) and the restore behind `restoreSessionHandler`, with a resume hook for the drain, into functions the route and the refresh share; the unarchive, restore and re-bind paths clear a recipient exclusion; the bridge and the session child already re-arm `qwen.channel.prompt` from trusted context and need no change |
| daemon | `packages/cli/src/serve/routes/session.ts` | extract the prompt admission (idempotency ring, classification, backfill skip on the trusted classification or the request's own marker; now `routes/session-prompt-admission.ts`) and the restore behind `restoreSessionHandler`, with a resume hook for the drain, into functions the route and the refresh share; the unarchive, restore and re-bind paths clear a recipient exclusion; the bridge and the session child already re-arm `qwen.channel.prompt` from trusted context and need no change |
| daemon | `packages/cli/src/serve/run-qwen-serve.ts` | late-bound getters for the shared admission and restore, passed to the timer |
| config (PR B) | settings schema + `packages/cli/src/serve/server.ts` | `sessionPrWatch` (`events`, `wakeDormant`, `actions`) / `QWEN_SESSION_PR_WATCH_EVENTS`, `QWEN_SESSION_PR_WATCH_WAKE_DORMANT` |
| channel-base and channel (PR A) | `packages/channels/base/src` (new module), `packages/channels/github/src/GithubAdapter.ts` | the bound-action template contract (key and template validation with the event vocabulary as a parameter, chain resolution with the lane class as a parameter, rendering from variable values) and the bound prompt envelope (header, both notice variants, the trusted-instruction block, the sanitised data lines, the `Role:` line, the capped body) moved out of the adapter, which imports them back with the fixes of §4 and shares them with the daemon |
@@ -109,7 +109,7 @@ owner 在一次完整扫描(无 `partial`,§4)发现某 PR 没有任何绑
- **角色化动作。** 设置 `sessionPrWatch.actions` 后,投递遵循 channel 的角色化模式(#16258),限于四种转换。键为 `<type>`、`<role>:<type>`、`<role>:default` 与 `default`,`<role>` 取 `review`、`verify`、`fix` 之一;值为指令模板,与 channel 一样须非空、经裁剪且不超过 2,000 码点,变量与 channel 相同(`%repo%`、`%pr%`、`%url%`、`%title%`、`%event%`、`%sender%`、`%role%`):`%repo%` 为投递时该组赢家的绑定 URL 中原样拼写的 `owner/repo`,与 channel 一样保留大小写,`%pr%` 为其编号,`%url%` 为该 URL 的 origin(去掉 `www.`、保留端口)后接 `/<owner/repo>/pull/<number>`(监视文件的键已转小写且不含端口,不用于显示),`%title%` 取自最近一次 view,`%sender%` 为 `GitHub`,与 channel 的监视事件相同;每个值都经过清洗,`%title%` 与 `%sender%` 还与 channel 一样以 200 码点为上限、带可见标记。每个角色组——review、verify、fix 与无类型,按 sidecar 中绑定的 `role`——依次解析 `<role>:<type>` → `<role>:default` → `<type>` → `default`,无类型绑定只解析裸链,无匹配则该组跳过这次转换。一个组的链解析出模板时,该组为匹配;匹配的组在完整扫描中没有合格绑定者时跳过这次转换,在不完整的扫描中则等待(扫描完整性)。每个匹配的组有自己的赢家(该角色最近活跃的绑定者)、以加组后缀的 key 单独准入,prompt 在 channel 带标签的受信指令块中携带渲染后的指令,并沿用 channel 的不可信数据提示——该提示同样覆盖代入指令中的 GitHub 派生值;每个转换最多四次投递。每个组准入后,都以一次写入把该组记进条目的 `served`、把加组后缀的 key 记进 `delivered`;排空跳过已服务的组,因此另一组的赢家仍休眠时,已服务的组不会被服务第二次;每个匹配组都已服务后,条目才离开 `pending`。没有 `actions` 时,投递为单一赢家与通用 prompt。键与模板校验、链解析与渲染都不重新实现:PR A 把 channel 自己的这部分代码从 `GithubAdapter.ts` 移到 `@qwen-code/channel-base`,以事件词表与评论 lane 类别为参数,渲染函数接收变量值而不是 channel 的 envelope——channel 传入 `watch:<type>`、它的通知原因,以及评论事件的 `comment` lane(因此 `<role>:comment` 与 `comment` 仍能匹配它们),并照旧从 envelope 推出变量值;daemon 传入四种裸转换类型、不传 lane,并传入上述变量值,因此这里 `fix:ci_failure` 是合法的键、`mention` 这类通知原因则不是——适配器继续用它处理评论事件,并在 PR B 之前处理监视事件,因此 `%%` 渲染为字面的 `%`、未知的 `%foo%` 原样保留,与 channel 完全一致。迁移顺带修正 channel 渲染函数的两处问题:指向 `Object.prototype` 成员的 `%name%`(如 `%constructor%`)此前会渲染出该成员而不是原样保留,且此前只有 `%title%` 与 `%sender%` 经过清洗。`%event%` 渲染为裸转换类型(`ci_failure`);因此在别名下(§7),为 channel 编写、原本渲染为 `watch:ci_failure` 的模板会渲染为裸类型。
- **扫描完整性。** 扫描在进程内被调用,为刷新提供 view 集合与收件者。在当前代码中 `partial` 表示有东西读不到——已注册但没有 runtime 的 workspace、列不出来的 chats 目录、读不了的 sidecar 或 transcript、拿不到的 live 会话列表——绝不是分页边界:扫描是每个 workspace 一次目录列表(#16241)。无法枚举的 workspace 本 tick 不提供 view,也不提供新列出的收件者——其会话的保留行仍参与竞争(目标)——此外再无任何东西依赖它,因为没有监视状态存放在它的 sidecar 中;`partial` 推迟所有否定性的决定:删除监视文件(§2;扫描不完整时只适用 7 天的 `lastBoundAt` 规则)、因没有合格绑定者而跳过某个角色组、因没有剩余候选而丢弃转换(下文)都只在完整扫描时判定;不完整的扫描中条目留在 `pending` 等待之后的排空,受 16 条上限与 7 天年龄上限约束(§5)。slim 列表的 `state` 刷新不受影响。
- **归档与恢复。** 归档会话不改动该 PR 监视文件中的任何东西:该会话只是离开收件者候选,其 sidecar 保留顶层 `state` 并照常被刷新。从归档恢复的会话重新加入候选。若另有绑定者一直在跟随该 PR,文件是新的,恢复的会话此后收到转换;若无人跟随该 PR,其文件已被删除(§2),该 PR 静默初始化,因此会话归档期间发生的变化不会补投给它——恢复是用户操作,刷新过的顶层 `state` 已经在侧栏显示了这些变化。在最后一个绑定者离开后 7 天内、且扫描一直不完整时的恢复,会从最后一次观察继续,把间隔期内的转换投递一次。
- **机制。** PR A 把 prompt 准入从路由处理函数中抽成一个路由与刷新共用的函数——幂等环、分类与回填跳过随之迁移,回填跳过改按受信分类判断,而不是按请求自带的标记——刷新在进程内调用它,以及恢复路由背后的代码。刷新全程沿用 channel 绑定中继的分类,而不是新增一种授权:它传入 `context.channelPrompt`,bridge 只从受信 context 把它变成 `qwen.channel.prompt`,会话子进程只在受信的私有父进程下认可它,注入文本也不做 PR 绑定回填。该回合带有这一分类今天在同样的会话中对绑定中继所具有的全部效果(`Session.ts`),并接受它们:跳过循环检测拒绝,不记录分支检查点(它不是 rewind 点),关闭重复工具失败防护,不计为用户提交回合,排队或立即发送的用户输入无法打断其模型响应,其 Stop hook 不可中断。以转换 key(启用角色化动作时加组后缀)作为 `idempotencyKey`,因此 daemon 生命周期内对同一会话的重试会去重到原始准入。不设置显示文本:与 channel 今天的绑定中继一样,注入的 prompt 在会话 transcript 中按原样显示,包括不可信数据提示,因此会话的用户看到的正是模型收到的内容(bridge 只为 channel 自有会话保留显示文本)。不新增任何标记,因此剥离清单无需改动;一个路由测试钉住:不带 worker 凭据、携带 `qwen.channel.prompt` 的客户端请求体得不到分类。
- **机制。** PR A 把 prompt 准入从路由处理函数中抽成一个路由与刷新共用的函数——幂等环、分类与回填跳过随之迁移,回填跳过既看受信分类,也看请求自带的标记(进程内调用方没有请求标记;请求自带的标记即使没有 worker 凭据,也仍让中继文本不进回填,且只影响发送方自己)——刷新在进程内调用它,以及恢复路由背后的代码。刷新全程沿用 channel 绑定中继的分类,而不是新增一种授权:它传入 `context.channelPrompt`,bridge 只从受信 context 把它变成 `qwen.channel.prompt`,会话子进程只在受信的私有父进程下认可它,注入文本也不做 PR 绑定回填。该回合带有这一分类今天在同样的会话中对绑定中继所具有的全部效果(`Session.ts`),并接受它们:跳过循环检测拒绝,不记录分支检查点(它不是 rewind 点),关闭重复工具失败防护,不计为用户提交回合,排队或立即发送的用户输入无法打断其模型响应,其 Stop hook 不可中断。以转换 key(启用角色化动作时加组后缀)作为 `idempotencyKey`,因此 daemon 生命周期内对同一会话的重试会去重到原始准入。不设置显示文本:与 channel 今天的绑定中继一样,注入的 prompt 在会话 transcript 中按原样显示,包括不可信数据提示,因此会话的用户看到的正是模型收到的内容(bridge 只为 channel 自有会话保留显示文本)。不新增任何标记,因此剥离清单无需改动;一个路由测试钉住:不带 worker 凭据、携带 `qwen.channel.prompt` 的客户端请求体得不到分类。
- **休眠会话。** 已绑定但不在线的会话通过 channel 访问器所用的恢复路径恢复——PR A 把恢复从路由的 `restoreSessionHandler` 中抽成路由与刷新共用的函数——后再投递,并像 channel 访问器那样在投递的 prompt 结束后释放恢复所创建的客户端注册,使被唤醒的会话空闲时仍能关闭。恢复绝不在 tick 上运行:排空启动休眠收件者的恢复与准入后不等待它们(同时最多四个,其余等之后的排空),key 在它们结束前保持在飞(§5),因此恢复超时绝不会拉长 tick。`wakeDormant` 默认开启,这样 daemon 重启不会让事件滞留在 `pending` 中;`wakeDormant: false` 时转换留在 `pending`,在该会话下一次 live 回合投递(排空在恢复时也会运行,经 PR A 在该共用恢复中加入的钩子),每个 PR 16 条的上限与 7 天年龄上限(§5)照常适用并记日志,不会静默消失。恢复失败按 channel 区分 `BoundSessionGoneError` 与 `BoundSessionUnavailableError` 的方式划分。只有永久性结果——即 channel 的 Gone 集合:会话记录已不存在(404),或 409 `session_archived`、`worktree_session_superseded`、`worktree_marker_missing`、`worktree_reset_interrupted`——才排除该会话;排空随即在其余绑定者中重新解析收件者(启用角色化动作时在其角色组内),只有当完整扫描没有留下任何候选时才丢弃该转换并记一行日志。临时性结果——恢复超时、503(会话数上限、draining)、429、409 `restore_in_progress` 或 `session_busy`,或 `session_writer_conflict`——让条目留在 pending,在下一次排空时投给同一收件者。只有在会话写锁开启时(`experimental.sessionWriterLease`,普通 workspace 会话默认关闭),另一个 daemon 持有该会话时恢复才会返回 `session_writer_conflict`,而且该写锁只覆盖 daemon 与 ACP 写入者:交互式与 headless 的 `qwen` CLI 会话不在其内。因此另一个进程正在写入的会话——终端里的 CLI 会话,或共用运行时根目录的另一个 daemon 在未开启写锁时持有的会话——可能被 owner 作为第二个写入者再恢复一份,事件投进一个无人使用的副本;开启写锁时,owner 的副本也可能在准入之后失败(`session_transcript_changed`、`session_writer_lost`),而准入即视为投递。channel 的绑定投递今天也有同样的风险,因为它经同一恢复路径唤醒会话。为交互式写入者加防护不在本设计范围内,在一个运行时根目录上运行多个 daemon 的运维者应开启该写锁。被排除的会话在 owner 自己的取消归档、恢复或重绑路径提到它时重新成为候选,无论如何一小时后也会恢复。任何地方都不会公开发布。
- **prompt 文本。** 沿用 channel 绑定 prompt 的形状及其卫生措施:事件头、不可信事件数据提示、PR 身份(仓库、编号、最后一次 view 的标题、URL)、一行 `Event:` 与摘要(`ci_failure` 从 rollup 中列出最多五个失败的 check)。每一行由 GitHub 数据构成的内容——标题、check 名称、分支名、摘要——都像 `buildBoundSessionPrompt` 那样经过 `@qwen-code/channel-base` 的 `sanitizePromptText`(折叠 CR/LF/TAB 及其它 C0/DEL 控制字符、剥掉行首标签,因此取自 PR 分支自己 workflow YAML 的 check-run `name:` 无法在 `Event:` 归属之外另起一行 prompt),拼好的正文保留 channel 的 6,000 码点上限与可见的截断标记。外层包装同样不重写:事件头、两种变体下的不可信数据提示与不发布提示、带标签的受信指令块、经过清洗的数据行、`Role:` 行与有上限的正文随模板契约一起移入 `@qwen-code/channel-base`(`buildBoundEventPrompt`),适配器的 `buildBoundSessionPrompt` 现在调用它,daemon 以自己的来源行调用它,因此日后对其中任何一项的修改都会同时作用于两者。迁移同时给受信块换成固定标签 `[Operator instruction — trusted]`,配置出处写在其下的 `Source:` 行:channel 原来的标签 `[Operator instruction — trusted, from this channel's configuration]` 内容为 65 个字符,比 `sanitizePromptText` 剥除行首标签时的 64 个字符上限多一个,所以此前以其副本开头的事件正文会保留方括号。回复留在会话内。
@@ -152,7 +152,7 @@ B 之后的第一个 minor 版本在 `connect()` 以同一条信息拒绝 `prWat
| daemon | `packages/cli/src/commands/channel/pidfile.ts`、`packages/cli/src/serve/serve-app-lifecycle.ts` | 带身份校验的记录与独占创建推广为由调用方指定路径;关闭时先停止 PR 监视再删除 owner 记录 |
| daemon | `packages/cli/src/serve/server/session-pr-refresh.ts` | 在扫描所得绑定上按 URL 的逐 PR 观察(上限 30、按 GitHub 偏移旋转、tick 级截止时间、workspace 顺序轮转、允许的 host、workspace 环境、daemon 私有工作目录、列表之外的 closed PR、不受信 primary 只做 view)、对照监视文件的转换检测、文件删除、带在飞 key 的排空、经共用准入与恢复投递并释放注册且恢复不在 tick 上运行、角色化动作、view 失败告警、内部开关(PR A 中关闭);别名(PR B) |
| daemon | `packages/cli/src/serve/routes/session-pr-bindings.ts` | 供刷新使用的进程内扫描入口 |
| daemon | `packages/cli/src/serve/routes/session.ts` | 把 prompt 准入(幂等环、分类、按受信分类判断的回填跳过)与 `restoreSessionHandler` 背后的恢复(带供排空用的恢复钩子)抽成路由与刷新共用的函数;取消归档、恢复与重绑路径清除收件者排除;bridge 与会话子进程本来就从受信 context 重新装配 `qwen.channel.prompt`,无需改动 |
| daemon | `packages/cli/src/serve/routes/session.ts` | 把 prompt 准入(幂等环、分类、按受信分类或请求自带标记判断的回填跳过;现为 `routes/session-prompt-admission.ts`)与 `restoreSessionHandler` 背后的恢复(带供排空用的恢复钩子)抽成路由与刷新共用的函数;取消归档、恢复与重绑路径清除收件者排除;bridge 与会话子进程本来就从受信 context 重新装配 `qwen.channel.prompt`,无需改动 |
| daemon | `packages/cli/src/serve/run-qwen-serve.ts` | 共用准入与恢复的延迟绑定 getter,传给定时器 |
| 配置(PR B) | settings schema + `packages/cli/src/serve/server.ts` | `sessionPrWatch`(`events`、`wakeDormant`、`actions`)/ `QWEN_SESSION_PR_WATCH_EVENTS`、`QWEN_SESSION_PR_WATCH_WAKE_DORMANT` |
| channel-base 与 channel(PR A) | `packages/channels/base/src`(新模块)、`packages/channels/github/src/GithubAdapter.ts` | 绑定动作模板契约(以事件词表为参数的键与模板校验、以 lane 类别为参数的链解析、按变量值渲染)与绑定 prompt 外层包装(事件头、两种变体的提示、受信指令块、经过清洗的数据行、`Role:` 行、有上限的正文)从适配器移出,适配器重新导入(含 §4 所述的修正),并与 daemon 共用 |
@@ -0,0 +1,475 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./session-pr-backfill.js', () => ({
bindSessionPrsFromPromptCommand: vi.fn(async () => undefined),
}));
import {
CHANNEL_PROMPT_META_KEY,
DAEMON_PROMPT_DISPLAY_TEXT_META_KEY,
DAEMON_SUBMITTED_PROMPT_META_KEY,
SUBMITTED_PROMPT_META_KEY,
} from '@qwen-code/acp-bridge/bridgeTypes';
import {
CHANNEL_BOUND_RELAY_META_KEY,
CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY,
registerChannelWorkerPromptAuthorization,
revokeChannelWorkerPromptAuthorization,
} from '../channel-worker-prompt-authorization.js';
import type { WorkspaceRuntime } from '../workspace-registry.js';
import type { SessionArchiveCoordinator } from '../server/session-archive.js';
import { bindSessionPrsFromPromptCommand } from './session-pr-backfill.js';
import {
PROMPT_IDEMPOTENCY_KEY_MAX_LENGTH,
admitSessionPrompt,
classifyPromptRequest,
type SessionPromptClassification,
} from './session-prompt-admission.js';
const mockBind = vi.mocked(bindSessionPrsFromPromptCommand);
type SendPrompt = (
sessionId: string,
request: unknown,
signal: AbortSignal | undefined,
context: { promptId: string; onPromptAdmitted?: () => void } & Record<
string,
unknown
>,
) => Promise<unknown>;
/** The cursor every fake bridge reports; tests move it to see a fresh read. */
const lastEventId = { value: 41 };
/** A runtime whose bridge admits every prompt and settles it on demand. */
function fakeRuntime(
overrides: { trusted?: boolean; sendPrompt?: SendPrompt } = {},
) {
const calls: Array<{
sessionId: string;
request: unknown;
context: Record<string, unknown>;
}> = [];
const settle: Array<{ resolve: () => void; reject: (e: unknown) => void }> =
[];
const sendPrompt: SendPrompt =
overrides.sendPrompt ??
((sessionId, request, _signal, context) => {
calls.push({ sessionId, request, context });
context.onPromptAdmitted?.();
return new Promise<void>((resolve, reject) => {
settle.push({ resolve, reject });
});
});
const runtime = {
workspaceCwd: '/ws',
primary: true,
trusted: overrides.trusted ?? true,
bridge: {
sendPrompt,
getSessionLastEventId: () => lastEventId.value,
getSessionEventEpoch: () => 'epoch-1',
},
} as unknown as WorkspaceRuntime;
return { runtime, calls, settle };
}
const deps = {
archiveCoordinator: {} as SessionArchiveCoordinator,
};
const plain: SessionPromptClassification = {
channelPrompt: false,
requestedChannelPrompt: false,
};
let nextSession = 0;
/** A fresh session id per test: the idempotency ring is module state. */
const freshSessionId = () => `admission-test-${(nextSession += 1)}`;
const reviewPrompt = [{ type: 'text', text: '/review 7' }];
beforeEach(() => {
mockBind.mockClear();
lastEventId.value = 41;
});
describe('classifyPromptRequest', () => {
const runtime = { workspaceCwd: '/ws', primary: true, trusted: true };
afterEach(() => {
revokeChannelWorkerPromptAuthorization('worker-token');
});
it('strips the envelope fields and every marker from the forwarded body', () => {
const { forwardedBody } = classifyPromptRequest(
{
prompt: [],
deadlineMs: 5,
delivery: {},
idempotencyKey: 'k',
_meta: {
[CHANNEL_PROMPT_META_KEY]: true,
[CHANNEL_BOUND_RELAY_META_KEY]: true,
[CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY]: 'forged',
[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY]: 'shown',
[SUBMITTED_PROMPT_META_KEY]: 'typed',
[DAEMON_SUBMITTED_PROMPT_META_KEY]: 'typed',
kept: 1,
},
},
runtime,
);
expect(forwardedBody).toEqual({ prompt: [], _meta: { kept: 1 } });
});
it('gives a channel marker without the worker credential no classification', () => {
const { classification } = classifyPromptRequest(
{
_meta: {
[CHANNEL_PROMPT_META_KEY]: true,
[CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY]: 'forged',
},
},
runtime,
);
expect(classification).toEqual({
channelPrompt: false,
requestedChannelPrompt: true,
});
});
it('classifies a channel prompt carrying a registered worker token', () => {
registerChannelWorkerPromptAuthorization('worker-token', '/ws');
const { classification } = classifyPromptRequest(
{
_meta: {
[CHANNEL_PROMPT_META_KEY]: true,
[CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY]: 'worker-token',
},
},
runtime,
);
expect(classification).toEqual({
channelPrompt: true,
requestedChannelPrompt: true,
});
});
});
describe('admitSessionPrompt', () => {
it('admits a prompt and dedupes a repeated key into the first admission', () => {
const { runtime, calls } = fakeRuntime();
const sessionId = freshSessionId();
const input = {
runtime,
sessionId,
prompt: [{ type: 'text', text: 'hi' }],
request: {},
classification: plain,
idempotencyKey: 'event-1',
};
const first = admitSessionPrompt(deps, input);
lastEventId.value = 57;
const second = admitSessionPrompt(deps, input);
expect(first).toMatchObject({
deduplicated: false,
lastEventId: 41,
eventEpoch: 'epoch-1',
});
// The repeat reads the cursor afresh, so the caller can seed a resume.
expect(second).toEqual({
deduplicated: true,
promptId: first.promptId,
lastEventId: 57,
eventEpoch: 'epoch-1',
});
expect(calls).toHaveLength(1);
expect(calls[0]!.context['promptId']).toBe(first.promptId);
});
it('passes a trusted channel classification to the bridge and skips the backfill', () => {
// The in-process caller: no request marker, only the classification.
const { runtime, calls } = fakeRuntime();
admitSessionPrompt(deps, {
runtime,
sessionId: freshSessionId(),
prompt: reviewPrompt,
request: {},
classification: { channelPrompt: true, requestedChannelPrompt: false },
});
expect(calls[0]!.context['channelPrompt']).toBe(true);
expect(mockBind).not.toHaveBeenCalled();
});
it.each([false, true])(
"skips the backfill for a request's own channel marker (trusted: %s)",
(channelPrompt) => {
const { runtime, calls } = fakeRuntime();
admitSessionPrompt(deps, {
runtime,
sessionId: freshSessionId(),
prompt: reviewPrompt,
request: {},
classification: { channelPrompt, requestedChannelPrompt: true },
});
expect(calls[0]!.context['channelPrompt']).toBe(
channelPrompt ? true : undefined,
);
expect(mockBind).not.toHaveBeenCalled();
},
);
it("backfills a trusted workspace's plain prompt from its first text block only", () => {
const { runtime } = fakeRuntime();
const sessionId = freshSessionId();
admitSessionPrompt(deps, {
runtime,
sessionId,
prompt: [
null,
undefined,
'text',
{ type: 'image', data: 'x', mimeType: 'image/png' },
{ type: 'text', text: '/review 7' },
{ type: 'text', text: '/review 8' },
],
request: {},
classification: plain,
});
expect(mockBind).toHaveBeenCalledTimes(1);
expect(mockBind).toHaveBeenCalledWith(runtime, sessionId, '/review 7', {
archiveCoordinator: deps.archiveCoordinator,
});
mockBind.mockClear();
const untrusted = fakeRuntime({ trusted: false });
admitSessionPrompt(deps, {
runtime: untrusted.runtime,
sessionId: freshSessionId(),
prompt: reviewPrompt,
request: {},
classification: plain,
});
expect(mockBind).not.toHaveBeenCalled();
});
it('forgets the key of a prompt rejected before admission, keeps that of a failed turn', async () => {
const sessionId = freshSessionId();
let admit = false;
const { runtime } = fakeRuntime({
sendPrompt: (_id, _request, _signal, context) => {
if (admit) context.onPromptAdmitted?.();
return Promise.reject(new Error('nope'));
},
});
const input = {
runtime,
sessionId,
prompt: [{ type: 'text', text: 'hi' }],
request: {},
classification: plain,
idempotencyKey: 'event-1',
};
const rejected = admitSessionPrompt(deps, input);
if (rejected.deduplicated) throw new Error('unexpected dedupe');
await rejected.settled;
// Never admitted: a retry is a fresh admission, not a phantom dedupe.
admit = true;
const retried = admitSessionPrompt(deps, input);
expect(retried.deduplicated).toBe(false);
if (retried.deduplicated) return;
await retried.settled;
// Admitted, then the turn failed: the key stays.
expect(admitSessionPrompt(deps, input)).toMatchObject({
deduplicated: true,
promptId: retried.promptId,
});
});
it('revokes the delivery authorization and remembers nothing on a synchronous rejection', () => {
const sessionId = freshSessionId();
const failure = new Error('queue full');
const { runtime } = fakeRuntime({
sendPrompt: () => {
throw failure;
},
});
const authorizations = {
authorizePrompt: vi.fn(),
revokePrompt: vi.fn(),
};
const input = {
runtime,
sessionId,
prompt: [{ type: 'text', text: 'hi' }],
request: {},
classification: plain,
idempotencyKey: 'event-1',
delivery: { target: 'chat' } as never,
};
let promptId: string | undefined;
expect(() =>
admitSessionPrompt(
{
...deps,
channelDeliveryAuthorizations: authorizations as never,
},
{ ...input, onPromptId: (id) => (promptId = id) },
),
).toThrow(failure);
expect(authorizations.authorizePrompt).toHaveBeenCalledWith('/ws', {
sessionId,
deliveryId: promptId,
target: 'chat',
});
expect(authorizations.revokePrompt).toHaveBeenCalledWith(
'/ws',
sessionId,
promptId,
);
expect(mockBind).not.toHaveBeenCalled();
const { runtime: healthy, calls } = fakeRuntime();
expect(
admitSessionPrompt(deps, { ...input, runtime: healthy }).deduplicated,
).toBe(false);
expect(calls).toHaveLength(1);
});
it('dedupes before minting a delivery authorization, and revokes it after the turn', async () => {
vi.useFakeTimers();
try {
const { runtime, settle } = fakeRuntime();
const authorizations = {
authorizePrompt: vi.fn(),
revokePrompt: vi.fn(),
};
const admissionDeps = {
...deps,
channelDeliveryAuthorizations: authorizations as never,
};
const sessionId = freshSessionId();
const input = {
runtime,
sessionId,
prompt: [{ type: 'text', text: 'hi' }],
request: {},
classification: plain,
idempotencyKey: 'event-1',
delivery: { target: 'chat' } as never,
};
const first = admitSessionPrompt(admissionDeps, input);
expect(admitSessionPrompt(admissionDeps, input).deduplicated).toBe(true);
expect(authorizations.authorizePrompt).toHaveBeenCalledTimes(1);
if (first.deduplicated) throw new Error('unexpected dedupe');
settle[0]!.resolve();
await first.settled;
// The grace outlasts the 30 s delivery IPC timeout.
vi.advanceTimersByTime(59_999);
expect(authorizations.revokePrompt).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(authorizations.revokePrompt).toHaveBeenCalledWith(
'/ws',
sessionId,
first.promptId,
);
} finally {
vi.useRealTimers();
}
});
it('mints no authorization when the session is gone before the enqueue', () => {
const { runtime } = fakeRuntime();
const gone = new Error('session not found');
(
runtime.bridge as unknown as { getSessionLastEventId: () => never }
).getSessionLastEventId = () => {
throw gone;
};
const authorizations = { authorizePrompt: vi.fn(), revokePrompt: vi.fn() };
expect(() =>
admitSessionPrompt(
{ ...deps, channelDeliveryAuthorizations: authorizations as never },
{
runtime,
sessionId: freshSessionId(),
prompt: [{ type: 'text', text: 'hi' }],
request: {},
classification: plain,
delivery: { target: 'chat' } as never,
},
),
).toThrow(gone);
expect(authorizations.authorizePrompt).not.toHaveBeenCalled();
});
it('accepts a key at the route limit and refuses an empty or longer one before anything happens', () => {
const { runtime, calls } = fakeRuntime();
const authorizations = { authorizePrompt: vi.fn(), revokePrompt: vi.fn() };
const onPromptId = vi.fn();
const admit = (idempotencyKey: string) =>
admitSessionPrompt(
{ ...deps, channelDeliveryAuthorizations: authorizations as never },
{
runtime,
sessionId: freshSessionId(),
prompt: [{ type: 'text', text: 'hi' }],
request: {},
classification: plain,
idempotencyKey,
delivery: { target: 'chat' } as never,
onPromptId,
},
);
expect(() =>
admit('k'.repeat(PROMPT_IDEMPOTENCY_KEY_MAX_LENGTH + 1)),
).toThrow(RangeError);
expect(() => admit('')).toThrow(RangeError);
expect(calls).toHaveLength(0);
expect(onPromptId).not.toHaveBeenCalled();
expect(authorizations.authorizePrompt).not.toHaveBeenCalled();
expect(
admit('k'.repeat(PROMPT_IDEMPOTENCY_KEY_MAX_LENGTH)).deduplicated,
).toBe(false);
expect(calls).toHaveLength(1);
});
it('settles once the turn has finished, whatever its outcome', async () => {
const { runtime, settle } = fakeRuntime();
const done = admitSessionPrompt(deps, {
runtime,
sessionId: freshSessionId(),
prompt: [{ type: 'text', text: 'hi' }],
request: {},
classification: plain,
});
const failed = admitSessionPrompt(deps, {
runtime,
sessionId: freshSessionId(),
prompt: [{ type: 'text', text: 'hi' }],
request: {},
classification: plain,
});
if (done.deduplicated || failed.deduplicated) throw new Error('dedupe');
let finished = 0;
void done.settled.then(() => (finished += 1));
void failed.settled.then(() => (finished += 1));
await new Promise((resolve) => setImmediate(resolve));
expect(finished).toBe(0);
settle[0]!.resolve();
settle[1]!.reject(new Error('turn failed'));
await expect(done.settled).resolves.toBeUndefined();
await expect(failed.settled).resolves.toBeUndefined();
expect(finished).toBe(2);
});
});
@@ -0,0 +1,536 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as crypto from 'node:crypto';
import { inspect } from 'node:util';
import { extractErrorCode, extractErrorMessage } from '@qwen-code/acp-bridge';
import {
CHANNEL_PROMPT_META_KEY,
DAEMON_PROMPT_DISPLAY_TEXT_META_KEY,
DAEMON_SUBMITTED_PROMPT_META_KEY,
SUBMITTED_PROMPT_META_KEY,
} from '@qwen-code/acp-bridge/bridgeTypes';
import type { parseChannelDelivery } from '../../runtime/channel-delivery.js';
import {
InvalidClientIdError,
PromptQueueFullError,
type AcpSessionBridge,
} from '../acp-session-bridge.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 type { DaemonLogger } from '../daemon-logger.js';
import { resolvePromptDeadlineMs } from '../server/prompt-deadline.js';
import {
DaemonDrainingError,
type SessionArchiveCoordinator,
} from '../server/session-archive.js';
import {
WorkspaceGenerationClosedError,
type WorkspaceRuntime,
} from '../workspace-registry.js';
import { bindSessionPrsFromPromptCommand } from './session-pr-backfill.js';
// Must exceed CHANNEL_DELIVERY_IPC_TIMEOUT_MS (30 s, channel-delivery-ipc.ts) plus scheduling slack.
const CHANNEL_DELIVERY_AUTHORIZATION_GRACE_MS = 60_000;
// Admission-side idempotency for prompt admissions (POST /session/:id/prompt
// and in-process callers share one ring, per session). 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).
export 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);
}
/**
* Renders a prompt-turn rejection for the daemon log. Non-Error rejections
* (bare JSON-RPC error objects forwarded by the bridge) must not degrade to
* `[object Object]` — that hid the failure cause in production incidents
* (#10710), so extract the structured message/code instead.
*/
export function describePromptTurnFailure(err: unknown): string {
const detail = extractErrorMessage(err);
if (err instanceof Error) {
return `[${err.name}] ${detail}`;
}
const code = extractErrorCode(err);
// `extractErrorMessage` terminates in `String(err)`, which renders a plain
// object as `[object Object]`. Fall back to `inspect` for those: unlike
// `JSON.stringify` it never throws, so circular and non-serializable
// rejections stay readable instead of degrading again.
const rendered =
typeof err === 'object' &&
err !== null &&
(detail === '' || detail === String(err))
? inspect(err, { depth: 2, breakLength: Infinity })
: detail;
return code === undefined ? rendered : `[code ${code}] ${rendered}`;
}
/** How a prompt's caller is classified, decided before admission. */
export interface SessionPromptClassification {
/**
* A channel turn: `qwen.channel.prompt` from a caller the daemon trusts.
* The bridge turns it into the session's channel classification.
*/
channelPrompt: boolean;
/** The display projection, accepted only from an authorized channel worker. */
promptDisplayText?: string;
/** The submitted prompt source, accepted only from a plain client. */
submittedPrompt?: string;
/**
* The request carried a `qwen.channel.prompt` marker of its own, trusted
* or not. With `channelPrompt` it keeps the PR-binding backfill off the
* turn: relayed text is an external sender's message, and a client that
* marks its own prompt opts only itself out.
*/
requestedChannelPrompt: boolean;
}
/**
* Split a prompt request into the body the bridge receives and the
* caller's classification: strip the request-envelope fields and every
* classification marker, then accept a marker only from the caller it is
* meant for.
*/
export function classifyPromptRequest(
body: Readonly<Record<string, unknown>>,
runtime: Pick<WorkspaceRuntime, 'workspaceCwd' | 'primary' | 'trusted'>,
): {
forwardedBody: Record<string, unknown>;
classification: SessionPromptClassification;
} {
const forwardedBody = { ...body };
delete forwardedBody['deadlineMs'];
delete forwardedBody['delivery'];
delete forwardedBody['idempotencyKey'];
const forwardedMeta =
typeof forwardedBody['_meta'] === 'object' &&
forwardedBody['_meta'] !== null &&
!Array.isArray(forwardedBody['_meta'])
? { ...(forwardedBody['_meta'] as Record<string, unknown>) }
: undefined;
const submittedPrompt = forwardedMeta?.[SUBMITTED_PROMPT_META_KEY];
const promptAuthorization =
forwardedMeta?.[CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY];
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'];
}
}
// 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));
// Channel classification opts the turn out of loop-detected rejection,
// so it rides the same worker authorization as the display projection;
// a forged key from any other caller is dropped here and again at the
// bridge admission strip.
return {
forwardedBody,
classification: {
channelPrompt: channelWorkerAuthorized && channelPrompt === true,
...(typeof promptDisplayText === 'string' && channelWorkerAuthorized
? { promptDisplayText }
: {}),
...(typeof submittedPrompt === 'string' &&
channelPrompt === undefined &&
promptAuthorization === undefined &&
promptDisplayText === undefined
? { submittedPrompt }
: {}),
requestedChannelPrompt: channelPrompt !== undefined,
},
};
}
export interface SessionPromptAdmissionDeps {
archiveCoordinator: SessionArchiveCoordinator;
channelDeliveryAuthorizations?: ChannelDeliveryAuthorizationStore;
daemonLog?: DaemonLogger;
promptDeadlineMs?: number;
}
export interface SessionPromptAdmissionInput {
runtime: WorkspaceRuntime;
/**
* The canonical id of a live session of `runtime`. The caller provides
* what `withOwnerMutableSession` provides for the route: the archive
* coordinator's shared lock on the session, the runtime activity gate
* for an internal runtime, and the generation guard asserted open with
* no `await` between that check and this call — or the standalone
* service's prompt dispatch for a standalone session.
*/
sessionId: string;
/** The prompt's content blocks, already validated. */
prompt: unknown[];
/** The rest of the bridge request (see {@link classifyPromptRequest}). */
request: Record<string, unknown>;
classification: SessionPromptClassification;
/** Non-empty, at most {@link PROMPT_IDEMPOTENCY_KEY_MAX_LENGTH} characters. */
idempotencyKey?: string;
requestDeadlineMs?: number;
clientId?: string;
delivery?: ReturnType<typeof parseChannelDelivery>;
signal?: AbortSignal;
/**
* Runs with the new promptId once the admission is past the dedupe check
* and the session's cursor reads, just before the enqueue.
*/
onPromptId?: (promptId: string) => void;
onPromptAdmitted?: () => void;
}
export type SessionPromptAdmission =
| {
deduplicated: true;
promptId: string;
lastEventId: ReturnType<AcpSessionBridge['getSessionLastEventId']>;
eventEpoch: ReturnType<AcpSessionBridge['getSessionEventEpoch']>;
}
| {
deduplicated: false;
promptId: string;
lastEventId: ReturnType<AcpSessionBridge['getSessionLastEventId']>;
eventEpoch: ReturnType<AcpSessionBridge['getSessionEventEpoch']>;
/** Settles, never rejecting, once the turn has finished either way. */
settled: Promise<void>;
};
/**
* Admit a prompt into a live session: deduplicate a repeated idempotency
* key, enqueue the turn with the caller's classification, remember the
* key, and run the PR-binding backfill for a local user's prompt. Used by
* `POST /session/:id/prompt`, and callable in-process with a
* classification the caller decides.
*
* Synchronous on purpose: the dedupe check, the enqueue and remembering the
* key happen with no `await` in between, so a concurrent retry cannot slip
* past the check. A pre-admission failure throws; the delivery
* authorization is minted just before the enqueue and revoked when it
* throws. An empty key or one longer than
* {@link PROMPT_IDEMPOTENCY_KEY_MAX_LENGTH} is a caller error and throws
* before anything happens.
*/
export function admitSessionPrompt(
deps: SessionPromptAdmissionDeps,
input: SessionPromptAdmissionInput,
): SessionPromptAdmission {
const { runtime, sessionId, idempotencyKey, clientId, delivery } = input;
const { daemonLog } = deps;
const ownerBridge = runtime.bridge;
if (
idempotencyKey !== undefined &&
(idempotencyKey.length === 0 ||
idempotencyKey.length > PROMPT_IDEMPOTENCY_KEY_MAX_LENGTH)
) {
throw new RangeError(
`idempotencyKey must be a non-empty string of at most ${PROMPT_IDEMPOTENCY_KEY_MAX_LENGTH} characters`,
);
}
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.
return {
deduplicated: true,
promptId: admittedPromptId,
lastEventId: ownerBridge.getSessionLastEventId(sessionId),
eventEpoch: ownerBridge.getSessionEventEpoch(sessionId),
};
}
}
const { classification } = input;
const lastEventId = ownerBridge.getSessionLastEventId(sessionId);
// Epoch token paired with the cursor above: a client that seeds its
// SSE resume position from this 202 must also learn the bus epoch so
// a daemon restart in between is detected (DAEMON-001).
const eventEpoch = ownerBridge.getSessionEventEpoch(sessionId);
input.onPromptId?.(promptId);
// Minted after everything above that can throw (the cursor getters
// throw for a session that has just closed), so only the enqueue below
// can fail with an authorization outstanding, and that path revokes it.
if (delivery && deps.channelDeliveryAuthorizations) {
deps.channelDeliveryAuthorizations.authorizePrompt(runtime.workspaceCwd, {
sessionId,
deliveryId: promptId,
target: delivery.target,
});
}
// The effective deadline (server cap ∩ request override) is passed
// to the bridge, which owns the deadline race: it publishes the
// formal `turn_error{code:'prompt_deadline_exceeded'}` terminal,
// releases the per-session FIFO, and best-effort cancels the agent.
// A route-side timer can't do any of that — it could only abort
// this request's signal.
const effectiveDeadlineMs = resolvePromptDeadlineMs(
deps.promptDeadlineMs,
input.requestDeadlineMs,
);
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,
{
...input.request,
sessionId,
prompt: input.prompt,
} as Parameters<AcpSessionBridge['sendPrompt']>[1],
input.signal,
{
...(clientId !== undefined ? { clientId } : {}),
promptId,
...(effectiveDeadlineMs !== undefined
? { deadlineMs: effectiveDeadlineMs }
: {}),
...(classification.promptDisplayText !== undefined
? { promptDisplayText: classification.promptDisplayText }
: {}),
...(classification.submittedPrompt !== undefined
? { submittedPrompt: classification.submittedPrompt }
: {}),
...(classification.channelPrompt ? { channelPrompt: true } : {}),
...(delivery !== undefined
? {
channelDelivery: {
deliveryId: promptId,
target: delivery.target,
},
}
: {}),
onPromptAdmitted: () => {
promptAdmitted = true;
input.onPromptAdmitted?.();
},
},
);
} catch (err) {
deps.channelDeliveryAuthorizations?.revokePrompt(
runtime.workspaceCwd,
sessionId,
promptId,
);
if (daemonLog && err instanceof PromptQueueFullError) {
daemonLog.warn('prompt admission rejected: queue full', {
sessionId,
promptId,
...(clientId !== undefined ? { clientId } : {}),
limit: err.limit,
pendingCount: err.pendingCount,
});
}
if (daemonLog && err instanceof InvalidClientIdError) {
daemonLog.warn('prompt admission rejected: invalid client id', {
sessionId,
promptId,
...(clientId !== undefined ? { clientId } : {}),
});
}
throw err;
}
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
// backfill sweep. Off the prompt path: a failed bind never affects
// the turn.
if (
runtime.trusted &&
!classification.channelPrompt &&
!classification.requestedChannelPrompt
) {
// Only the prompt the user typed is a source: take the first text
// block. Later blocks can carry @-imported file content whose
// line-leading examples would forge a binding; a channel-relayed
// turn is an external sender's message, not the local user's.
const firstTextBlock = input.prompt.find(
(item: unknown) =>
typeof item === 'object' &&
item !== null &&
(item as Record<string, unknown>)['type'] === 'text',
) as Record<string, unknown> | undefined;
const promptText =
typeof firstTextBlock?.['text'] === 'string'
? (firstTextBlock['text'] as string)
: undefined;
if (promptText !== undefined) {
void bindSessionPrsFromPromptCommand(runtime, sessionId, promptText, {
archiveCoordinator: deps.archiveCoordinator,
})
.then((result) => {
if (!result) return;
// A `--repo` decline is a policy outcome, not a failure, but
// it still has to reach the log: a badge missing because the
// number named another repository is otherwise
// indistinguishable from one that never resolved.
if (result.writeErrors || result.unresolved || result.repoDeclined) {
daemonLog?.warn('session PR bind incomplete', {
sessionId,
unresolved: result.unresolved,
repoDeclined: result.repoDeclined,
writeErrors: result.writeErrors,
overLimit: result.overLimit,
});
}
})
.catch((error) => {
if (
error instanceof WorkspaceGenerationClosedError ||
error instanceof DaemonDrainingError
) {
return;
}
daemonLog?.warn('session PR bind failed', {
sessionId,
error: describePromptTurnFailure(error),
});
});
}
}
const settled = promptPromise
.then(
() => {
if (daemonLog) {
daemonLog.info('prompt turn completed', {
sessionId,
promptId,
clientId,
});
}
},
(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)}`,
{ sessionId, promptId, clientId },
);
}
},
)
.finally(() => {
if (delivery && deps.channelDeliveryAuthorizations) {
const revokeTimer = setTimeout(() => {
deps.channelDeliveryAuthorizations?.revokePrompt(
runtime.workspaceCwd,
sessionId,
promptId,
);
}, CHANNEL_DELIVERY_AUTHORIZATION_GRACE_MS);
revokeTimer.unref();
}
})
.catch(() => {});
if (daemonLog) {
daemonLog.info('prompt enqueued', { sessionId, promptId, clientId });
}
return { deduplicated: false, promptId, lastEventId, eventEpoch, settled };
}
+46 -381
View File
@@ -11,7 +11,6 @@ import {
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { inspect } from 'node:util';
import { z } from 'zod';
import {
hashDaemonWorkspace,
@@ -69,20 +68,11 @@ import {
SessionAttachmentUploadError,
} from '@qwen-code/acp-bridge/sessionAttachments';
import {
CHANNEL_PROMPT_META_KEY,
DAEMON_PROMPT_DISPLAY_TEXT_META_KEY,
DAEMON_SUBMITTED_PROMPT_META_KEY,
SUBMITTED_PROMPT_META_KEY,
type BridgeBranchedSession,
type BridgePersistedBranchedSession,
} from '@qwen-code/acp-bridge/bridgeTypes';
import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus';
import {
extractErrorCode,
extractErrorMessage,
parseSessionSource,
summarizeReplay,
} from '@qwen-code/acp-bridge';
import { parseSessionSource, summarizeReplay } from '@qwen-code/acp-bridge';
import { readServeWorkflowActionInput } from '@qwen-code/acp-bridge/status';
import {
isReservedLiveSessionSource,
@@ -117,7 +107,6 @@ import {
BridgeChannelQuarantinedError,
InvalidClientIdError,
InvalidSessionMetadataError,
PromptQueueFullError,
SessionArtifactValidationError,
SessionArchivedError,
SessionConflictError,
@@ -146,7 +135,6 @@ import {
WorktreeResetUnsupportedError,
WorktreeSessionSupersededError,
} from '../server/worktree-reset-errors.js';
import { resolvePromptDeadlineMs } from '../server/prompt-deadline.js';
import {
parseClientIdHeader,
parseOptionalWorkspaceCwd,
@@ -169,7 +157,6 @@ import {
assertSessionArchived,
assertSessionLoadable,
assertSessionRestorable,
DaemonDrainingError,
deleteDaemonSessionIfOrphan,
deleteDaemonSessions,
logSessionArchiveWarning,
@@ -187,7 +174,12 @@ import {
} from '../server/session-artifact-library.js';
import { WorkspaceArtifactCache } from '../server/workspace-artifact-cache.js';
import { ArtifactLibraryStream } from '../server/artifact-library-stream.js';
import { bindSessionPrsFromPromptCommand } from './session-pr-backfill.js';
import {
PROMPT_IDEMPOTENCY_KEY_MAX_LENGTH,
admitSessionPrompt,
classifyPromptRequest,
type SessionPromptAdmission,
} from './session-prompt-admission.js';
import { acquireWorktreeOwnershipOp } from '../server/worktree-ownership-op.js';
import {
exportSessionTranscript,
@@ -284,12 +276,6 @@ import {
type BranchWorktreePreparationJournal,
} 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,
RequestedSessionIdAdmissionError,
@@ -297,6 +283,8 @@ import {
type RequestedSessionIdReservation,
} from '../session-id-admission.js';
export { describePromptTurnFailure } from './session-prompt-admission.js';
// `HEAD` is the most prominent ref name git rejects as a branch name.
// The surrounding predicate covers the remaining reserved forms (`@`, `-`,
// `..`, `.lock` suffixes, etc.). Compared case-insensitively because ref
@@ -372,8 +360,6 @@ const WORKSPACE_TRANSCRIPT_RESPONSE_MAX_BYTES =
const WORKSPACE_TRANSCRIPT_CURSOR_MAX_BYTES = 64 * 1024;
const TRANSCRIPT_CURSOR_TOO_LARGE_REPLAY_ERROR =
'Transcript pagination state exceeds the safe limit';
// Must exceed CHANNEL_DELIVERY_IPC_TIMEOUT_MS (30 s, channel-delivery-ipc.ts) plus scheduling slack.
const CHANNEL_DELIVERY_AUTHORIZATION_GRACE_MS = 60_000;
const ARTIFACT_LIBRARY_CONCURRENCY = 4;
// Media blocks are resolved into inline bytes at dispatch, so an unbounded
// content array lets one small request fan out into gigabytes of heap (a
@@ -381,47 +367,6 @@ 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.
@@ -580,31 +525,6 @@ function sendSessionOrganizationError(res: Response, err: unknown): boolean {
return true;
}
/**
* Renders a prompt-turn rejection for the daemon log. Non-Error rejections
* (bare JSON-RPC error objects forwarded by the bridge) must not degrade to
* `[object Object]` — that hid the failure cause in production incidents
* (#10710), so extract the structured message/code instead.
*/
export function describePromptTurnFailure(err: unknown): string {
const detail = extractErrorMessage(err);
if (err instanceof Error) {
return `[${err.name}] ${detail}`;
}
const code = extractErrorCode(err);
// `extractErrorMessage` terminates in `String(err)`, which renders a plain
// object as `[object Object]`. Fall back to `inspect` for those: unlike
// `JSON.stringify` it never throws, so circular and non-serializable
// rejections stay readable instead of degrading again.
const rendered =
typeof err === 'object' &&
err !== null &&
(detail === '' || detail === String(err))
? inspect(err, { depth: 2, breakLength: Infinity })
: detail;
return code === undefined ? rendered : `[code ${code}] ${rendered}`;
}
function parseTranscriptLimitQuery(
rawLimit: unknown,
res: Response,
@@ -8482,7 +8402,6 @@ export function registerSessionRoutes(
withOwnerMutableSession(
'POST /session/:id/prompt',
async (req, res, sessionId, runtime, onPromptAdmitted) => {
const ownerBridge = runtime.bridge;
const body = safeBody(req);
const eventDetailMode = parseReplayMode(body, res, 'eventDetailMode');
if (eventDetailMode === null) return;
@@ -8577,101 +8496,10 @@ 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,
{
sessionId,
deliveryId: promptId,
target: delivery.target,
},
);
}
const forwardedBody = { ...body };
delete forwardedBody['deadlineMs'];
delete forwardedBody['delivery'];
delete forwardedBody['idempotencyKey'];
const forwardedMeta =
typeof forwardedBody['_meta'] === 'object' &&
forwardedBody['_meta'] !== null &&
!Array.isArray(forwardedBody['_meta'])
? { ...(forwardedBody['_meta'] as Record<string, unknown>) }
: undefined;
const submittedPrompt = forwardedMeta?.[SUBMITTED_PROMPT_META_KEY];
const promptAuthorization =
forwardedMeta?.[CHANNEL_WORKER_PROMPT_AUTHORIZATION_META_KEY];
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'];
}
}
// 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
: undefined;
// Channel classification opts the turn out of loop-detected
// rejection, so it rides the same worker authorization as the
// display projection; a forged key from any other caller is dropped
// here and again at the bridge admission strip.
const trustedChannelPrompt =
channelWorkerAuthorized && channelPrompt === true;
const lastEventId = ownerBridge.getSessionLastEventId(sessionId);
// Epoch token paired with the cursor above: a client that seeds its
// SSE resume position from this 202 must also learn the bus epoch so
// a daemon restart in between is detected (DAEMON-001).
const eventEpoch = ownerBridge.getSessionEventEpoch(sessionId);
addDaemonRequestAttribute('qwen-code.prompt_id', promptId);
const { forwardedBody, classification } = classifyPromptRequest(
body,
runtime,
);
const abort = new AbortController();
let responseFinished = false;
@@ -8684,88 +8512,35 @@ export function registerSessionRoutes(
};
res.once('close', onResClose);
res.once('finish', onResFinish);
// The effective deadline (server cap ∩ request override) is passed
// to the bridge, which owns the deadline race: it publishes the
// formal `turn_error{code:'prompt_deadline_exceeded'}` terminal,
// releases the per-session FIFO, and best-effort cancels the agent.
// A route-side timer can't do any of that — it could only abort
// this request's signal.
const effectiveDeadlineMs = resolvePromptDeadlineMs(
promptDeadlineMs,
requestDeadlineMs,
);
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;
let admission: SessionPromptAdmission;
try {
promptPromise = ownerBridge.sendPrompt(
sessionId,
admission = admitSessionPrompt(
{
...forwardedBody,
archiveCoordinator: deps.archiveCoordinator,
channelDeliveryAuthorizations: deps.channelDeliveryAuthorizations,
daemonLog,
promptDeadlineMs,
},
{
runtime,
sessionId,
prompt,
} as Parameters<AcpSessionBridge['sendPrompt']>[1],
abort.signal,
{
...(clientId !== undefined ? { clientId } : {}),
promptId,
...(effectiveDeadlineMs !== undefined
? { deadlineMs: effectiveDeadlineMs }
: {}),
...(trustedPromptDisplayText !== undefined
? { promptDisplayText: trustedPromptDisplayText }
: {}),
...(typeof submittedPrompt === 'string' &&
channelPrompt === undefined &&
promptAuthorization === undefined &&
promptDisplayText === undefined
? { submittedPrompt }
: {}),
...(trustedChannelPrompt ? { channelPrompt: true } : {}),
...(delivery !== undefined
? {
channelDelivery: {
deliveryId: promptId,
target: delivery.target,
},
}
: {}),
onPromptAdmitted: () => {
promptAdmitted = true;
onPromptAdmitted?.();
},
request: forwardedBody,
classification,
idempotencyKey,
requestDeadlineMs,
clientId,
delivery,
signal: abort.signal,
onPromptId: (promptId) =>
addDaemonRequestAttribute('qwen-code.prompt_id', promptId),
onPromptAdmitted,
},
);
} catch (err) {
deps.channelDeliveryAuthorizations?.revokePrompt(
runtime.workspaceCwd,
sessionId,
promptId,
);
res.off('close', onResClose);
res.off('finish', onResFinish);
if (daemonLog && err instanceof PromptQueueFullError) {
daemonLog.warn('prompt admission rejected: queue full', {
sessionId,
promptId,
...(clientId !== undefined ? { clientId } : {}),
limit: err.limit,
pendingCount: err.pendingCount,
});
}
if (daemonLog && err instanceof InvalidClientIdError) {
daemonLog.warn('prompt admission rejected: invalid client id', {
sessionId,
promptId,
...(clientId !== undefined ? { clientId } : {}),
});
}
sendBridgeError(res, err, {
route: 'POST /session/:id/prompt',
sessionId,
@@ -8773,131 +8548,21 @@ 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);
if (admission.deduplicated) {
res.off('finish', onResFinish);
res.status(202).json({
promptId: admission.promptId,
lastEventId: admission.lastEventId,
eventEpoch: admission.eventEpoch,
deduplicated: true,
});
return;
}
// 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
// backfill sweep. Off the prompt path: a failed bind never affects
// the turn.
if (runtime.trusted && channelPrompt === undefined) {
// Only the prompt the user typed is a source: take the first text
// block. Later blocks can carry @-imported file content whose
// line-leading examples would forge a binding; a channel-relayed
// turn is an external sender's message, not the local user's.
const firstTextBlock: Record<string, unknown> | undefined =
prompt.find(
(item: unknown) =>
(item as Record<string, unknown>)['type'] === 'text',
);
const promptText =
typeof firstTextBlock?.['text'] === 'string'
? (firstTextBlock['text'] as string)
: undefined;
if (promptText !== undefined) {
void bindSessionPrsFromPromptCommand(
runtime,
sessionId,
promptText,
{
archiveCoordinator: deps.archiveCoordinator,
},
)
.then((result) => {
if (!result) return;
// A `--repo` decline is a policy outcome, not a failure, but
// it still has to reach the log: a badge missing because the
// number named another repository is otherwise
// indistinguishable from one that never resolved.
if (
result.writeErrors ||
result.unresolved ||
result.repoDeclined
) {
daemonLog?.warn('session PR bind incomplete', {
sessionId,
unresolved: result.unresolved,
repoDeclined: result.repoDeclined,
writeErrors: result.writeErrors,
overLimit: result.overLimit,
});
}
})
.catch((error) => {
if (
error instanceof WorkspaceGenerationClosedError ||
error instanceof DaemonDrainingError
) {
return;
}
daemonLog?.warn('session PR bind failed', {
sessionId,
error: describePromptTurnFailure(error),
});
});
}
}
promptPromise
.then(
() => {
if (daemonLog) {
daemonLog.info('prompt turn completed', {
sessionId,
promptId,
clientId,
});
}
},
(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)}`,
{ sessionId, promptId, clientId },
);
}
},
)
.finally(() => {
if (delivery && deps.channelDeliveryAuthorizations) {
const revokeTimer = setTimeout(() => {
deps.channelDeliveryAuthorizations?.revokePrompt(
runtime.workspaceCwd,
sessionId,
promptId,
);
}, CHANNEL_DELIVERY_AUTHORIZATION_GRACE_MS);
revokeTimer.unref();
}
})
.catch(() => {});
if (daemonLog) {
daemonLog.info('prompt enqueued', { sessionId, promptId, clientId });
}
res.status(202).json({ promptId, lastEventId, eventEpoch });
res.status(202).json({
promptId: admission.promptId,
lastEventId: admission.lastEventId,
eventEpoch: admission.eventEpoch,
});
},
{ promptAdmission: true },
),