diff --git a/docs/architecture/ownership/cells/routing-context.md b/docs/architecture/ownership/cells/routing-context.md index 8dd223cfe3..de1093e324 100644 --- a/docs/architecture/ownership/cells/routing-context.md +++ b/docs/architecture/ownership/cells/routing-context.md @@ -1,8 +1,8 @@ --- cell_id: routing-context title: Live Routing Context -summary: 在决策时组合 F208 applied capability revision、owner 条件偏好与带 freshness 的 cat/provider/provable-pool availability,并向 Workspace Team、invocation cognition 与 dispatch preflight 提供可解释 projection;不拥有 capability 更新、成员/runtime 配置、quota collection、provider telemetry、identity/session 或 dispatch execution。 -description: F293 路由决策组合边界:持久 signal、版本化 preference、applied capability revision、Team/ledger read model、动态 cognition 与发送前 preflight;不接管 Workspace host、事实采集、画像更新或 dispatch。 +summary: 在决策时组合 F208 applied capability revision、owner 条件偏好与带 freshness 的 cat/provider/provable-pool availability,并向 Workspace Team 与显式查询提供可解释 projection;普通消息不消费 availability 预检或提示;不拥有 capability 更新、成员/runtime 配置、quota collection、provider telemetry、identity/session 或 dispatch execution。 +description: F293 路由决策组合边界:持久 signal、版本化 preference、applied capability revision、Team/ledger read model、显式路由查询;普通消息执行不接可用性闸、动态提示或终态回写;不接管 Workspace host、事实采集、画像更新或 dispatch。 description_source: model description_author: codex-sol description_generated_by: codex-sol@gpt-5.6-sol @@ -50,14 +50,13 @@ boundaries: - owner-scoped immutable routing signal events and active projection semantics - versioned owner routing preferences with provenance - candidate explanation, alternatives and allowed/warned/rejected preflight decisions - - sparse per-invocation cognition projection and shared F284 Team-mode/ledger/contextual routing view model + - explicit routing projections and shared F284 Team-mode/ledger/contextual routing view model - translation of canonical typed observations into immutable routing assertions and causal recoveries - routing signal owner-revision coherence and immutable timeline cache invalidation consumes: - F208 applied capability revisions and relevant evidence-backed signals - F051 canonical quota refresh evidence only when stable pool identity is present; route impact still requires a provable binding - F153 typed provider/runtime health observations with explicit subject authority - - canonical durable dispatch terminals paired with the exact actual-send preflight decision - runtime catalog cat/provider bindings and thread-local routing overrides does_not_own: - capability dossier prose, proposal/apply lifecycle or model benchmark truth @@ -68,7 +67,7 @@ boundaries: - TurnExecution terminal ownership, failure text classification beyond the closed stable routing classes, or delivery outcome mutation - Workspace host/chrome, current-thread Now surface or background focus navigation - silent target selection or opaque routing scores -last_reviewed: 2026-09-02 +last_reviewed: 2026-10-10 --- # Live Routing Context @@ -77,12 +76,12 @@ F293 owns the decision-time composition layer between canonical source facts and Signals and global preferences are owner-scoped. They are not partitioned by project path because provider health and quota scarcity do not become healthy in another repository. The resolver intersects that truth with the current runtime catalog and candidate set. -The native L0 remains a stable, cached identity/governance surface. Dynamic routing exceptions are injected through a per-invocation cognition projection adjacent to the ADR-038 staging path, then independently re-read at the actual dispatch boundary. +The native L0 remains a stable, cached identity/governance surface. Per operator direction in F117/#1398 (2026-10-10, messages 051/054 in `thread_msr51149hym0i79f`), ordinary serial/parallel replies, callbacks, Append and failure returns no longer consult availability preflight or inject routing advice. Requested targets enter the existing delivery lifecycle; actual client results produce the normal success/failure response. Invocation terminals no longer publish global member availability. Team views, explicit routing queries and persisted ledgers remain independent of message execution. -Automatic source adapters do not own their input facts. They validate and translate only canonical F051 refreshes, explicitly scoped F153 observations and persisted TurnExecution terminals through `AutomaticRoutingSignalService`. They cannot append the event store directly, import another domain store, derive scope from hidden account identity or instantiate another resolver. The architecture guard ratchets those seams mechanically. +Automatic source adapters do not own their input facts. They validate and translate only canonical F051 refreshes, explicitly scoped F153 observations through `AutomaticRoutingSignalService`. They cannot append the event store directly, import another domain store, derive scope from hidden account identity or instantiate another resolver. The architecture guard ratchets those seams mechanically. The resolver caches only immutable owner timelines under `RedisRoutingSignalEventStore.getOwnerRevision()`. A newly appended event increments the shared revision atomically; replay does not. Every resolve still re-runs time-sensitive reduction, so cache reuse never freezes expiry or derived availability. -`unavailable` may reject only when subject scope and active validity are proven. Advisory states never silently reroute. Resolver/store failure returns warned fail-open and an audit event so routing context cannot become a dispatch availability single point of failure. +Explicit routing queries can explain `unavailable` only when subject scope and active validity are proven. These projections do not reject, delay or warn during ordinary delivery, and cannot silently change its requested target. Already persisted signal events remain readable; this retirement does not erase historical data. The user-facing Team surface is a canonical F284 Workspace mode, not a sibling host or an expansion of the current-thread Now surface. Background routing changes update the shared read model and needs-you Activity only; explicit user navigation is required to open Team list or detail. diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/ci-followup-2026-10-10/f117-ci-followup-related-green.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/ci-followup-2026-10-10/f117-ci-followup-related-green.log.gz new file mode 100644 index 0000000000..02460c3ba9 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/ci-followup-2026-10-10/f117-ci-followup-related-green.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/ci-followup-2026-10-10/f117-routing-human-recovery-green.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/ci-followup-2026-10-10/f117-routing-human-recovery-green.log.gz new file mode 100644 index 0000000000..52ed240d7d Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/ci-followup-2026-10-10/f117-routing-human-recovery-green.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/ci-followup-2026-10-10/f117-routing-human-recovery-red.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/ci-followup-2026-10-10/f117-routing-human-recovery-red.log.gz new file mode 100644 index 0000000000..ec36609468 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/ci-followup-2026-10-10/f117-routing-human-recovery-red.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/ci-followup-2026-10-10/report.md b/feature-specs/evidence/f117-feedback-2026-10-09/ci-followup-2026-10-10/report.md new file mode 100644 index 0000000000..3aeabf93ff --- /dev/null +++ b/feature-specs/evidence/f117-feedback-2026-10-09/ci-followup-2026-10-10/report.md @@ -0,0 +1,16 @@ +# F117 CI follow-up: ordinary delivery ignores legacy availability + +Observed public head: `52cb79817066d458555d67d81e7132545434dd26`. +Failure source: [distributable-6 job](https://github.com/zts212653/clowder-ai/actions/runs/38017850925/job/114112452232), run `38017850925`. + +The lane has one failing test in `routing-human-recovery.test.js`: its old journey expected a failed provider invocation to publish global unavailability, reject later cross-thread requests, and require an owner recovery attempt. The aggregate `Test (Public)` consequently failed. This test was omitted from the earlier targeted author and independent checks; their passing counts did not cover this journey. + +The implementation already matches operator 017/051/054. Keep production code unchanged. Rewrite this existing journey to assert each requested thread executes, reports its real error, never queries legacy availability or publishes global member state, and never creates an availability rejection receipt. Seed one durable legacy signal to prove it stays readable without controlling ordinary sends. After provider recovery, only explicit new requests execute; failed work is not replayed. Other explicit recovery-service tests remain intact. + +Local original test: 8/9, exit 1, same `allowed` versus `rejected` assertion as CI. Corrected test: 9/9, exit 0. Related three-file check: see `f117-ci-followup-related-green.log.gz` for the exact total and all named cases. Biome check and git diff check passed. + +No production source, runtime config, persistent user data, running process, or workflow change. The earlier Opus approval remains bound to `709caf89`; it is not a verdict on this new test-only commit. Independent validation for this external CI correction is the fresh published-head CI. No merge or runtime restart is authorized by this record. + +Architecture cell: routing-context ordinary-delivery consumer retirement. +Map delta: none; existing regression now asserts the retired consumer stays disconnected. +Why: preserve the operator-approved delivery contract instead of restoring a global suppression layer to satisfy an obsolete expectation. diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/ci-followup-2026-10-10/sha256.json b/feature-specs/evidence/f117-feedback-2026-10-09/ci-followup-2026-10-10/sha256.json new file mode 100644 index 0000000000..8de39930ea --- /dev/null +++ b/feature-specs/evidence/f117-feedback-2026-10-09/ci-followup-2026-10-10/sha256.json @@ -0,0 +1,17 @@ +[ + { + "file": "f117-routing-human-recovery-red.log.gz", + "sha256": "8114c464c535103cc1b8c842461035fcf5dc78eebf14f7356bd7ace3e9d5173d", + "uncompressedSha256": "bd5068de54ca338fb2a92634c4b3a9a2617b54c6f3761f06982137910a7b47b8" + }, + { + "file": "f117-routing-human-recovery-green.log.gz", + "sha256": "605623cccdc9910eaece723b3bc64ad7b3ece4486c1ae3c032c65fd053331e86", + "uncompressedSha256": "b45b72008e64e91f211145816192cb6007c71ec6f655ecb2a84c9f1131d65f62" + }, + { + "file": "f117-ci-followup-related-green.log.gz", + "sha256": "84cad6ba4a9006320aed6c852566b98b0d06d845f9838162c05f78c1c00769f0", + "uncompressedSha256": "4b1ab60926ccffa0e524139ce2bfa0f4ffeca2753ce4ace53142b5148ee0d0b0" + } +] diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/cross-thread-followup-2026-10-10.md b/feature-specs/evidence/f117-feedback-2026-10-09/cross-thread-followup-2026-10-10.md new file mode 100644 index 0000000000..d904fe2647 --- /dev/null +++ b/feature-specs/evidence/f117-feedback-2026-10-09/cross-thread-followup-2026-10-10.md @@ -0,0 +1,99 @@ +# F117 / #1398:跨线程唤醒与追加读取回执修复 + +本轮从 `984edb0da56872ee771441b0d2e9586eba5ce19b` 继续;中间候选 `6cc7181486066074e16306ad19bfd98cdddccd28` 已被独立复审退回,本报告按后续 operator 051/054 的最终方向更新。使用原 `fix/f117-append-receipt-feedback` worktree。报告记录作者自验;独立放行、发布、运行回流及 operator 体验另据实际回执记录。 + +## 原问题与授权 + +同一 thread `thread_msr51149hym0i79f` 的不可变来源: + +> `0001791595491779-000002-465a7b95`:跨线程消息 `0001791556382333-000171-697b2d56` 应投递给 Astra,但没有触发。 +> `0001791595815561-000007-7fd17750`:Codex app-server 能否确认消息进入模型,为什么只有投递于且不闪烁? +> `0001791596027888-000015-69592a04`:补充消息圆点应该是 source 主题色。 +> `0001791596170928-000017-3a8debc4`:本回合超时不应标记成员全局不可用,更不应影响其他 thread;应记录失败并回调触发成员。 + +`0001791595926479-000011-ea1ae754` 追问 #215 未关闭;此前的冗余 PR 清理授权继续有效。原 `0001791553890412-000138-a1dca2eb` 授权修复、报告、更新 #1398 和 fork develop_base 增量 PR,由 operator 择时重启。 + +## 根因与改动 + +### 超时与跨线程漏唤醒 + +原通知的目标是 `thread_mrkn6povq4zzgh45` / `cat-xlbldqjc`,来源是另一 thread 中的同一成员。目标解析正确,并非同成员跨线程被误当成自发自收。 + +运行日志 `packages/api/data/logs/api/api.2026-10-09.1.log` 实核:14:31:08.772,另一个回合 `b16935bd-37b0-4aa4-b355-6adf5386f467` 的本地输出超时生成全局 `provider_timeout` 路由断言,有效至 14:36:08.772。14:33:02.333 原通知已持久化,14:33:02.355 进入 preflight,随后没有 Queue admission。日志没有逐字记录拒绝 verdict;结合当时有效的全局断言与代码的入队前过滤,判断该门禁造成漏唤醒,不能把日志顺序本身写成拒绝 verdict 原帧。源工具返回 `200/routed=[]`,未保留后续唤醒责任。14:33:40.112 成功投递已使全局信号恢复,原通知仍没有队列记录可重试。 + +最终修复删除普通投递与 F293 availability 的接线,而非降为提示。串行、并行、callback、完成回复、失败回流、投票及多目标通知直接以原请求目标进入既有消息生命周期;不查询可用性,不提前拒绝、跳过 Append、延后执行或展示拒绝提示。普通 invocation 也不注入动态 routing-context 提示,不从成功/失败/本地超时终态发布全局可用性断言。实际客户端成功或失败负责正常回显;失败只回流到精确前序,失败回报本身不反弹。 + +中间 6cc 曾保留 actual-send preflight 与路由延后;operator 051/054 明确反对此方案,本次将相关分支和两个失去消费者的 helper 删除,没有增加 advisory 替代层。Team/显式查询及历史 signal 存储保持独立;后台记忆机会调度仍使用其自身路由契约,不属于普通消息发送路径。没有修改运行配置或清理生产 signal。 + +保留的退避仅处理后端在持久接纳输入之前真实失败,防止丢消息与热循环,不依据预测的 provider 可用性。定向验证发现多目标消息接纳了一个成员后、另一个成员在接纳前失败的收尾缺口:Queue 重建剩余目标后,也需要进入既有失败退避;现按尚存的 queued 行决定,已接纳成员不会重跑。客户端已经接纳后的 provider 失败走失败回复,不把源消息退回 Queue。 + +这里的 callback token 核对只是将回调绑定到正确 invocation/member/thread,防止写错回复;不是发送来源或角色的权限门禁。本轮不新增这种门禁,也不改变精确 parent、来源身份、循环/深度或行动归属契约。运行环境实际 `CLI_TIMEOUT_MS=1800000`,本轮不修改这项 30 分钟无输出配置或本地终态行为。 + +原消息 171 已由后来的手动通知触发并进入上下文,本轮不重发,避免重复业务执行。 + +### Codex 原生读取回执 + +本机 Codex `0.161.0` 的原生协议支持 `turn/steer.clientUserMessageId` 和 `userMessage.clientId`。此前适配器已发送 UUID,却未接入 `onInputRead`,也未声明 `inputReadReceipt`,因此 console 只能静止显示投递于。 + +本回合真实 SDK archive 中 5 条追加输入都有 exact thread、turn、clientId 的 `item/completed` 消费事件;归档仅提取 ID 与时间,不复制用户正文或凭证。`native-consumption-metadata.json` 保留这一事实。 + +修复在每个 active run 内绑定 UUID 与既有回调:RPC 接纳仍只是 pending;只有匹配 thread、turn、clientId 的 native user-message completion 才写现有 `dispatchRefs.inputRead`。其他回合、未知 ID、其他 item 类型及 `item/started` 不会升级为 read;重复 completion 只记一次。回执持久化异步执行,不阻塞 provider 输出、Stop 或 terminal;关闭回合清理映射。没有新增 consume 字段或消息状态体系。 + +这里的读取表示该输入进入客户端为模型准备的上下文,不表示模型理解或执行了内容。依据是 [OpenAI 原生消费实现](https://github.com/openai/codex/blob/rust-v0.161.0/codex-rs/core/src/session/mod.rs#L4861) 与 [pending-input 协议测试](https://github.com/openai/codex/blob/rust-v0.161.0/codex-rs/core/tests/suite/pending_input.rs#L180);[官方 app-server 文档](https://learn.chatgpt.com/docs/app-server) 的 steer 接纳本身不替代读取证据。F117 原文已约定 client-ID 消费接线,本轮补齐当前适配器缺口。 + +### 圆点与 #215 + +`AppendedInputReceipts` 复用 `resolveMessageSender`,圆点和名称从同一个 source 身份取颜色;target 仅用于匹配投递/读取状态。用户、成员及 connector 的既有主题色规则共用,不另造颜色表。 + +#215 已按授权通过 canonical gh 关闭,provider 回读 `closedAt=2026-10-10T01:34:13Z`;对应 tracking/task 已结束并取消登记。保留分支、原审查和证据,不合入旧差异。先前保留 draft 后没有完成清理是遗漏,本轮已纠正。 + +## Ownership / 风险 + +Architecture cells:`dispatch`、`routing-context`、`transport`、`bubble-pipeline`。Map delta:更新 `docs/architecture/ownership/cells/routing-context.md`,登记普通消息的 availability 消费/提示/终态回写退役。没有改变 owner 或新增运行组件。 + +行为:普通消息不根据跨 thread 可用性阻止执行;实际失败回显并回流,接纳前后按既有持久事实区分。数据:新验证仅使用 owned Git/in-memory fixture,生产记录只读;未迁移、清理或重发历史数据。身份:callback token 精确运行归属保留,没有增设来源或角色发送许可。契约:使用既有 native client-ID 与 `inputRead`,不把接纳当消费。不可逆:代码未自行合入或部署;唯一外部清理是已授权的 #215 关闭,可重开且无分支删除。 + +## 中间候选验证与独立退回 + +`followup-2026-10-10/sha256.json` 记录归档原件和 gzip 的哈希;红日志保留,不覆盖此前验收证据。 + +- API 编译:`pnpm exec tsc -p packages/api/tsconfig.json`,exit 0。 +- API 定向 10 文件:138/138,覆盖 callback admission、路由 preflight、Queue retry、超时失败、投票及并行失败回流;`f117-cross-thread-final-api.log.gz`。 +- 原失败回流 seam:`node --import tsx --test packages/api/test/a2a-1577-failure-return-seam.test.mjs`,5/5。 +- 原生 app-server transport:`node --test packages/api/test/codex-app-server-transport.test.js`,37/37。随后加强 native-started 处理屏障的两个定向案例再跑 2/2,分别报告,不累计。 +- Web 两个定向文件:10/10,含 source 用户/成员/connector 色彩及 receipt 展示。 +- Web 类型比较:candidate 与 exact base 984 的诊断一致,只有已有 `proposal-card-realtime-socket.integration.test.tsx:152` 的 boolean/null 错误;不能称 Web tsc 全绿。 +- 15 个改动文件 Biome error-level 检查及 `git diff --check` 通过。 +- 作者隔离浏览器 preview:本 feature 的当前组件、合成输入及内存 API,实际 URL `http://127.0.0.1:5173/`。圆点实色为发送者默认 cocoa `rgb(107, 84, 67)`,不是 target Opus 紫色;已投递/读取于 tooltip、点击和键盘展开、停止全部两精确运行及移动视口均正常,零 page error。`preview-result.json`、`source-color-preview.png` 和实际 check 脚本保留;页面不连接运行数据或真实 provider。 + +已先红后绿:Queue 丢目标、timeout 全局映射、native 消费缺回调和 target 色彩各有独立原红。首次无 TS loader 的 seam 命令失败属于 fixture 启动问题,使用 `--import tsx` 后 5/5;不能算生产缺陷。 + +本轮不重跑不变全量、不请求真实 provider、不改运行 checkout/config、不重启进程,也不宣称体验或 soak 完成。原报告中剩余内部准备延迟仍是未解决范围,本轮不得借读取回执修复声称毫秒级入队到执行已实现。 + +## 后继修正与验证 + +Opus 对精确 6cc 的 `changes_requested`:`local-review:0001791597781796-000056-7bbac02a:changes_requested`。其 API 独跑 186 条中 185 过,一条既有 `1398-failed-response-recovery` 用例仍期待“前序拒绝则不入队”。这是作者漏跑该测试留下的 P1;本次保留并改写为“任何旧可用性记录不能阻止精确前序回流”,同时保留 `isFailureReport` 不反弹。旧红日志归档,没有删用例或改成无断言。 + +后继作者验证使用同一 feature checkout: + +- API 编译 exit 0:`remove-availability-build-final.log.gz`。 +- API 12 个定向文件 **206/206**:`remove-availability-api-final.log.gz`。包含原 P1 文件、跨线程原 source 准入、普通串行/并行不查询 availability、真实错误事件失败终态、两条串行/并行失败回复 → 实际 MessageStore/Queue 事务 → 精确前序案例;客户端错误是受控 fixture,不冒充真实 provider 请求。 +- 普通回复不注入/调用动态 availability 投影的两个反例:原实现 **0/2**,后继 **2/2**;`remove-availability-prompt-red.log.gz` 与 `remove-availability-prompt-green.log.gz`。 +- 原失败回流事务 seam **5/5**:`remove-availability-seam-final.log.gz`,旧 preflight spy 零调用、不产生额外提示。 +- 首次扩展运行 **204/205** 的原红保留为 `remove-availability-api-partial-red.log.gz`;唯一失败是多目标接纳前后端失败的剩余目标未退避,后继修正并在 206/206 中通过。不是 provider 真请求失败,也不是编译问题。 +- 格式 error-level 和 `git diff --check` 在提交前复核。既有复杂度等 warning 不称全仓已治理。 + +6cc 已独立核实的 Codex 原生 UUID 消费回调、inputRead pending/read、旧 signal 持久化兼容、source 圆点色继续沿用,后继未修改这部分,不重跑不变的 Web/native 全量。Opus 原 API 红、seam 与 Web 独立日志另存为 `opus-6cc7-*.log.gz`;其其他 extended 测试 10 项 MCP dist 缺失属于环境加载失败,不写成代码失败或通过。 + +`remove-availability-first-red.log.gz` 保存最初转向时的试验:普通串行拒绝反例为有效原红;另一条 attempted advisory 政策反例属于随后被 operator 054 否定的方案,其测试已经恢复,不将它算入最终产品验证计数。所有旧原件及归档哈希保留。 + +## 独立复审交接 + +Review-Subject-Ref:`task:0001787725735853-000657-d43e332d`。 +Accepted-Source-Ref:`docs/features/F117-message-delivery-lifecycle.md`。 +Accepted-Revision:`d015c806d91839658788a32593735e3f4ff99254`。 +Scope override:同一 thread 的 `0001791597613326-000051-04cd35ac`、`0001791597713607-000054-cd1b4b61` 与 `0001791597752751-000055-f3cb29c5`;最终要求删除普通发送 availability 层和提示,身份只绑定运行归属。 + +请非作者 local peer 只读精确后继,重点检查普通发送是否仍残留 availability 拒绝/延后/提示、实际失败回复与精确前序回流、接纳前后 Queue 重试边界,以及旧测试改写是否保留原失败回报不反弹。复验使用同一 feature checkout 的新编译 dist;不使用运行目录的产物或数据。放行后沿已授权的原 #1398 与 fork develop_base 增量交付,不自行 merge 或应用到运行实例。当前后继没有独立放行、发布或体验/soak 完成回执。 + +[砚砚/gpt-6.1-sol🐾] diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-codex-read-build.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-codex-read-build.log.gz new file mode 100644 index 0000000000..86e43ea321 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-codex-read-build.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-codex-read-exact-green.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-codex-read-exact-green.log.gz new file mode 100644 index 0000000000..60ba98beee Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-codex-read-exact-green.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-codex-read-green-2.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-codex-read-green-2.log.gz new file mode 100644 index 0000000000..08191e25a6 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-codex-read-green-2.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-codex-read-red.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-codex-read-red.log.gz new file mode 100644 index 0000000000..c416cef09a Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-codex-read-red.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-cross-thread-custody-red.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-cross-thread-custody-red.log.gz new file mode 100644 index 0000000000..217bdc2c11 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-cross-thread-custody-red.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-cross-thread-failure-seam-green-2.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-cross-thread-failure-seam-green-2.log.gz new file mode 100644 index 0000000000..5557278492 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-cross-thread-failure-seam-green-2.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-cross-thread-final-api.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-cross-thread-final-api.log.gz new file mode 100644 index 0000000000..bca4a86960 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-cross-thread-final-api.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-receipt-source-color-green.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-receipt-source-color-green.log.gz new file mode 100644 index 0000000000..b319876713 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-receipt-source-color-green.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-receipt-source-color-red-2.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-receipt-source-color-red-2.log.gz new file mode 100644 index 0000000000..c7ce952331 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-receipt-source-color-red-2.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-timeout-isolation-red.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-timeout-isolation-red.log.gz new file mode 100644 index 0000000000..e8b90ed899 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/f117-timeout-isolation-red.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/native-consumption-metadata.json b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/native-consumption-metadata.json new file mode 100644 index 0000000000..1a0b634692 --- /dev/null +++ b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/native-consumption-metadata.json @@ -0,0 +1,51 @@ +{ + "invocationId": "1986d0d1-39ba-447a-821c-21ae343af683", + "steerCount": 5, + "observations": [ + { + "submittedAt": 1791595702533, + "threadId": "01a1192e-20de-77d0-a608-ec471b6c1dc1", + "turnId": "01a12369-ce70-7063-bf35-35b6d7d307e6", + "consumedAt": 1791595708282, + "clientId": "7e83dd7d-939d-448d-8554-75dd4cbb8e42", + "exactThread": true, + "exactTurn": true + }, + { + "submittedAt": 1791595815566, + "threadId": "01a1192e-20de-77d0-a608-ec471b6c1dc1", + "turnId": "01a12369-ce70-7063-bf35-35b6d7d307e6", + "consumedAt": 1791595816791, + "clientId": "624cc841-9ca8-4871-99c1-9cb8bedba475", + "exactThread": true, + "exactTurn": true + }, + { + "submittedAt": 1791595926484, + "threadId": "01a1192e-20de-77d0-a608-ec471b6c1dc1", + "turnId": "01a12369-ce70-7063-bf35-35b6d7d307e6", + "consumedAt": 1791596009331, + "clientId": "14157843-6c39-40a9-b60c-48a96c6b38eb", + "exactThread": true, + "exactTurn": true + }, + { + "submittedAt": 1791596027929, + "threadId": "01a1192e-20de-77d0-a608-ec471b6c1dc1", + "turnId": "01a12369-ce70-7063-bf35-35b6d7d307e6", + "consumedAt": 1791596035314, + "clientId": "d5fd9da3-edfd-4de2-8822-f06004ac89f0", + "exactThread": true, + "exactTurn": true + }, + { + "submittedAt": 1791596170945, + "threadId": "01a1192e-20de-77d0-a608-ec471b6c1dc1", + "turnId": "01a12369-ce70-7063-bf35-35b6d7d307e6", + "consumedAt": 1791596185275, + "clientId": "e6a48cd9-8e2d-4555-b1b1-84d82c8c86b7", + "exactThread": true, + "exactTurn": true + } + ] +} diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/opus-6cc7-api-changes-requested.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/opus-6cc7-api-changes-requested.log.gz new file mode 100644 index 0000000000..8603a42fee Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/opus-6cc7-api-changes-requested.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/opus-6cc7-seam.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/opus-6cc7-seam.log.gz new file mode 100644 index 0000000000..6925632597 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/opus-6cc7-seam.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/opus-6cc7-web.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/opus-6cc7-web.log.gz new file mode 100644 index 0000000000..2d274877ee Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/opus-6cc7-web.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/preview-check.mjs b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/preview-check.mjs new file mode 100644 index 0000000000..7609039507 --- /dev/null +++ b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/preview-check.mjs @@ -0,0 +1,80 @@ +import { chromium } from 'file:///Users/lang/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright/index.mjs'; +import assert from 'node:assert/strict'; +import { writeFile } from 'node:fs/promises'; + +const browser = await chromium.launch({ + headless: true, + executablePath: + '/Users/lang/Library/Caches/ms-playwright/chromium_headless_shell-1228/chrome-headless-shell-mac-arm64/chrome-headless-shell', +}); +const page = await browser.newPage({ viewport: { width: 1100, height: 850 } }); +const errors = []; +page.on('pageerror', (error) => errors.push(error.message)); +await page.route('**/*', (route) => { + const url = new URL(route.request().url()); + return url.origin === 'http://127.0.0.1:5173' ? route.continue() : route.abort(); +}); +try { + await page.goto('http://127.0.0.1:5173/'); + await page.getByPlaceholder('继续输入,消息可不中断追加给当前成员...').waitFor(); + assert.equal(await page.getByTestId('active-invocation-banner').count(), 0); + const stop = page.getByRole('button', { name: 'Stop generation', exact: true }); + assert.equal(await stop.count(), 1); + assert.equal(await stop.getAttribute('title'), '停止当前对话全部回复'); + assert.notEqual(await stop.evaluate((element) => getComputedStyle(element).backgroundColor), 'rgba(0, 0, 0, 0)'); + const delivered = page.getByRole('button', { name: /^lang · 投递于: 10\/09 12:26:02 · / }); + await delivered.hover(); + await page.getByRole('tooltip').filter({ hasText: '投递于: 10/09 12:26:02' }).waitFor(); + assert.equal(await page.getByText('读取状态不可用', { exact: false }).count(), 0); + await page.screenshot({ path: '/tmp/f117-followup-preview.png', fullPage: true }); + await page.keyboard.press('Escape'); + const read = page.getByRole('button', { name: /^lang · 读取于: 10\/09 12:26:03 · / }); + await read.hover(); + await page.getByRole('tooltip').filter({ hasText: '读取于: 10/09 12:26:03' }).waitFor(); + await page.screenshot({ path: '/tmp/f117-followup-preview-read.png', fullPage: true }); + const color = await page + .locator('[data-append-delivery-state] span') + .first() + .evaluate((element) => getComputedStyle(element).backgroundColor); + assert.equal(color, 'rgb(107, 84, 67)'); + assert.equal(await page.getByText('展开全文', { exact: true }).count(), 0); + await delivered.click(); + assert.equal(await delivered.getAttribute('aria-expanded'), 'true'); + await delivered.press('Enter'); + assert.equal(await delivered.getAttribute('aria-expanded'), 'false'); + await stop.click(); + await page.waitForFunction( + () => window.fixtureRequests.filter((request) => request.path.includes('/cancel')).length === 2, + ); + const cancels = await page.evaluate(() => + window.fixtureRequests.filter((request) => request.path.includes('/cancel')), + ); + assert.deepEqual(cancels.map((request) => JSON.parse(request.body).catId).sort(), ['codex', 'opus']); + assert.equal(await page.getByText('猫猫正在回复中', { exact: false }).count(), 0); + await page.setViewportSize({ width: 430, height: 800 }); + await page.reload(); + await page.getByPlaceholder('继续输入,消息可不中断追加给当前成员...').waitFor(); + const bounds = await page.getByRole('button', { name: 'Stop generation', exact: true }).boundingBox(); + assert(bounds && bounds.x >= 0 && bounds.x + bounds.width <= 430); + await page.screenshot({ path: '/tmp/f117-followup-preview-mobile.png', fullPage: true }); + assert.equal(errors.length, 0); + const result = { + kind: 'current-components-with-in-memory-API', + url: 'http://127.0.0.1:5173/', + runtimeDataUsed: false, + providerUsed: false, + duplicateBanner: false, + themeDot: color, + exactDeliveryAndReadTooltips: true, + messageRowToggle: true, + keyboardToggle: true, + tooltipDelayMs: 1000, + cancelTargets: cancels, + mobileStopWithinViewport: true, + errors, + }; + await writeFile('/tmp/f117-followup-preview-result.json', JSON.stringify(result, null, 2)); + console.log(JSON.stringify(result)); +} finally { + await browser.close(); +} diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/preview-result.json b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/preview-result.json new file mode 100644 index 0000000000..9669c0dfcb --- /dev/null +++ b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/preview-result.json @@ -0,0 +1,24 @@ +{ + "kind": "current-components-with-in-memory-API", + "url": "http://127.0.0.1:5173/", + "runtimeDataUsed": false, + "providerUsed": false, + "duplicateBanner": false, + "themeDot": "rgb(107, 84, 67)", + "exactDeliveryAndReadTooltips": true, + "messageRowToggle": true, + "keyboardToggle": true, + "tooltipDelayMs": 1000, + "cancelTargets": [ + { + "path": "/api/threads/feedback-preview/executions/live/one/cancel", + "body": "{\"catId\":\"opus\"}" + }, + { + "path": "/api/threads/feedback-preview/executions/live/two/cancel", + "body": "{\"catId\":\"codex\"}" + } + ], + "mobileStopWithinViewport": true, + "errors": [] +} diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/remove-availability-api-final.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/remove-availability-api-final.log.gz new file mode 100644 index 0000000000..e90d64c947 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/remove-availability-api-final.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/remove-availability-api-partial-red.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/remove-availability-api-partial-red.log.gz new file mode 100644 index 0000000000..1f7aa01aee Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/remove-availability-api-partial-red.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/remove-availability-biome-final.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/remove-availability-biome-final.log.gz new file mode 100644 index 0000000000..743db22dfe Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/remove-availability-biome-final.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/remove-availability-build-final.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/remove-availability-build-final.log.gz new file mode 100644 index 0000000000..86e43ea321 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/remove-availability-build-final.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/remove-availability-first-red.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/remove-availability-first-red.log.gz new file mode 100644 index 0000000000..dabecd81d1 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/remove-availability-first-red.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/remove-availability-prompt-green.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/remove-availability-prompt-green.log.gz new file mode 100644 index 0000000000..083ee4990d Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/remove-availability-prompt-green.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/remove-availability-prompt-red.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/remove-availability-prompt-red.log.gz new file mode 100644 index 0000000000..da8dfc7cb4 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/remove-availability-prompt-red.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/remove-availability-seam-final.log.gz b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/remove-availability-seam-final.log.gz new file mode 100644 index 0000000000..803f858cfc Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/remove-availability-seam-final.log.gz differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/sha256.json b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/sha256.json new file mode 100644 index 0000000000..61a3059a37 --- /dev/null +++ b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/sha256.json @@ -0,0 +1,127 @@ +[ + { + "file": "f117-cross-thread-custody-red.log.gz", + "originalSha256": "35e504df8176b5b297079b84341e166654b74dfc365bebabea0f8ca1477e7a26", + "sha256": "1b782e780b664b8e1ff820d4591bcef5f7a2ef4279baf7c71c2f511622aa4303" + }, + { + "file": "f117-receipt-source-color-red-2.log.gz", + "originalSha256": "8fa852ef1c6abefeba01e7990f6ceef98fc3835807fa7600e6039ed999c8b914", + "sha256": "c957ede54b687439622ffdf7845bef4e1f73c99cb3fdd38212aec304d0483b0d" + }, + { + "file": "f117-timeout-isolation-red.log.gz", + "originalSha256": "2042aa7f69da12035aeddea47e4595dc9b7132af08886fae57890f1e5d69dc29", + "sha256": "f2ce10b96f8b692930ece78d0da3ecb34338858798cb2c93f88d1d8e6bce8416" + }, + { + "file": "f117-codex-read-red.log.gz", + "originalSha256": "5f6ff36375f5323794f972310e202fb67931f0ce3f82707944d1cb332aa78343", + "sha256": "720b858f46873aa759cb906c22a1cb2da7b58be4a73b8169a7737e057e06f992" + }, + { + "file": "f117-cross-thread-final-api.log.gz", + "originalSha256": "60176b3863e00630f19b63ad54508cb4fffcf59d5403768fd51fe2bc9ba093f1", + "sha256": "06ac1c0bc9f8fbd4131244af5695884574cb3058986e2ddbc13cefd84b774b50" + }, + { + "file": "f117-cross-thread-failure-seam-green-2.log.gz", + "originalSha256": "79d4fea68ec80f1e64c3590862b129e357b4eecfb3280333bbf633ca6851736a", + "sha256": "53afd4c2fddc8b14675905c889353056e35b843d484349fab06a115ef2b556ee" + }, + { + "file": "f117-codex-read-green-2.log.gz", + "originalSha256": "9222693048612b21db2f6a137f61dba6ac61a8ed29dd8e5557b0e401cfc3574e", + "sha256": "5f915429d986b56e830661d71df44b63061d756bf69c2e621692a1514ce491e4" + }, + { + "file": "f117-codex-read-exact-green.log.gz", + "originalSha256": "de67b4e08336969da8d9ce1a1c9468c178f58f42355316d2fee32b5c6aa1f256", + "sha256": "4d95a6aaa1635a5df808c33e6634d18044d238acc14130ffafea29c516aa7015" + }, + { + "file": "f117-receipt-source-color-green.log.gz", + "originalSha256": "9978e4e6de6d0a64756d44a9c97f0040963c43832684b6663e7eae27b07162ef", + "sha256": "f2fc56732dc3ee03ab7eb445489f7112f36dd764af4d4b9af3f3c35a1407ad31" + }, + { + "file": "f117-codex-read-build.log.gz", + "originalSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "sha256": "9ceffb7310338057cfe71a4ae1e2c98d2c485d81cdef906532a801f457a38d64" + }, + { + "file": "native-consumption-metadata.json", + "sha256": "4d40673baf97ce5e1705f6dededd3d3eb9ee8c70246f4493352db56a45b41bb9" + }, + { + "file": "web-types.json", + "sha256": "55f3af27c14ee7027aeb4f5647589819d8e5ed61336a384c3e3aba3bf994d2bf" + }, + { + "file": "preview-result.json", + "sha256": "db9620e3f35db84361ffe4fa9f52a624f55b76316af83ef1e95fe2a7964b4d49" + }, + { + "file": "source-color-preview.png", + "sha256": "57348f9b801d35f3d8122d8769d4635e14d9006de8f5923cc5c9121e584a2e5e" + }, + { + "file": "preview-check.mjs", + "sha256": "f85fe8c795bf01d32dc449130e2af532d12082b936e6de2b896247f8cc457851" + }, + { + "file": "remove-availability-first-red.log.gz", + "originalSha256": "7741c01cff68aa6922e0be0c27a5c70d09691f7220d225e790d25cdda4def260", + "sha256": "fa0c93ee02f4c738e35a2098531a5d8e3cfa292f89b200befa8e98754ab31fac" + }, + { + "file": "remove-availability-api-partial-red.log.gz", + "originalSha256": "9106bdb1aabc491f78d9c358164ad93fe81c5bf4488f6f1d92ce694284e2b202", + "sha256": "7bf9e62665c31441a90816b03541935457cfe164f1ce91aa020ecdf245896139" + }, + { + "file": "remove-availability-prompt-red.log.gz", + "originalSha256": "cb414e0527aaf6e3e333c3b3dd252a478af1761e3dadefa37a0125464c95cc2f", + "sha256": "e2bf5cb8c81d56e127f6fa93eb0681f745efe7c410c9563c1c3ac333a8ef278d" + }, + { + "file": "remove-availability-api-final.log.gz", + "originalSha256": "1687112560dd11e19cb1d04a600c615a6a4e16cd21899f0891dd9b379616cd18", + "sha256": "ae245286d220f0b3ee71ea9e5c46ef8e900aebe76573198177fc81ba7b65595f" + }, + { + "file": "remove-availability-prompt-green.log.gz", + "originalSha256": "a1c3ef539e463c3b239ce5ce556e64c5c7c81b019262b03411d3155361e41bc3", + "sha256": "8a3f50fee2a67617af86101d78397753eb3389692ee72700632c4069084ed53e" + }, + { + "file": "remove-availability-seam-final.log.gz", + "originalSha256": "2dedf2205cba7cc5d5071e602b3878fd3fc67a2669a79bfdc6904c3e84be7803", + "sha256": "47d6893449c6111bc8610ecd2f29c4917f17eff4c13ef125cca55634d5e4caf6" + }, + { + "file": "opus-6cc7-api-changes-requested.log.gz", + "originalSha256": "b75dc4428c6ec7fc5b278e3102751b5b36f4adcd58ce2989c79e723351d22ccf", + "sha256": "bcde1a542690e446fef0fbc1bd84cb8678c92262693ff8b2aae7f3c7bd4ae176" + }, + { + "file": "opus-6cc7-seam.log.gz", + "originalSha256": "6bc753e0f5f1cb8588e8a6fcb43f0eb1740ee9b0f5aa1a2ab262800c7f647586", + "sha256": "7b5f597ba4c44f57d5baef580faaceda5b786b71af88f11cd7ffedceedebff1b" + }, + { + "file": "opus-6cc7-web.log.gz", + "originalSha256": "f594fc07bfb1ff5e2a79222465939b084795656c1099f8582f5d2ba3761981a0", + "sha256": "3a8814bb8f5c14d08e0b87660d637049b0d61e6f5e12b3ce662ba66e075a67e9" + }, + { + "file": "remove-availability-build-final.log.gz", + "originalSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "sha256": "9ceffb7310338057cfe71a4ae1e2c98d2c485d81cdef906532a801f457a38d64" + }, + { + "file": "remove-availability-biome-final.log.gz", + "originalSha256": "252ecaa316ec4d27d7374d7e2d274db884e9d5150430ab23df9be76d1a46b280", + "sha256": "963f5405356dd5e5a22d4ff43bae340654d79c3f688362cd6e573fcfacd6d1c1" + } +] diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/source-color-preview.png b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/source-color-preview.png new file mode 100644 index 0000000000..ed8bba45a7 Binary files /dev/null and b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/source-color-preview.png differ diff --git a/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/web-types.json b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/web-types.json new file mode 100644 index 0000000000..6dcfe0d2ee --- /dev/null +++ b/feature-specs/evidence/f117-feedback-2026-10-09/followup-2026-10-10/web-types.json @@ -0,0 +1,21 @@ +{ + "base": "984edb0da56872ee771441b0d2e9586eba5ce19b", + "replacedBaseFiles": 2, + "candidate": [ + { + "file": "packages/web/src/hooks/__tests__/proposal-card-realtime-socket.integration.test.tsx", + "line": 152, + "code": 2322, + "message": "Type 'boolean | null' is not assignable to type 'boolean'.\n Type 'null' is not assignable to type 'boolean'." + } + ], + "baseDiagnostics": [ + { + "file": "packages/web/src/hooks/__tests__/proposal-card-realtime-socket.integration.test.tsx", + "line": 152, + "code": 2322, + "message": "Type 'boolean | null' is not assignable to type 'boolean'.\n Type 'null' is not assignable to type 'boolean'." + } + ], + "sameDiagnostics": true +} diff --git a/packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts b/packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts index b62fd21c52..c7f4129432 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts @@ -87,7 +87,6 @@ import { type PersistenceContext, type RouteExecutionOptions, type RouteOptions, - type RoutingDispatchRejection, } from '../routing/route-helpers.js'; import { accumulateTextAggregate, @@ -259,11 +258,6 @@ interface QueueExecutionResult { * be retried at once: each waits for its retry time while the rest of the thread keeps draining. */ primarySettlementIncomplete?: boolean; - /** - * The targets actual-send routing refused in this attempt, as the route saw them. The Queue row - * forgets a targetless entry's resolved target when it goes back, so the retry wait is read here. - */ - routingRejections: RoutingDispatchRejection[]; } type ProcessingSlotReservation = PrestartRetirementReservation; @@ -2661,17 +2655,11 @@ export class QueueProcessor { options: { suppressAutomaticDrain?: boolean; attemptedQueueEntryIds?: readonly string[]; - routingRejections?: readonly RoutingDispatchRejection[]; suppressAutomaticFollowUp?: boolean; terminalInvocationIdByCatId?: Readonly>; } = {}, ): Promise { - const { - suppressAutomaticDrain = false, - attemptedQueueEntryIds = [], - routingRejections = [], - suppressAutomaticFollowUp = false, - } = options; + const { suppressAutomaticDrain = false, attemptedQueueEntryIds = [], suppressAutomaticFollowUp = false } = options; const sk = QueueProcessor.slotKey(threadId, catId); const isSuperseded = (candidateCatId: string): boolean => invocationId !== undefined && this.hasReplacementExecutionOwner(threadId, candidateCatId, invocationId); @@ -2686,11 +2674,10 @@ export class QueueProcessor { return; } if (isSuperseded(catId) || suppressAutomaticFollowUp) return; - if (suppressAutomaticDrain || routingRejections.length > 0) { - // F117 soak: the attempt failed before its handoff, or routing refused some of its targets at - // actual send. What went back to the Queue waits for its retry time rather than looping on the - // same failure, and the rest of the thread keeps draining. - this.deferFailedAttempt(threadId, catId, status, attemptedQueueEntryIds, routingRejections); + if (suppressAutomaticDrain) { + // A backend attempt that failed before handoff leaves the exact source + // queued with bounded backoff; other members continue draining. + this.deferFailedAttempt(threadId, catId, status, attemptedQueueEntryIds); } else { for (const entryId of attemptedQueueEntryIds) this.retryDeferrals.forget(entryId); } @@ -2704,22 +2691,10 @@ export class QueueProcessor { } /** - * Each entry the attempt left in the Queue waits for its retry time: the later of its backoff and - * the latest retry time routing named for a target it refused in this attempt. The refusal is the - * attempt's own, because a targetless entry goes back without the target it resolved to. + * Retry only entries left queued by an actual failed handoff, with the + * existing bounded backend backoff. Availability advice cannot park them. */ - private deferFailedAttempt( - threadId: string, - catId: string, - status: string, - entryIds: readonly string[], - routingRejections: readonly RoutingDispatchRejection[], - ): void { - const retryAts = routingRejections.flatMap((rejection) => - rejection.automaticRetryAt !== undefined ? [rejection.automaticRetryAt] : [], - ); - const targetRetryAt = retryAts.length > 0 ? Math.max(...retryAts) : undefined; - const refusedTargets = routingRejections.map((rejection) => rejection.catId); + private deferFailedAttempt(threadId: string, catId: string, status: string, entryIds: readonly string[]): void { for (const entryId of entryIds) { const entry = this.deps.queue.getEntrySnapshotAcrossUsers(threadId, entryId); if (!entry) { @@ -2727,9 +2702,9 @@ export class QueueProcessor { this.retryDeferrals.forget(entryId); continue; } - const retryAt = this.retryDeferrals.defer(threadId, entryId, targetRetryAt); + const retryAt = this.retryDeferrals.defer(threadId, entryId); this.deps.log.warn( - { threadId, catId, status, entryId, retryAt, refusedTargets, queued: entry.status === 'queued' }, + { threadId, catId, status, entryId, retryAt, queued: entry.status === 'queued' }, '[QueueProcessor] an attempt left its entry in the Queue; the entry waits for its retry time', ); } @@ -3008,7 +2983,6 @@ export class QueueProcessor { void this.onInvocationComplete(entry.threadId, catId, result.status, result.invocationId, [], { suppressAutomaticDrain: result.primarySettlementIncomplete, attemptedQueueEntryIds: result.attemptedQueueEntryIds, - routingRejections: result.routingRejections, suppressAutomaticFollowUp, terminalInvocationIdByCatId: result.terminalInvocationIdByCatId, }).catch(() => {}); @@ -3614,8 +3588,6 @@ export class QueueProcessor { // streams names this message, so clients write by id instead of guessing. const lifecycleResponseMessageIdByCat = new Map(); const terminalInvocationIdByCatId: Record = {}; - // F117 soak: the exact targets actual-send routing refused, kept for the retry wait. - const routingRejections: RoutingDispatchRejection[] = []; let returnedExecutionResult: QueueExecutionResult | undefined; const executionResult = (status: InvocationFinalStatus): QueueExecutionResult => { // Keep finally cleanup and the caller-visible completion status on one @@ -3633,7 +3605,6 @@ export class QueueProcessor { ...(invocationId ? { invocationId } : {}), attemptedQueueEntryIds: [entry.id, ...batchedEntryIds], terminalInvocationIdByCatId, - routingRejections, }; returnedExecutionResult = result; return result; @@ -4391,9 +4362,6 @@ export class QueueProcessor { ? { modeSystemPromptByCat: callerDispatchPromptByCat } : {}), turnCustodyWakeForCat: (catId: string) => retargetTurnCustodyWake(turnCustodyWake, catId), - onRoutingDispatchRejected: (rejection: RoutingDispatchRejection) => { - routingRejections.push(rejection); - }, ...(contentBlocks.length > 0 ? { contentBlocks } : {}), ...(controller.signal ? { signal: controller.signal } : {}), // F-parallel-cancel: per-cat signal so canceling one concurrent cat (e.g. @codex) @@ -5398,6 +5366,13 @@ export class QueueProcessor { ) { throw new Error('post-receiver Queue target reconciliation did not converge'); } + // Accepted siblings are retired; only a source still queued without + // its receiver needs the existing failed-handoff backoff. + if (returnedExecutionResult) { + returnedExecutionResult.primarySettlementIncomplete = [entry.id, ...batchedEntryIds].some( + (id) => queue.getEntrySnapshotAcrossUsers(threadId, id)?.status === 'queued', + ); + } } else { await this.settleAttemptQueueEntry(entry, finalStatus); } diff --git a/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts b/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts index a1faca3d1b..e475de98b3 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/invoke-single-cat.ts @@ -30,7 +30,6 @@ import { type RequestGenerationPresentationV1, type RequestGenerationRetryReason, type RequestGenerationSourceRef, - type RoutingPreflightDecisionV1, resolveCodexSpeed, type SealReason, type SessionPolicySnapshot, @@ -106,10 +105,6 @@ import { type AsrPersonMemoryPresentationEnvelope, type AsrPersonMemoryPresentationReceipt, } from '../../../../memory/people/AsrPersonMemoryOpportunityPromptService.js'; -import { - classifyRoutingDispatchFailure, - type RoutingDispatchFailureClass, -} from '../../../../routing-context/RoutingDispatchSignalContract.js'; import type { AgentPaneRegistry } from '../../../../terminal/agent-pane-registry.js'; import type { TmuxGateway } from '../../../../terminal/tmux-gateway.js'; import { resolveBootcampWorkspaceRoot } from '../../bootcamp/workspace-root.js'; @@ -1119,9 +1114,6 @@ export interface InvocationDeps { | import('../../../../plugin/builtin-runtime/collective-current-context.js').CollectiveCurrentContext | undefined; /** F293: fresh owner-scoped sparse routing projection resolved for every provider generation. */ - readonly routingContextPromptProjection?: import('../../../../routing-context/RoutingContextPromptProjector.js').RoutingContextPromptProjectionPort; - /** F293: observes routing evidence only after the canonical child terminal is durable. */ - readonly routingDispatchSignalObserver?: import('../../../../routing-context/RoutingDispatchSignalContract.js').RoutingDispatchTerminalObserver; /** F296 B3b-1: single owner of context epoch and cold/hot mode for provider-bound invocations. */ readonly contextEpochOwner?: Pick; /** Live project-hook auth readiness required before Claude can own a compaction sequence. */ @@ -1237,9 +1229,6 @@ export interface InvocationParams { /** Route-owned intent projected to provider behavior only when its provenance permits it. */ readonly routeIntent?: AgentRouteIntent; /** F293: deterministic message scope, independent from provider prompt inference. */ - readonly routingContextIntent?: 'review' | 'architecture'; - /** F293: exact actual-send decision retained until the durable terminal transition. */ - readonly routingDispatchPreflightDecision?: RoutingPreflightDecisionV1; readonly catId: CatId; readonly service: AgentService; /** #1208: one pre-provider capacity owner shared by all invocation consumers. */ @@ -1646,8 +1635,6 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP let turnExecutionFailureReason: string | undefined; let turnExecutionInterruptionReason: string | undefined; let turnExecutionCompletedSuccessfully = false; - let routingDispatchFailureClass: RoutingDispatchFailureClass | undefined; - let routingFailureObservedAt: number | undefined; // F153: Record cat invocation count with trigger type const triggerType = params.a2aTriggerMessageId ? 'mention' : params.parentInvocationId ? 'routing' : 'default'; @@ -3477,38 +3464,6 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP await exposeCurrentPromptMessages(); return true; }; - let routingContextPrepend = ''; - let routingContextProjectionFailureAudited = false; - const resolveRoutingContextProjection = async (): Promise => { - routingContextPrepend = ''; - if (!deps.routingContextPromptProjection) return; - try { - routingContextPrepend = await deps.routingContextPromptProjection.resolve({ - ownerId: userId, - ...(params.routingContextIntent ? { intent: params.routingContextIntent } : {}), - }); - } catch (err) { - log.warn( - { threadId, invocationId, catId, errorName: err instanceof Error ? err.name : 'unknown' }, - 'Routing context prompt projection failed; continuing without dynamic routing context', - ); - if (routingContextProjectionFailureAudited) return; - routingContextProjectionFailureAudited = true; - try { - await auditLog.append({ - type: AuditEventTypes.ROUTING_CONTEXT_PROJECTION_FAILED, - threadId, - data: { - catId, - invocationId, - errorName: err instanceof Error ? err.name : 'unknown', - }, - }); - } catch (auditErr) { - log.warn({ threadId, invocationId, auditErr }, 'Routing context projection failure audit write failed'); - } - } - }; const applyEpochScopedPrompt = async (): Promise => { const promptBuiltForEpoch = await applyContextPromptFactory(); if ( @@ -3525,7 +3480,6 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP await resolveInvocationMemoryCues(); resolveEntityNudgePresentation(); await resolveAsrPersonMemoryOpportunities(contextContinuityHandshake); - await resolveRoutingContextProjection(); }; // F-BLOAT: Only inject staticIdentity (systemPrompt) on new provider sessions. @@ -3604,9 +3558,6 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP : `${promptWithMission}`; if (contextHintPrefix) composedEffectivePrompt = `${contextHintPrefix}\n\n---\n\n${composedEffectivePrompt}`; if (stagingPrepend) composedEffectivePrompt = `${stagingPrepend}\n\n---\n\n${composedEffectivePrompt}`; - if (routingContextPrepend) { - composedEffectivePrompt = `${routingContextPrepend}\n\n---\n\n${composedEffectivePrompt}`; - } /* @segment M2 — Transcript Path Hints */ composedEffectivePrompt = appendTranscriptPathHints(composedEffectivePrompt, TRANSCRIPT_DIR, threadId); return { promptWithInvocationAdditions: composedPrompt, effectivePrompt: composedEffectivePrompt }; @@ -3768,7 +3719,7 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP injectSystemPrompt, hasContextHint: Boolean(contextHintPrefix), hasStagingPrepend: Boolean(stagingPrepend), - hasRoutingContextProjection: Boolean(routingContextPrepend), + hasRoutingContextProjection: false, hasMissionPrefix: Boolean(missionPrefix), }); return { @@ -5237,15 +5188,7 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP const streamProcessedOutputs = async function* (sourceMsg: AgentMessage | undefined): AsyncIterable { if (!sourceMsg) return; - const messageObservedAt = Date.now(); for (const out of await processMessage(sourceMsg)) { - routingDispatchFailureClass ??= classifyRoutingDispatchFailure({ - ...(out.metadata?.cliDiagnostics?.reasonCode - ? { cliReasonCode: out.metadata.cliDiagnostics.reasonCode } - : {}), - ...(out.errorCode ? { providerErrorCode: out.errorCode } : {}), - }); - if (routingDispatchFailureClass !== undefined) routingFailureObservedAt ??= messageObservedAt; if (out.type === 'error') { hadError = true; turnExecutionFailureReason ??= 'provider_execution_failed'; @@ -6252,34 +6195,6 @@ export async function* invokeSingleCat(deps: InvocationDeps, params: InvocationP const result = await deps.turnExecutionStore.transitionTerminal(invocationId, terminal); if (result.outcome === 'not_found' || result.record === null) { log.error({ invocationId, terminal }, 'Turn execution disappeared before terminal transition'); - } else if ( - deps.routingDispatchSignalObserver && - params.routingDispatchPreflightDecision && - result.record.status !== 'running' && - result.record.endedAt !== undefined - ) { - const failureClass = - routingDispatchFailureClass ?? - classifyRoutingDispatchFailure({ terminalReason: result.record.terminalReason }); - try { - await deps.routingDispatchSignalObserver.observeTerminal({ - ownerId: userId, - observationId: invocationId, - observedAt: result.record.endedAt, - evidenceRef: `turn-execution:${invocationId}`, - catId, - status: result.record.status, - ...(result.record.status === 'failed' && failureClass ? { failureClass } : {}), - ...(result.record.status === 'failed' && routingFailureObservedAt !== undefined - ? { failureObservedAt: routingFailureObservedAt } - : {}), - preflightDecision: params.routingDispatchPreflightDecision, - }); - } catch (err) { - // Durable lifecycle truth and delivery already committed. Routing observation - // is advisory and must never rewrite or suppress that canonical terminal. - log.warn({ invocationId, err }, 'F293 durable dispatch signal observation failed'); - } } } catch (err) { // A running record is deliberately left for startup reconciliation; diff --git a/packages/api/src/domains/cats/services/agents/providers/CodexAppServerClient.ts b/packages/api/src/domains/cats/services/agents/providers/CodexAppServerClient.ts index 2acb92d748..6c4e276fa6 100644 --- a/packages/api/src/domains/cats/services/agents/providers/CodexAppServerClient.ts +++ b/packages/api/src/domains/cats/services/agents/providers/CodexAppServerClient.ts @@ -236,6 +236,7 @@ export class CodexAppServerClient { let activeThreadId: string | null = null; let activeTurnId: string | null = null; let activeRunDispatchOpen = false; + const inputReadCallbacks = new Map Promise; observing: boolean }>(); let releaseActiveRunDispatch: (() => void) | undefined; let transportDisposition: 'release' | 'evict' = 'release'; const liveEnd = Symbol('live-end'); @@ -466,7 +467,7 @@ export class CodexAppServerClient { activeRunDispatchOpen = true; const release = input.activeRunDispatch.register({ invocationId, - capabilities: { append: true, steer: true }, + capabilities: { append: true, steer: true, inputReadReceipt: true }, handle, dispatch: async (dispatchInput, options) => { if (options.expectedInvocationId !== invocationId) { @@ -479,6 +480,13 @@ export class CodexAppServerClient { const imagePaths = dispatchInput.imagePaths?.filter((path) => path.length > 0) ?? []; if (!text && imagePaths.length === 0) return { accepted: false, reason: 'invalid_input' }; const clientUserMessageId = randomUUID(); + if (dispatchInput.onInputRead) { + inputReadCallbacks.set(clientUserMessageId, { + turnId: handle.turnId, + notify: dispatchInput.onInputRead, + observing: false, + }); + } try { const result = asCodexAppServerRecord( await this.request('turn/steer', { @@ -492,8 +500,10 @@ export class CodexAppServerClient { }), ); if (result?.turnId === handle.turnId) return { accepted: true, handle }; + inputReadCallbacks.delete(clientUserMessageId); return { accepted: false, reason: 'active_run_mismatch' }; } catch { + inputReadCallbacks.delete(clientUserMessageId); return { accepted: false, reason: 'provider_rejected' }; } }, @@ -592,6 +602,24 @@ export class CodexAppServerClient { const itemObserved = record?.method === 'item/started' || record?.method === 'item/completed'; const exactCompletedItem = record?.method === 'item/completed' && params?.threadId === threadId && params?.turnId === activeTurnId; + if (exactCompletedItem) { + const item = asCodexAppServerRecord(params?.item); + const clientId = + item?.type === 'userMessage' && typeof item.clientId === 'string' ? item.clientId : undefined; + const receipt = clientId ? inputReadCallbacks.get(clientId) : undefined; + if (clientId && receipt && receipt.turnId === params?.turnId && !receipt.observing) { + // Native completion means this exact pending input entered prompt + // history; turn/steer acknowledgement alone does not prove consumption. + receipt.observing = true; + void Promise.resolve() + .then(receipt.notify) + .then(() => inputReadCallbacks.delete(clientId)) + .catch(() => { + // Persistence never blocks provider/Stop; a replay may retry it. + receipt.observing = false; + }); + } + } if (exactCompletedItem && this.deps.freshnessController?.observeProtocolItem) { const observation = classifyCodexProtocolItem(envelope); if (observation) { @@ -728,6 +756,7 @@ export class CodexAppServerClient { throw failure; } finally { activeRunDispatchOpen = false; + inputReadCallbacks.clear(); releaseActiveRunDispatch?.(); runClosed = true; clearInterval(idleTimer); diff --git a/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts b/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts index 6c7fe7f98d..4e37d4ee49 100644 --- a/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts +++ b/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts @@ -512,11 +512,8 @@ export interface AgentRouterOptions { /** F296 B3b-2: shared provider-presentation delivery ledger. */ presentationLedger?: PresentationLedger; /** F293: owner-scoped sparse routing projection consumed by provider generation. */ - routingContextPromptProjection?: import('../../../../routing-context/RoutingContextPromptProjector.js').RoutingContextPromptProjectionPort; /** F293: shared actual-send preflight over the same resolver/catalog graph. */ - routingDispatchPreflight?: import('../../../../routing-context/RoutingDispatchPreflightPort.js').RoutingDispatchPreflightPort; /** F293: durable dispatch terminal observer over the same routing signal graph. */ - routingDispatchSignalObserver?: import('../../../../routing-context/RoutingDispatchSignalContract.js').RoutingDispatchTerminalObserver; /** F276: terminal disposition authority for person-memory write opportunities. */ writeOpportunityTerminalLedger?: import('../invocation/invoke-single-cat.js').InvocationDeps['writeOpportunityTerminalLedger']; /** F276: reservation and delivery authority for person-memory write opportunities. */ @@ -623,9 +620,6 @@ export class AgentRouter { private claudeProjectHookCarrierReady: boolean | ((projectRoot: string) => boolean); private claudeCompactionHooks: ClaudeCompactionHooksFactory | undefined; private presentationLedger: PresentationLedger | undefined; - private routingContextPromptProjection?: import('../../../../routing-context/RoutingContextPromptProjector.js').RoutingContextPromptProjectionPort; - private routingDispatchPreflight?: import('../../../../routing-context/RoutingDispatchPreflightPort.js').RoutingDispatchPreflightPort; - private routingDispatchSignalObserver?: import('../../../../routing-context/RoutingDispatchSignalContract.js').RoutingDispatchTerminalObserver; private writeOpportunityTerminalLedger?: import('../invocation/invoke-single-cat.js').InvocationDeps['writeOpportunityTerminalLedger']; private writeOpportunityDeliveryStore?: import('../invocation/invoke-single-cat.js').InvocationDeps['writeOpportunityDeliveryStore']; private runtimeSessionStore: IRuntimeSessionStore | undefined; @@ -780,9 +774,6 @@ export class AgentRouter { this.claudeProjectHookCarrierReady = options.claudeProjectHookCarrierReady ?? false; this.claudeCompactionHooks = options.claudeCompactionHooks; this.presentationLedger = options.presentationLedger; - this.routingContextPromptProjection = options.routingContextPromptProjection; - this.routingDispatchPreflight = options.routingDispatchPreflight; - this.routingDispatchSignalObserver = options.routingDispatchSignalObserver; this.writeOpportunityTerminalLedger = options.writeOpportunityTerminalLedger; this.writeOpportunityDeliveryStore = options.writeOpportunityDeliveryStore; this.runtimeSessionStore = options.runtimeSessionStore; @@ -1492,7 +1483,6 @@ export class AgentRouter { return { services: this.services, unavailableServices: this.unavailableServices, - ...(this.routingDispatchPreflight ? { routingDispatchPreflight: this.routingDispatchPreflight } : {}), invocationDeps: { messageStore: this.messageStore, ...(this.collectiveContext ? { collectiveContext: this.collectiveContext } : {}), @@ -1508,12 +1498,6 @@ export class AgentRouter { claudeProjectHookCarrierReady: this.claudeProjectHookCarrierReady, ...(this.claudeCompactionHooks ? { claudeCompactionHooks: this.claudeCompactionHooks } : {}), ...(this.presentationLedger ? { presentationLedger: this.presentationLedger } : {}), - ...(this.routingContextPromptProjection - ? { routingContextPromptProjection: this.routingContextPromptProjection } - : {}), - ...(this.routingDispatchSignalObserver - ? { routingDispatchSignalObserver: this.routingDispatchSignalObserver } - : {}), ...(this.writeOpportunityTerminalLedger ? { writeOpportunityTerminalLedger: this.writeOpportunityTerminalLedger } : {}), @@ -1719,10 +1703,8 @@ export class AgentRouter { } const strategyDeps = this.getStrategyDeps(); - const routingContextIntent = inferRoutingContextIntent(cleanMessage); const routeOptions = { routeIntent: projectAgentRouteIntent(intent), - ...(routingContextIntent ? { routingContextIntent } : {}), contentBlocks, uploadDir, signal, @@ -1856,11 +1838,9 @@ export class AgentRouter { const strategyDeps = this.getStrategyDeps(); const { callerTraceContext: _callerTraceContext, ...strategyInputOptions } = options; - const routingContextIntent = inferRoutingContextIntent(cleanMessage); const routeOptions: RouteOptions = { ...strategyInputOptions, routeIntent: projectAgentRouteIntent(intent), - ...(routingContextIntent ? { routingContextIntent } : {}), promptTags: intent.promptTags, currentUserMessageId: userMessageId, persistedPromptMessages: options?.persistedPromptMessages?.map((persisted) => ({ diff --git a/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts b/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts index e8e5b2c80e..e4a9132600 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts @@ -120,7 +120,6 @@ export interface RouteStrategyDeps { unavailableServices?: ReadonlyMap; invocationDeps: InvocationDeps; /** F293: fresh advisory/rejection decision at each actual child boundary. */ - routingDispatchPreflight?: import('../../../../routing-context/RoutingDispatchPreflightPort.js').RoutingDispatchPreflightPort; messageStore: IMessageStore; deliveryCursorStore?: DeliveryCursorStore; /** #80: Streaming draft persistence store */ @@ -253,11 +252,6 @@ export function mergePersistedPromptMessages( } /** Actual-send routing refused one of a route's requested targets. */ -export interface RoutingDispatchRejection { - readonly catId: string; - /** When routing will next accept an automatic attempt at this target, if routing names a time. */ - readonly automaticRetryAt?: number; -} /** Common options for both strategies */ export interface RouteOptions { @@ -268,7 +262,6 @@ export interface RouteOptions { /** Route-owned intent plus whether the user explicitly selected it. */ routeIntent?: AgentRouteIntent; /** F293: deterministic scope used to resolve sparse routing cognition. */ - routingContextIntent?: 'review' | 'architecture'; /** Authentication-grade owner provenance propagated unchanged to every child invocation. */ ownerAuthProvenance?: OwnerAuthProvenance; /** F281 Phase C: explicit first-party ingress provenance; omitted legacy callers fail closed. */ @@ -382,7 +375,6 @@ export interface RouteOptions { * targets are not requested targets). A Queue attempt keeps the exact refusal, so its entry waits * for that target's retry time even when the entry named no target. */ - onRoutingDispatchRejected?: ((rejection: RoutingDispatchRejection) => void) | undefined; /** F11: Mode-specific system prompt section (appended after identity prompt) */ modeSystemPrompt?: string | undefined; /** F11: Per-cat mode prompt override (takes precedence over modeSystemPrompt) */ diff --git a/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts b/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts index 1ffba40c3c..f595167cbd 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-parallel.ts @@ -4,7 +4,7 @@ */ import crypto from 'node:crypto'; -import type { CatConfig, CatId, OutputCommitDecision, RoutingPreflightDecisionV1 } from '@cat-cafe/shared'; +import type { CatConfig, CatId, OutputCommitDecision } from '@cat-cafe/shared'; import { catRegistry, resolveWorkflowSopSkill } from '@cat-cafe/shared'; import { deriveHistoryContextTokenCeiling, @@ -43,11 +43,6 @@ import { sharedEventStore, sharedNudgeCooldown } from '../../../../memory/entity import type { PushRecallPresentation } from '../../../../memory/f200-types.js'; import type { PreparedProactiveMemoryNudge } from '../../../../memory/ProactiveMemoryNudgeService.js'; import { mergePushRecallPresentations, triggerRecallCorrelation } from '../../../../memory/recall-correlation-hook.js'; -import { - isUserVisibleRoutingPreflightReceipt, - preflightRoutingDispatch, - routingDispatchPreflightReceipt, -} from '../../../../routing-context/RoutingDispatchPreflightPort.js'; import { assembleContext } from '../../context/ContextAssembler.js'; import { buildInvocationContext, @@ -136,8 +131,6 @@ import { toStoredToolEvent, upsertMaxBoundary, } from './route-helpers.js'; -import { isRoutingOwnerAttempt } from './routing-owner-attempt.js'; -import { routingPreflightNotice } from './routing-preflight-notice.js'; import { appendThinkingChunk, renderThinkingChunks } from './thinking-chunks.js'; import { withTimeoutDiagnostics } from './timeout-diagnostics-metadata.js'; import { buildVoteTally, checkVoteCompletion, extractVoteFromText, VOTE_RESULT_SOURCE } from './vote-intercept.js'; @@ -198,43 +191,7 @@ export async function* routeParallel( modeSystemPrompt, modeSystemPromptByCat, } = options; - let routingDispatchPreflightDecision: RoutingPreflightDecisionV1 | undefined; - if (deps.routingDispatchPreflight) { - const requestedTargetCats = [...targetCats]; - const routingPreflight = await preflightRoutingDispatch(deps.routingDispatchPreflight, { - ownerId: userId, - targetCatIds: requestedTargetCats, - ...(isRoutingOwnerAttempt(options) ? { ownerRequestedAttempt: true } : {}), - ...(options.routingContextIntent ? { intent: options.routingContextIntent } : {}), - }); - routingDispatchPreflightDecision = routingPreflight; - for (const targetCatId of requestedTargetCats) { - const receipt = routingDispatchPreflightReceipt(routingPreflight, targetCatId); - if (!isUserVisibleRoutingPreflightReceipt(receipt)) continue; - const notice = await routingPreflightNotice(deps, options, routingPreflight, targetCatId, threadId, true); - if (notice) yield notice; - if (receipt.target.disposition === 'rejected') { - const { automaticRetryAt } = receipt.target; - options.onRoutingDispatchRejected?.({ - catId: targetCatId, - ...(automaticRetryAt !== undefined ? { automaticRetryAt } : {}), - }); - yield { - type: 'error', - catId: targetCatId, - errorCode: 'routing_preflight_rejected', - error: '本次未执行:成员当前不可用。恢复后可重试原消息。', - timestamp: Date.now(), - }; - } - } - targetCats = requestedTargetCats.filter( - (catId) => routingPreflight.targets.find((target) => target.targetCatId === catId)?.disposition !== 'rejected', - ); - if (targetCats.length === 0) { - return; - } - } + const ownerAuthProvenance = options.ownerAuthProvenance ?? 'unknown'; const thinkingMode = options.thinkingMode ?? 'play'; const turnExecutionKind = 'ordinary'; @@ -997,8 +954,6 @@ export async function* routeParallel( catRemoteCancellation.set(catId, remoteCancellation); const invocationStream = invokeSingleCat(deps.invocationDeps, { ...(options.routeIntent ? { routeIntent: options.routeIntent } : {}), - ...(options.routingContextIntent ? { routingContextIntent: options.routingContextIntent } : {}), - ...(routingDispatchPreflightDecision ? { routingDispatchPreflightDecision } : {}), catId, service, capacitySnapshot, diff --git a/packages/api/src/domains/cats/services/agents/routing/route-serial.ts b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts index f30a8d31c5..5fec9a138f 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-serial.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts @@ -16,7 +16,6 @@ import { type OutputCommitDecision, type QueueTerminalConsumptionWitness, type RichBlock, - type RoutingPreflightDecisionV1, resolveWorkflowSopSkill, } from '@cat-cafe/shared'; import type { Span } from '@opentelemetry/api'; @@ -129,11 +128,6 @@ import { sharedEventStore, sharedNudgeCooldown } from '../../../../memory/entity import type { PushRecallPresentation } from '../../../../memory/f200-types.js'; import type { PreparedProactiveMemoryNudge } from '../../../../memory/ProactiveMemoryNudgeService.js'; import { mergePushRecallPresentations, triggerRecallCorrelation } from '../../../../memory/recall-correlation-hook.js'; -import { - isUserVisibleRoutingPreflightReceipt, - preflightRoutingDispatch, - routingDispatchPreflightReceipt, -} from '../../../../routing-context/RoutingDispatchPreflightPort.js'; import { assembleContext } from '../../context/ContextAssembler.js'; import { buildInvocationContext, @@ -234,8 +228,6 @@ import { toStoredToolEvent, upsertMaxBoundary, } from './route-helpers.js'; -import { isRoutingOwnerAttempt } from './routing-owner-attempt.js'; -import { routingPreflightNotice } from './routing-preflight-notice.js'; import { appendThinkingChunk, renderThinkingChunks } from './thinking-chunks.js'; import { withTimeoutDiagnostics } from './timeout-diagnostics-metadata.js'; import { detectMatchedVerdictKeyword, shouldWarnVerdictWithoutPass } from './verdict-detect.js'; @@ -812,7 +804,7 @@ export async function* routeSerial( preparationTimings.length = 0; } let stopGateRemedialAttempted = false; - let routingDispatchPreflightDecision: RoutingPreflightDecisionV1 | undefined; + // F-parallel-cancel: per-cat signal — canceling one cat skips ONLY that cat, not the // whole worklist. force-reset/cancelAll aborts every cat's controller, so all entries // skip = equivalent to stopping. Using the shared primaryController.signal made @@ -822,49 +814,8 @@ export async function* routeSerial( index++; continue; } - if (deps.routingDispatchPreflight) { - routingDispatchPreflightDecision = await preflightRoutingDispatch(deps.routingDispatchPreflight, { - ownerId: userId, - targetCatIds: [catId], - ...(index < targetCats.length && isRoutingOwnerAttempt(options) ? { ownerRequestedAttempt: true } : {}), - ...(options.routingContextIntent ? { intent: options.routingContextIntent } : {}), - }); - const receipt = routingDispatchPreflightReceipt(routingDispatchPreflightDecision, catId); - // Only a rejected send changes user-visible behavior. Warned decisions - // remain available through routing evidence/telemetry without adding a - // synthetic chat message for an unchanged target. - if (isUserVisibleRoutingPreflightReceipt(receipt)) { - const notice = await routingPreflightNotice( - deps, - options, - routingDispatchPreflightDecision, - catId, - threadId, - index < targetCats.length, - ); - if (notice) yield notice; - } - if (receipt.target.disposition === 'rejected') { - if (index < targetCats.length) { - const { automaticRetryAt } = receipt.target; - options.onRoutingDispatchRejected?.({ - catId, - ...(automaticRetryAt !== undefined ? { automaticRetryAt } : {}), - }); - } - yield { - type: 'error', - catId, - errorCode: 'routing_preflight_rejected', - error: '本次未执行:成员当前不可用。恢复后可重试原消息。', - timestamp: Date.now(), - }; - index++; - continue; - } - } + // F148 OQ-2: briefing→invocation link + context eval - checkpointPreparation('routing_preflight'); let briefingMessageId: string | undefined; let briefingCoverageMap: import('./context-transport.js').CoverageMap | undefined; const currentPushRecallPresentations: PushRecallPresentation[] = []; @@ -1935,8 +1886,6 @@ export async function* routeSerial( onRemoteExecutionDispatched: remoteCancellation.onDispatched, ...(options.liveCompanion && index === 0 ? { liveCompanion: options.liveCompanion } : {}), ...(options.routeIntent ? { routeIntent: options.routeIntent } : {}), - ...(options.routingContextIntent ? { routingContextIntent: options.routingContextIntent } : {}), - ...(routingDispatchPreflightDecision ? { routingDispatchPreflightDecision } : {}), catId, service, capacitySnapshot, @@ -2578,38 +2527,6 @@ export async function* routeSerial( hasLocalCoCreatorLineStartMention: boolean; streamEvents: AgentMessage[]; }> => { - let remedialRoutingPreflightNotice: AgentMessage | undefined; - let remedialRoutingDispatchPreflightDecision: RoutingPreflightDecisionV1 | undefined; - if (deps.routingDispatchPreflight) { - remedialRoutingDispatchPreflightDecision = await preflightRoutingDispatch(deps.routingDispatchPreflight, { - ownerId: userId, - targetCatIds: [catId], - ...(options.routingContextIntent ? { intent: options.routingContextIntent } : {}), - }); - const receipt = routingDispatchPreflightReceipt(remedialRoutingDispatchPreflightDecision, catId); - if (isUserVisibleRoutingPreflightReceipt(receipt)) { - remedialRoutingPreflightNotice = await routingPreflightNotice( - deps, - options, - remedialRoutingDispatchPreflightDecision, - catId, - threadId, - false, - ); - } - if (receipt.target.disposition === 'rejected') { - stopGateRemedialAttempted = true; - return { - storedContent: originalStoredContentBeforeRemedial, - routingContent: '', - allRichBlocks: originalRichBlocksBeforeRemedial, - a2aMentions: [], - hasCoCreatorLineStartMention: false, - hasLocalCoCreatorLineStartMention: false, - streamEvents: remedialRoutingPreflightNotice ? [remedialRoutingPreflightNotice] : [], - }; - } - } stopGateRemedialAttempted = true; const originalVisibleInvocationIdBeforeRemedial = ownInvocationId; const originalDeferredVoiceInvocationIdBeforeRemedial = deferredVoiceInvocationId; @@ -2640,9 +2557,7 @@ export async function* routeSerial( callbackPosts.reset(); ownInvocationId = undefined; - const remedialStreamEvents: AgentMessage[] = remedialRoutingPreflightNotice - ? [remedialRoutingPreflightNotice] - : []; + const remedialStreamEvents: AgentMessage[] = []; const remedialStripper = createLeakedToolCallStreamStripper(); const remedialService = getService(deps.services, catId, deps.unavailableServices); const resolvedRemedialCapacitySnapshot = await resolveInvocationCapacitySnapshot({ @@ -2705,10 +2620,6 @@ export async function* routeSerial( for await (const remedialMsg of invokeSingleCat(deps.invocationDeps, { onRemoteExecutionDispatched: remoteCancellation.onDispatched, ...(options.routeIntent ? { routeIntent: options.routeIntent } : {}), - ...(options.routingContextIntent ? { routingContextIntent: options.routingContextIntent } : {}), - ...(remedialRoutingDispatchPreflightDecision - ? { routingDispatchPreflightDecision: remedialRoutingDispatchPreflightDecision } - : {}), catId, service: remedialService, capacitySnapshot: remedialCapacitySnapshot, diff --git a/packages/api/src/domains/cats/services/agents/routing/routing-owner-attempt.ts b/packages/api/src/domains/cats/services/agents/routing/routing-owner-attempt.ts deleted file mode 100644 index acb77a5777..0000000000 --- a/packages/api/src/domains/cats/services/agents/routing/routing-owner-attempt.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { RouteOptions } from './route-helpers.js'; - -/** Only authenticated human ingress can choose a real attempt despite automatic supply advice. */ -export function isRoutingOwnerAttempt(options: RouteOptions): boolean { - if (options.ownerAuthProvenance !== 'strict' || options.a2aTriggerMessageId || options.a2aCallerCatId) return false; - return ( - options.humanDispositionInvocationOrigin === 'direct_owner' || - (options.humanDispositionInvocationOrigin === 'queue_replay' && options.routingQueueSource === 'user') - ); -} diff --git a/packages/api/src/domains/cats/services/agents/routing/routing-preflight-notice.ts b/packages/api/src/domains/cats/services/agents/routing/routing-preflight-notice.ts deleted file mode 100644 index 7b4bb202d8..0000000000 --- a/packages/api/src/domains/cats/services/agents/routing/routing-preflight-notice.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { CatId, RoutingPreflightDecisionV1 } from '@cat-cafe/shared'; -import { routingDispatchPreflightReceipt } from '../../../../routing-context/RoutingDispatchPreflightPort.js'; -import type { AgentMessage } from '../../types.js'; -import type { RouteOptions, RouteStrategyDeps } from './route-helpers.js'; - -/** Persist the same receipt that live clients consume; its source ref is not retry authorization. */ -export async function routingPreflightNotice( - deps: RouteStrategyDeps, - options: RouteOptions, - decision: RoutingPreflightDecisionV1, - catId: CatId, - threadId: string, - originalTarget: boolean, -): Promise { - const receipt = routingDispatchPreflightReceipt(decision, catId); - if (receipt.target.disposition === 'allowed') return undefined; - if (options.beforeOutputCommit && !(await options.beforeOutputCommit(catId))) { - if (options.persistenceContext) options.persistenceContext.actionOutputCommitRejected = true; - return undefined; - } - const payload = { - ...receipt, - ...(options.currentUserMessageId ? { sourceMessageId: options.currentUserMessageId } : {}), - ...(originalTarget && !options.routingQueueSource && options.parentInvocationId - ? { retryInvocationId: options.parentInvocationId } - : {}), - }; - const content = JSON.stringify(payload); - const timestamp = Date.now(); - let messageId: string | undefined; - try { - const stored = await deps.messageStore.append({ - userId: 'system', - from: { kind: 'system', service: 'routing-preflight' }, - threadId, - timestamp, - content, - mentions: [], - origin: 'stream', - extra: { systemInfo: { v: 1, payload, fallbackCatId: catId } }, - }); - messageId = stored.id; - } catch (error) { - if (options.persistenceContext) { - options.persistenceContext.failed = true; - options.persistenceContext.errors.push({ - catId, - error: error instanceof Error ? error.message : 'routing receipt persistence failed', - }); - } - } - return { type: 'system_info', catId, content, timestamp, ...(messageId ? { messageId } : {}) }; -} diff --git a/packages/api/src/domains/routing-context/RoutingDispatchSignalContract.ts b/packages/api/src/domains/routing-context/RoutingDispatchSignalContract.ts index f53cdf81e2..54cbbfac90 100644 --- a/packages/api/src/domains/routing-context/RoutingDispatchSignalContract.ts +++ b/packages/api/src/domains/routing-context/RoutingDispatchSignalContract.ts @@ -2,11 +2,7 @@ import { type RoutingPreflightDecisionV1, routingPreflightDecisionV1Schema } fro import { z } from 'zod'; import type { RoutingSignalEventAppendResult } from './RoutingSignalEventStore.js'; -export type RoutingDispatchFailureClass = - | 'quota_exhausted' - | 'authentication_rejected' - | 'provider_unreachable' - | 'provider_timeout'; +export type RoutingDispatchFailureClass = 'quota_exhausted' | 'authentication_rejected' | 'provider_unreachable'; export interface RoutingDispatchFailureEvidence { cliReasonCode?: string; @@ -34,12 +30,7 @@ export interface RoutingDispatchTerminalObserver { const boundedId = z.string().trim().min(1).max(200); const ownerId = z.string().trim().min(1).max(120); const boundedRef = z.string().trim().min(1).max(500); -const failureClassSchema = z.enum([ - 'quota_exhausted', - 'authentication_rejected', - 'provider_unreachable', - 'provider_timeout', -]); +const failureClassSchema = z.enum(['quota_exhausted', 'authentication_rejected', 'provider_unreachable']); export const routingDispatchTerminalEvidenceSchema = z .object({ @@ -101,8 +92,6 @@ const CLI_FAILURE_CLASSES: Readonly> quota_exceeded: 'quota_exhausted', auth_failed: 'authentication_rejected', network_error: 'provider_unreachable', - cli_response_timeout: 'provider_timeout', - cli_stall_timeout: 'provider_timeout', }; const PROVIDER_FAILURE_CLASSES: Readonly> = { @@ -113,16 +102,6 @@ const PROVIDER_FAILURE_CLASSES: Readonly> = { - // F117 KD-22: a member stopped by its output timeout. - timeout: 'provider_timeout', - // Written by the outer invocation timer before KD-22; still read from existing records. - invocation_timeout: 'provider_timeout', }; export function classifyRoutingDispatchFailure( @@ -136,6 +115,7 @@ export function classifyRoutingDispatchFailure( const classified = PROVIDER_FAILURE_CLASSES[evidence.providerErrorCode]; if (classified) return classified; } - if (evidence.terminalReason) return TERMINAL_FAILURE_CLASSES[evidence.terminalReason]; + // Execution/output deadlines describe this invocation, not provider or member + // availability. Its durable failed response and predecessor return own recovery. return undefined; } diff --git a/packages/api/src/domains/routing-context/RoutingSignalObservation.ts b/packages/api/src/domains/routing-context/RoutingSignalObservation.ts index b1249b4ef0..8ad1a16304 100644 --- a/packages/api/src/domains/routing-context/RoutingSignalObservation.ts +++ b/packages/api/src/domains/routing-context/RoutingSignalObservation.ts @@ -88,9 +88,7 @@ const dispatchTerminalObservationV1Schema = z kind: z.literal('dispatch_terminal'), catId: ownerIdSchema, status: z.enum(['succeeded', 'failed', 'canceled', 'interrupted']), - failureClass: z - .enum(['quota_exhausted', 'authentication_rejected', 'provider_unreachable', 'provider_timeout']) - .optional(), + failureClass: z.enum(['quota_exhausted', 'authentication_rejected', 'provider_unreachable']).optional(), failureObservedAt: epochMsSchema.optional(), preflightDecision: routingPreflightDecisionV1Schema, }) diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 743d2a9406..1e72037d4a 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -385,10 +385,8 @@ import { avatarsRoutes } from './routes/avatars.js'; import { appendA2ASourceWithLedgerAdmission, commitRecoveredFailedResponse, - emitA2ARoutingPreflightReceipts, enqueueA2ATargets, planA2AFanoutAdmission, - preflightA2ATargets, } from './routes/callback-a2a-trigger.js'; import { CallbackAuthSystemMessageNotifier } from './routes/callback-auth-system-message.js'; import { @@ -2719,9 +2717,6 @@ async function main(): Promise { hookAuthenticationReady: sessionHookAuthenticationReady, claudeCompactionHooks, presentationLedger, - ...(routingContextRuntime ? { routingContextPromptProjection: routingContextRuntime.promptProjection } : {}), - ...(routingContextRuntime ? { routingDispatchPreflight: routingContextRuntime.dispatchPreflight } : {}), - ...(routingContextRuntime ? { routingDispatchSignalObserver: routingContextRuntime.dispatchSignalAdapter } : {}), runtimeSessionStore, transcriptWriter, transcriptReader, @@ -2870,19 +2865,10 @@ async function main(): Promise { : []; const existingTargets = new Set(existingEntries.flatMap((entry) => entry.targets)); const freshTargetCatIds = targetCatIds.filter((catId) => !existingTargets.has(catId)); - const routingPreflight = await preflightA2ATargets( - routingContextRuntime ? { routingDispatchPreflight: routingContextRuntime.dispatchPreflight } : {}, - { - targetCats: freshTargetCatIds, - content: proposal.content, - userId: proposal.ownerUserId, - }, - ); - const routingPreflightRejected = routingPreflight.acceptedTargetCats.length !== freshTargetCatIds.length; const planned = planA2AFanoutAdmission( { invocationQueue }, { - targetCats: routingPreflightRejected ? [] : routingPreflight.acceptedTargetCats, + targetCats: freshTargetCatIds, requestedTargetCats: targetCatIds, content: proposal.content, userId: proposal.ownerUserId, @@ -2897,9 +2883,7 @@ async function main(): Promise { const acceptedFresh = new Set(planned.acceptedTargetCats); const admissionPlan = { requestedTargetCats: targetCatIds, - acceptedTargetCats: targetCatIds.filter( - (catId) => existingTargets.has(catId) || (!routingPreflightRejected && acceptedFresh.has(catId)), - ), + acceptedTargetCats: targetCatIds.filter((catId) => existingTargets.has(catId) || acceptedFresh.has(catId)), streakTargetCats: planned.streakTargetCats, ...(planned.stop ? { stop: planned.stop } : {}), }; @@ -2917,14 +2901,6 @@ async function main(): Promise { const persisted = await classifyPersistedCarrier(storedMsg); const persistedState = persisted.state; if (persistedState.outcome === 'conflict') { - emitA2ARoutingPreflightReceipts( - { socketManager: actionSocketManager }, - { - decision: routingPreflight.decision, - receiptCatId: senderCatId, - threadId: proposal.targetThreadId, - }, - ); return { outcome: 'terminal_failure', reason: persistedState.reason, @@ -2947,7 +2923,6 @@ async function main(): Promise { ...(deliveryCursorStore ? { deliveryCursorStore } : {}), queueProcessor, invocationQueue, - ...(routingContextRuntime ? { routingDispatchPreflight: routingContextRuntime.dispatchPreflight } : {}), log: app.log, }, { @@ -2960,7 +2935,6 @@ async function main(): Promise { callerCatId: senderCatId, actionSuccessorFence: fence, preplannedAdmission: admissionPlan, - ...(routingPreflight.decision ? { routingPreflightDecision: routingPreflight.decision } : {}), ...(atomicAdmission.preAdmittedEntries ? { preAdmittedEntries: atomicAdmission.preAdmittedEntries, @@ -3131,7 +3105,6 @@ async function main(): Promise { messageStore, invocationQueue, log: app.log, - ...(routingContextRuntime ? { routingDispatchPreflight: routingContextRuntime.dispatchPreflight } : {}), }, response, patch, @@ -5153,7 +5126,6 @@ async function main(): Promise { profileRepository, agentRegistry, router, - ...(routingContextRuntime ? { routingDispatchPreflight: routingContextRuntime.dispatchPreflight } : {}), invocationRecordStore, turnExecutionStore, invocationTracker, @@ -6318,7 +6290,6 @@ async function main(): Promise { ...(deliveryCursorStore ? { deliveryCursorStore } : {}), ...(queueProcessor ? { queueProcessor } : {}), ...(invocationQueue ? { invocationQueue } : {}), - ...(routingContextRuntime ? { routingDispatchPreflight: routingContextRuntime.dispatchPreflight } : {}), log: app.log, }, { @@ -8496,19 +8467,9 @@ async function main(): Promise { ...(deliveryCursorStore ? { deliveryCursorStore } : {}), queueProcessor, invocationQueue, - ...(routingContextRuntime ? { routingDispatchPreflight: routingContextRuntime.dispatchPreflight } : {}), log: app.log, }; - // Routing and fan-out are decided before anything is written. A carrier exists only to - // carry work, so a refused owner must leave no queued Message behind to be re-found. - const routingPreflight = await preflightA2ATargets(a2aDeps, { - targetCats: [request.targetCatId], - content: request.message.content, - userId: request.userId, - }); - if (!routingPreflight.acceptedTargetCats.includes(request.targetCatId)) { - return { outcome: 'not_admitted' }; - } + // Exact fan-out and action provenance are decided before atomic admission. const plan = planA2AFanoutAdmission( { invocationQueue }, { @@ -8549,7 +8510,6 @@ async function main(): Promise { callerCatId: request.callerCatId, actionSuccessorFence: request.actionSuccessorFence, preplannedAdmission: plan, - ...(routingPreflight.decision ? { routingPreflightDecision: routingPreflight.decision } : {}), ...(admission.preAdmittedEntries ? { preAdmittedEntries: admission.preAdmittedEntries, diff --git a/packages/api/src/routes/callback-a2a-trigger.ts b/packages/api/src/routes/callback-a2a-trigger.ts index ef79a12ed0..8f1a75fc59 100644 --- a/packages/api/src/routes/callback-a2a-trigger.ts +++ b/packages/api/src/routes/callback-a2a-trigger.ts @@ -5,7 +5,7 @@ * message. There is no direct routeExecution fallback. */ -import type { CatId, RoutingPreflightDecisionV1 } from '@cat-cafe/shared'; +import type { CatId } from '@cat-cafe/shared'; import type { ActionSuccessorFence } from '../domains/ball-custody/ActionSuccessorAdmissionService.js'; import type { InvocationQueue, QueueEntry } from '../domains/cats/services/agents/invocation/InvocationQueue.js'; import { @@ -37,13 +37,6 @@ import { lifecycleResponseTerminalPatchFromAppendInput, settleLifecycleResponseInputs, } from '../domains/cats/services/stores/ports/MessageStore.js'; -import { - inferRoutingContextIntent, - isUserVisibleRoutingPreflightReceipt, - preflightRoutingDispatch, - type RoutingDispatchPreflightPort, - routingDispatchPreflightReceipt, -} from '../domains/routing-context/RoutingDispatchPreflightPort.js'; import { wrapWithDispatchSpan } from '../infrastructure/telemetry/dispatch-span.js'; import type { CallerTraceContext } from '../infrastructure/telemetry/genai-semconv.js'; import { emitQueueUpdated } from '../utils/queue-enrichment.js'; @@ -82,8 +75,6 @@ export interface A2ATriggerDeps { queueProcessor?: QueueProcessorLike; /** #706: MessageStore for queue enrichment (messagePreview in queue_updated SSE). */ messageStore?: IMessageStore; - /** F293: fresh per-target decision before worklist, queue custody or fallback creation. */ - routingDispatchPreflight?: RoutingDispatchPreflightPort; /** F122B: InvocationQueue for agent-sourced entries. * Same-turn handoffs remain independent scalar ledger rows. */ invocationQueue?: Pick< @@ -162,7 +153,7 @@ export async function appendA2ASourceWithLedgerAdmission( interface A2AFanoutAdmissionOptions { targetCats: readonly CatId[]; - /** Original requested set when routing preflight removed rejected targets. */ + /** Original requested set, including already dispatched targets on a replay. */ requestedTargetCats?: readonly CatId[]; content: string; userId: string; @@ -227,62 +218,6 @@ export function planA2AFanoutAdmission( }; } -export interface A2ARoutingPreflightPartition { - requestedTargetCats: readonly CatId[]; - acceptedTargetCats: readonly CatId[]; - decision?: RoutingPreflightDecisionV1; -} - -/** - * Resolve the fresh routing partition before any durable Queue admission. - * Warned targets remain eligible; only explicit rejections are removed. - */ -export async function preflightA2ATargets( - deps: Pick, - opts: { targetCats: readonly CatId[]; content: string; userId: string }, -): Promise { - const requestedTargetCats = [...opts.targetCats]; - if (!deps.routingDispatchPreflight) { - return { requestedTargetCats, acceptedTargetCats: requestedTargetCats }; - } - const intent = inferRoutingContextIntent(opts.content); - const decision = await preflightRoutingDispatch(deps.routingDispatchPreflight, { - ownerId: opts.userId, - targetCatIds: requestedTargetCats, - ...(intent ? { intent } : {}), - }); - return { - requestedTargetCats, - acceptedTargetCats: requestedTargetCats.filter( - (catId) => decision.targets.find((target) => target.targetCatId === catId)?.disposition !== 'rejected', - ), - decision, - }; -} - -export function emitA2ARoutingPreflightReceipts( - deps: Pick, - input: { decision?: RoutingPreflightDecisionV1; receiptCatId: CatId; threadId: string }, -): void { - if (!input.decision) return; - for (const target of input.decision.targets) { - // A warned decision is fail-open infrastructure telemetry: the requested - // target is unchanged, so surfacing it as chat content only duplicates the - // per-send preflight and makes a healthy delivery look user-actionable. - const receipt = routingDispatchPreflightReceipt(input.decision, target.targetCatId); - if (!isUserVisibleRoutingPreflightReceipt(receipt)) continue; - deps.socketManager.broadcastAgentMessage( - { - type: 'system_info', - catId: input.receiptCatId, - content: JSON.stringify(receipt), - timestamp: Date.now(), - }, - input.threadId, - ); - } -} - export async function commitCompletedResponseAndEnqueueA2ATargets( deps: A2ATriggerDeps, opts: { @@ -307,14 +242,10 @@ export async function commitCompletedResponseAndEnqueueA2ATargets( const durableLineage = causalTriggerMessageId ? await readDurableA2ALineage(deps.messageStore, causalTriggerMessageId, opts.callerCatId) : undefined; - const routingPreflight = await preflightA2ATargets(deps, { - targetCats: opts.targetCats, - content: opts.message.content, - userId: opts.userId, - }); const admissionOptions: A2AFanoutAdmissionOptions = { - targetCats: routingPreflight.acceptedTargetCats, - requestedTargetCats: routingPreflight.requestedTargetCats, + // The completed source and its exact recipient wake commit atomically. + targetCats: opts.targetCats, + requestedTargetCats: opts.targetCats, content: opts.message.content, userId: opts.userId, ownerAuthProvenance: normalizeOwnerAuthProvenance(opts.ownerAuthProvenance), @@ -380,11 +311,6 @@ export async function commitCompletedResponseAndEnqueueA2ATargets( } if (plan.acceptedTargetCats.length === 0) { - emitA2ARoutingPreflightReceipts(deps, { - decision: routingPreflight.decision, - receiptCatId: opts.callerCatId, - threadId: opts.threadId, - }); if (plan.stop?.reason === 'depth') { deps.log.warn( { @@ -432,7 +358,6 @@ export async function commitCompletedResponseAndEnqueueA2ATargets( ...(opts.parentInvocationId ? { parentInvocationId: opts.parentInvocationId } : {}), ...(opts.callerTraceContext ? { callerTraceContext: opts.callerTraceContext } : {}), preplannedAdmission: plan, - ...(routingPreflight.decision ? { routingPreflightDecision: routingPreflight.decision } : {}), ...(preAdmittedEntries ? { preAdmittedEntries, preAdmittedReplayed } : {}), }); return (await deps.messageStore.getById(stored.id)) ?? stored; @@ -481,19 +406,6 @@ async function commitFailedResponsePatchAndEnqueueA2ACaller( }, ): Promise { if (!deps.messageStore) throw new Error('failed response A2A report requires MessageStore'); - const routingPreflight = await preflightA2ATargets(deps, { - targetCats: [opts.predecessorCatId], - content: opts.terminalPatch.content, - userId: opts.userId, - }); - if (!routingPreflight.acceptedTargetCats.includes(opts.predecessorCatId)) { - emitA2ARoutingPreflightReceipts(deps, { - decision: routingPreflight.decision, - receiptCatId: opts.reporterCatId, - threadId: opts.threadId, - }); - return commitLifecycleResponseFromTerminalPatch(deps.messageStore, opts.responseMessageId, opts.terminalPatch); - } if (!deps.invocationQueue) throw new Error('failed response A2A report requires InvocationQueue'); const admission = await deps.invocationQueue.terminalizeResponseAndEnqueueDurable( @@ -539,7 +451,6 @@ async function commitFailedResponsePatchAndEnqueueA2ACaller( acceptedTargetCats: [opts.predecessorCatId], streakTargetCats: [], }, - ...(routingPreflight.decision ? { routingPreflightDecision: routingPreflight.decision } : {}), preAdmittedEntries: admission.entries, preAdmittedReplayed: admission.deduped, }); @@ -637,8 +548,6 @@ export async function enqueueA2ATargets( requiresExactCloudDispatchProvenance?: boolean; /** Exact policy plan already persisted with a newly appended source message. */ preplannedAdmission?: A2AFanoutAdmissionPlan; - /** Fresh routing decision already obtained before atomic source/ledger admission. */ - routingPreflightDecision?: RoutingPreflightDecisionV1; /** * Register consumer-specific completion observers after canonical Queue custody is durable * and before any accepted carrier can start. Multi-mention uses this to aggregate sibling @@ -649,7 +558,7 @@ export async function enqueueA2ATargets( preAdmittedEntries?: readonly QueueEntry[]; preAdmittedReplayed?: boolean; }, -): Promise<{ enqueued: CatId[]; coalesced?: CatId[]; routingPreflight?: RoutingPreflightDecisionV1 }> { +): Promise<{ enqueued: CatId[]; coalesced?: CatId[] }> { if (!deps.invocationQueue || !deps.queueProcessor?.requestDrain) { throw new Error('A2A dispatch requires InvocationQueue and QueueProcessor'); } @@ -682,34 +591,14 @@ export async function enqueueA2ATargets( throw new Error('A2A Queue dispatch caller does not match persisted MessageFrom'); } const requestedTargetCats = opts.targetCats; - const routingPreflight = opts.routingPreflightDecision - ? { - requestedTargetCats, - acceptedTargetCats: requestedTargetCats.filter( - (catId) => - opts.routingPreflightDecision?.targets.find((target) => target.targetCatId === catId)?.disposition !== - 'rejected', - ), - decision: opts.routingPreflightDecision, - } - : await preflightA2ATargets(deps, { - targetCats: requestedTargetCats, - content: opts.content, - userId: opts.userId, - }); - emitA2ARoutingPreflightReceipts(deps, { - decision: routingPreflight.decision, - receiptCatId: fromCatId, - threadId, - }); const dispatchedTargetCats = new Set( persistedQueueTrigger.lifecycle?.dispatchRefs?.map((dispatch) => dispatch.targetId) ?? [], ); - const targetCats = routingPreflight.acceptedTargetCats.filter((catId) => !dispatchedTargetCats.has(catId)); + // Exact source targets stay pending until handed off or explicitly canceled. + const targetCats = opts.targetCats.filter((catId) => !dispatchedTargetCats.has(catId)); if (targetCats.length === 0) { return { enqueued: [], - ...(routingPreflight.decision ? { routingPreflight: routingPreflight.decision } : {}), }; } @@ -775,7 +664,7 @@ export async function enqueueA2ATargets( entry.targets.some((targetId) => !plan.acceptedTargetCats.includes(targetId as CatId)), ) ) { - throw new Error('A2A routing preflight must run before atomic ledger admission'); + throw new Error('A2A ledger admission contains a target outside the fan-out plan'); } if (plan.stop?.reason === 'depth') { @@ -908,6 +797,5 @@ export async function enqueueA2ATargets( return { enqueued, ...(coalesced.length > 0 ? { coalesced } : {}), - ...(routingPreflight.decision ? { routingPreflight: routingPreflight.decision } : {}), }; } diff --git a/packages/api/src/routes/callback-multi-mention-routes.ts b/packages/api/src/routes/callback-multi-mention-routes.ts index 617ea2c66b..57c9fda5a7 100644 --- a/packages/api/src/routes/callback-multi-mention-routes.ts +++ b/packages/api/src/routes/callback-multi-mention-routes.ts @@ -65,7 +65,6 @@ import { type A2ATriggerDeps, appendA2ASourceWithLedgerAdmission, enqueueA2ATargets, - preflightA2ATargets, } from './callback-a2a-trigger.js'; import { requireCallbackAuth } from './callback-auth-prehandler.js'; import { resolveCallbackActionLeaseRef } from './callback-scope-helpers.js'; @@ -239,7 +238,6 @@ export interface MultiMentionRouteDeps { 'admit' | 'markUnavailable' | 'markReturnedDelivered' >; /** F293: fresh routing policy at the actual Queue admission boundary. */ - routingDispatchPreflight?: A2ATriggerDeps['routingDispatchPreflight']; /** F122B B6: InvocationQueue for unified dispatch */ invocationQueue?: A2ATriggerDeps['invocationQueue'] & Pick; @@ -412,17 +410,13 @@ async function dispatchViaQueue( '\n\n', ); - // Resolve routing before publishing the source so Message + pending Queue targets - // are committed as one fact. The lifecycle response that invoked this tool remains + // Commit the source and its exact Queue targets as one fact. + // The lifecycle response that invoked this tool remains // the causal parent; it must not impersonate a different synthetic Queue body. - const routingPreflight = await preflightA2ATargets( - deps.routingDispatchPreflight ? { routingDispatchPreflight: deps.routingDispatchPreflight } : {}, - { targetCats: targetCatIds, content: messageContent, userId }, - ); const preplannedAdmission = planMultiMentionFanout({ invocationQueue, - targetCatIds: routingPreflight.acceptedTargetCats, - requestedTargetCatIds: routingPreflight.requestedTargetCats, + targetCatIds: targetCatIds, + requestedTargetCatIds: targetCatIds, threadId, actionFence, }); @@ -461,7 +455,6 @@ async function dispatchViaQueue( queueProcessor, messageStore: deps.messageStore, invocationQueue, - ...(deps.routingDispatchPreflight ? { routingDispatchPreflight: deps.routingDispatchPreflight } : {}), log, }, { @@ -474,7 +467,6 @@ async function dispatchViaQueue( callerCatId: initiator, parentInvocationId, preplannedAdmission, - ...(routingPreflight.decision ? { routingPreflightDecision: routingPreflight.decision } : {}), ...(atomicAdmission.preAdmittedEntries ? { preAdmittedEntries: atomicAdmission.preAdmittedEntries, diff --git a/packages/api/src/routes/callbacks.ts b/packages/api/src/routes/callbacks.ts index 67f7fe0f93..c177fd68d7 100644 --- a/packages/api/src/routes/callbacks.ts +++ b/packages/api/src/routes/callbacks.ts @@ -17,7 +17,6 @@ import type { LocalReviewVerdict, PrAutomationState, RichBlock, - RoutingPreflightDecisionV1, SuggestedCrossPostAction, } from '@cat-cafe/shared'; import { @@ -178,7 +177,6 @@ import { appendA2ASourceWithLedgerAdmission, enqueueA2ATargets, planA2AFanoutAdmission, - preflightA2ATargets, } from './callback-a2a-trigger.js'; import { actionCarrierRecoveryKey, @@ -1031,7 +1029,6 @@ export interface CallbackRoutesOptions { /** For post_message @mention → invocation triggering */ router?: AgentRouter; /** F293: shared per-target decision before callback dispatch admission. */ - routingDispatchPreflight?: import('../domains/routing-context/RoutingDispatchPreflightPort.js').RoutingDispatchPreflightPort; invocationRecordStore?: IInvocationRecordStore; /** Durable child lifecycle truth. InvocationRegistry remains callback auth only. */ turnExecutionStore?: Pick; @@ -1982,7 +1979,6 @@ export const callbacksRoutes: FastifyPluginAsync = async ...(deliveryCursorStore ? { deliveryCursorStore } : {}), ...(queueProcessor ? { queueProcessor } : {}), ...(opts.invocationQueue ? { invocationQueue: opts.invocationQueue } : {}), - ...(opts.routingDispatchPreflight ? { routingDispatchPreflight: opts.routingDispatchPreflight } : {}), log: app.log, }, { @@ -2045,7 +2041,7 @@ export const callbacksRoutes: FastifyPluginAsync = async } let a2aAdmissionPlan: A2AFanoutAdmissionPlan | undefined; - let a2aRoutingPreflightDecision: RoutingPreflightDecisionV1 | undefined; + const a2aAdmissionOptions = hasA2AMentions ? { targetCats: mentions, @@ -2065,17 +2061,13 @@ export const callbacksRoutes: FastifyPluginAsync = async message: 'Recipient wake admission is unavailable; no message was published.', }; } - const routingPreflight = await preflightA2ATargets( - opts.routingDispatchPreflight ? { routingDispatchPreflight: opts.routingDispatchPreflight } : {}, - { targetCats: mentions, content: storedContent, userId: principal.userId }, - ); - a2aRoutingPreflightDecision = routingPreflight.decision; + a2aAdmissionPlan = planA2AFanoutAdmission( { invocationQueue: opts.invocationQueue }, { ...a2aAdmissionOptions, - targetCats: routingPreflight.acceptedTargetCats, - requestedTargetCats: routingPreflight.requestedTargetCats, + targetCats: mentions, + requestedTargetCats: mentions, }, ); } @@ -2170,7 +2162,6 @@ export const callbacksRoutes: FastifyPluginAsync = async ...(deliveryCursorStore ? { deliveryCursorStore } : {}), ...(queueProcessor ? { queueProcessor } : {}), ...(opts.invocationQueue ? { invocationQueue: opts.invocationQueue } : {}), - ...(opts.routingDispatchPreflight ? { routingDispatchPreflight: opts.routingDispatchPreflight } : {}), log: app.log, }, { @@ -2182,7 +2173,6 @@ export const callbacksRoutes: FastifyPluginAsync = async triggerMessage: storedMsg, callerCatId: senderCatId, ...(a2aAdmissionPlan ? { preplannedAdmission: a2aAdmissionPlan } : {}), - ...(a2aRoutingPreflightDecision ? { routingPreflightDecision: a2aRoutingPreflightDecision } : {}), ...(atomicAdmission.preAdmittedEntries ? { preAdmittedEntries: atomicAdmission.preAdmittedEntries, @@ -3497,7 +3487,6 @@ export const callbacksRoutes: FastifyPluginAsync = async ...(deliveryCursorStore ? { deliveryCursorStore } : {}), ...(queueProcessor ? { queueProcessor } : {}), ...(opts.invocationQueue ? { invocationQueue: opts.invocationQueue } : {}), - ...(opts.routingDispatchPreflight ? { routingDispatchPreflight: opts.routingDispatchPreflight } : {}), log: app.log, }, { @@ -3581,7 +3570,7 @@ export const callbacksRoutes: FastifyPluginAsync = async }; if (duplicateMsg) return recoverPersistedCallbackMessage(duplicateMsg); let a2aAdmissionPlan: A2AFanoutAdmissionPlan | undefined; - let a2aRoutingPreflightDecision: RoutingPreflightDecisionV1 | undefined; + const a2aAdmissionOptions = hasA2AMentions ? { targetCats: mentions, @@ -3611,17 +3600,13 @@ export const callbacksRoutes: FastifyPluginAsync = async message: 'Recipient wake admission is unavailable; no message was published.', }; } - const routingPreflight = await preflightA2ATargets( - opts.routingDispatchPreflight ? { routingDispatchPreflight: opts.routingDispatchPreflight } : {}, - { targetCats: mentions, content: storedContent, userId: actor.userId }, - ); - a2aRoutingPreflightDecision = routingPreflight.decision; + a2aAdmissionPlan = planA2AFanoutAdmission( { invocationQueue: opts.invocationQueue }, { ...a2aAdmissionOptions, - targetCats: routingPreflight.acceptedTargetCats, - requestedTargetCats: routingPreflight.requestedTargetCats, + targetCats: mentions, + requestedTargetCats: mentions, }, ); } @@ -3749,7 +3734,6 @@ export const callbacksRoutes: FastifyPluginAsync = async ...(deliveryCursorStore ? { deliveryCursorStore } : {}), ...(queueProcessor ? { queueProcessor } : {}), ...(opts.invocationQueue ? { invocationQueue: opts.invocationQueue } : {}), - ...(opts.routingDispatchPreflight ? { routingDispatchPreflight: opts.routingDispatchPreflight } : {}), log: app.log, }, { @@ -3764,7 +3748,6 @@ export const callbacksRoutes: FastifyPluginAsync = async callerTraceContext: record.traceContext, ...(actionFence ? { actionSuccessorFence: actionFence } : {}), ...(a2aAdmissionPlan ? { preplannedAdmission: a2aAdmissionPlan } : {}), - ...(a2aRoutingPreflightDecision ? { routingPreflightDecision: a2aRoutingPreflightDecision } : {}), ...(atomicAdmission.preAdmittedEntries ? { preAdmittedEntries: atomicAdmission.preAdmittedEntries, @@ -6397,29 +6380,22 @@ export const callbacksRoutes: FastifyPluginAsync = async const canDispatchVote = Boolean( router && invocationRecordStore && opts.invocationQueue && queueProcessor?.requestDrain, ); - const voteRoutingPreflight = canDispatchVote - ? await preflightA2ATargets( - opts.routingDispatchPreflight ? { routingDispatchPreflight: opts.routingDispatchPreflight } : {}, - { targetCats: mentionCatIds, content: notificationContent, userId: record.userId }, + const voteAdmissionPlan = canDispatchVote + ? planA2AFanoutAdmission( + { invocationQueue: opts.invocationQueue }, + { + targetCats: mentionCatIds, + requestedTargetCats: mentionCatIds, + content: notificationContent, + userId: record.userId, + ownerAuthProvenance: record.ownerAuthProvenance, + threadId: record.threadId, + createdAt: notificationTimestamp, + callerCatId: record.catId as CatId, + ...(record.parentInvocationId ? { parentInvocationId: record.parentInvocationId } : {}), + }, ) : undefined; - const voteAdmissionPlan = - canDispatchVote && voteRoutingPreflight - ? planA2AFanoutAdmission( - { invocationQueue: opts.invocationQueue }, - { - targetCats: voteRoutingPreflight.acceptedTargetCats, - requestedTargetCats: voteRoutingPreflight.requestedTargetCats, - content: notificationContent, - userId: record.userId, - ownerAuthProvenance: record.ownerAuthProvenance, - threadId: record.threadId, - createdAt: notificationTimestamp, - callerCatId: record.catId as CatId, - ...(record.parentInvocationId ? { parentInvocationId: record.parentInvocationId } : {}), - }, - ) - : undefined; let notificationMsg: Awaited> | undefined; let preAdmittedVoteEntries: readonly QueueEntry[] | undefined; let preAdmittedVoteReplayed = false; @@ -6463,7 +6439,6 @@ export const callbacksRoutes: FastifyPluginAsync = async deliveryCursorStore, queueProcessor, invocationQueue: opts.invocationQueue, - ...(opts.routingDispatchPreflight ? { routingDispatchPreflight: opts.routingDispatchPreflight } : {}), log: app.log, }; const a2aOpts = { @@ -6476,7 +6451,6 @@ export const callbacksRoutes: FastifyPluginAsync = async ownerAuthProvenance: record.ownerAuthProvenance, callerTraceContext: record.traceContext, ...(voteAdmissionPlan ? { preplannedAdmission: voteAdmissionPlan } : {}), - ...(voteRoutingPreflight?.decision ? { routingPreflightDecision: voteRoutingPreflight.decision } : {}), ...(preAdmittedVoteEntries ? { preAdmittedEntries: preAdmittedVoteEntries, @@ -6690,7 +6664,6 @@ export const callbacksRoutes: FastifyPluginAsync = async ...(opts.turnExecutionStore ? { turnExecutionStore: opts.turnExecutionStore } : {}), ...(opts.invocationQueue ? { invocationQueue: opts.invocationQueue } : {}), ...(queueProcessor ? { queueProcessor } : {}), - ...(opts.routingDispatchPreflight ? { routingDispatchPreflight: opts.routingDispatchPreflight } : {}), ...(opts.actionSuccessorAdmissionService ? { actionSuccessorAdmissionService: opts.actionSuccessorAdmissionService } : {}), diff --git a/packages/api/test/1398-failed-response-recovery.test.js b/packages/api/test/1398-failed-response-recovery.test.js index 0884d71fa2..152457c7b1 100644 --- a/packages/api/test/1398-failed-response-recovery.test.js +++ b/packages/api/test/1398-failed-response-recovery.test.js @@ -162,7 +162,6 @@ for (const options of [ { status: 'canceled' }, { status: 'interrupted' }, { status: 'succeeded' }, - { rejected: true }, { isFailureReport: true }, ]) { test('recovery does not manufacture failed-return bounce: ' + JSON.stringify(options), async () => { @@ -174,6 +173,19 @@ for (const options of [ }); } +for (const isFailureReport of [false, true]) { + test(`availability cannot suppress exact failed-return recovery or cause a bounce: ${isFailureReport}`, async () => { + const f = await fixture({ rejected: true, isFailureReport }); + await f.recovery().reconcile({ processStartedAt: 200 }); + const rows = await wakeRows(f); + assert.equal(rows.length, isFailureReport ? 0 : 1); + if (!isFailureReport) assert.deepEqual(rows[0].targets, ['codex']); + assert.equal(f.preflightCalls.length, 0, 'failure return does not consult availability'); + assert.equal(f.turns.listResponsePending().length, 0); + assert.equal(f.messages.getByThread('thread').length, 2); + }); +} + for (const mutate of [ (response) => { response.extra.a2aFailureReturn.callerCatId = 'kimi'; diff --git a/packages/api/test/1398-phase4-member-timeout-stop.test.js b/packages/api/test/1398-phase4-member-timeout-stop.test.js index 8cd6e6b0b7..af35096688 100644 --- a/packages/api/test/1398-phase4-member-timeout-stop.test.js +++ b/packages/api/test/1398-phase4-member-timeout-stop.test.js @@ -467,8 +467,8 @@ describe('F117 J4: how a stopped member ends', () => { }); }); - it('routing counts a member timeout as a provider timeout', () => { - assert.equal(classifyRoutingDispatchFailure({ terminalReason: 'timeout' }), 'provider_timeout'); + it('routing keeps a member timeout local to its failed response', () => { + assert.equal(classifyRoutingDispatchFailure({ terminalReason: 'timeout' }), undefined); }); it('keeps the session-resume retry for a CLI that never came up, not for silence after it did', () => { diff --git a/packages/api/test/a2a-1577-failure-return-seam.test.mjs b/packages/api/test/a2a-1577-failure-return-seam.test.mjs index b1b0c1446d..42748ff6c4 100644 --- a/packages/api/test/a2a-1577-failure-return-seam.test.mjs +++ b/packages/api/test/a2a-1577-failure-return-seam.test.mjs @@ -6,7 +6,7 @@ import { MessageStore } from '../src/domains/cats/services/stores/ports/MessageS import { commitFailedResponseAndEnqueueA2ACaller } from '../src/routes/callback-a2a-trigger.ts'; // Real production failed-response ingress, MessageStore transaction and Queue. -// The actual-send catalog seam is deterministic. Not Redis/provider/routeSerial. +// A legacy availability resolver is a spy only; ordinary failure return must not consult it. function fixture({ rejected = false } = {}) { const ledger = new InMemoryQueueLedgerStore(); const queue = new InvocationQueue(ledger); @@ -97,7 +97,7 @@ test('failed response and exact predecessor queue reference commit together, wit assert.equal(row.execution.a2aTriggerMessageId, f.source.id); assert.deepEqual(f.business, []); assert.equal(f.drains.length, 1); - assert.deepEqual(f.checks[0].targetCatIds, ['codex']); + assert.deepEqual(f.checks, [], 'failure return does not consult global availability'); }); test('replaying the same failed result is idempotent; altered result cannot mutate the won identity', async () => { @@ -130,13 +130,17 @@ test('failed ledger commit cannot publish a terminal response, drain or reporter assert.deepEqual(f.business, []); }); -test('fresh routing rejection commits only the failed History result, never inventing another route', async () => { +test('legacy routing rejection cannot suppress the exact failed History and predecessor wake', async () => { const f = fixture({ rejected: true }); const result = await commitFailedResponseAndEnqueueA2ACaller(f.deps, f.opts); assert.equal(result.lifecycle.status, 'failed'); - assert.deepEqual(await f.queue.listAllDurable(f.source.threadId), []); - assert.equal(f.receipts.length, 1); - assert.deepEqual(f.drains, []); + const [row] = await f.queue.listAllDurable(f.source.threadId); + assert.equal(row.sourceCategory, 'a2a_failure'); + assert.deepEqual(row.targets, ['codex']); + assert.equal(row.payload.messageId, f.source.id); + assert.equal(f.receipts.length, 0); + assert.deepEqual(f.checks, []); + assert.deepEqual(f.drains, [f.source.threadId]); assert.deepEqual(f.business, []); }); diff --git a/packages/api/test/callback-a2a-postmsg.test.js b/packages/api/test/callback-a2a-postmsg.test.js index 1df31ee007..842f192309 100644 --- a/packages/api/test/callback-a2a-postmsg.test.js +++ b/packages/api/test/callback-a2a-postmsg.test.js @@ -612,7 +612,7 @@ describe('F052: cross-thread A2A mention routing', () => { queueProcessor = { async requestDrain() {} }; }); - async function createAppWithThreadStore() { + async function createAppWithThreadStore(opts = {}) { const { callbacksRoutes } = await import('../dist/routes/callbacks.js'); const app = Fastify(); await app.register(callbacksRoutes, { @@ -624,6 +624,7 @@ describe('F052: cross-thread A2A mention routing', () => { invocationRecordStore, invocationQueue, queueProcessor, + ...opts, }); return app; } @@ -652,6 +653,64 @@ describe('F052: cross-thread A2A mention routing', () => { assert.ok(crossMsg.mentions.includes('codex'), 'cross-thread @codex should be in mentions'); }); + test('cross-thread same-member source enters durable Queue without consulting availability', async () => { + const retryAt = Date.now() + 300_000; + let availabilityChecks = 0; + const app = await createAppWithThreadStore({ + routingDispatchPreflight: { + async preflight(input) { + availabilityChecks++; + return { + v: 1, + ownerId: input.ownerId, + observedAt: Date.now(), + resolverState: 'fresh', + targets: input.targetCatIds.map((targetCatId) => ({ + targetCatId, + disposition: 'rejected', + automaticRetryAt: retryAt, + reasons: [ + { code: 'provider_timeout', summary: 'another turn timed out', sourceRefs: ['turn:other-thread'] }, + ], + alternatives: [], + })), + }; + }, + }, + }); + const source = await threadStore.create('user-1', 'Source'); + const target = await threadStore.create('user-1', 'Target'); + const { invocationId, callbackToken } = await registry.create('user-1', 'codex', source.id); + const request = { + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': invocationId, 'x-callback-token': callbackToken }, + payload: { + threadId: target.id, + targetCats: ['codex'], + effectClass: 'coordinate', + content: 'Read the existing provider boundary', + clientMessageId: 'cross-thread-temporary-refusal', + }, + }; + const response = await app.inject(request); + assert.equal(response.statusCode, 200); + const body = response.json(); + assert.deepEqual(body.routed, ['codex'], 'requested recipient owns the durable wake'); + assert.equal(availabilityChecks, 0, 'message sending does not resolve availability'); + const entries = invocationQueue.list(target.id, 'user-1'); + assert.equal(entries.length, 1); + assert.deepEqual(entries[0].targets, ['codex']); + assert.equal(entries[0].payload.messageId, body.messageId); + const stored = await messageStore.getById(body.messageId); + assert.equal(stored.extra.crossPost.sourceThreadId, source.id); + assert.equal(stored.from.catId, 'codex'); + assert.equal(mockRouter.getExecutions().length, 0, 'admission uses the normal Queue drain'); + await app.inject(request); + assert.equal(invocationQueue.list(target.id, 'user-1').length, 1, 'retry does not duplicate the source'); + await app.close(); + }); + test('same-thread @codex from codex still filtered (self-reference)', async () => { const app = await createAppWithThreadStore(); const thread = await threadStore.create('user-1', 'Self Ref Thread'); diff --git a/packages/api/test/codex-app-server-transport.test.js b/packages/api/test/codex-app-server-transport.test.js index f597387f6e..7225b291a9 100644 --- a/packages/api/test/codex-app-server-transport.test.js +++ b/packages/api/test/codex-app-server-transport.test.js @@ -198,7 +198,7 @@ test('app-server registers one exact active-run dispatcher and fences it at turn ); await waitFor(() => dispatcher !== undefined); - assert.deepEqual(dispatcher.capabilities, { append: true, steer: true }); + assert.deepEqual(dispatcher.capabilities, { append: true, steer: true, inputReadReceipt: true }); assert.deepEqual(dispatcher.handle, { provider: 'openai_codex', carrier: 'codex_app_server', @@ -251,6 +251,126 @@ test('app-server registers one exact active-run dispatcher and fences it at turn assert.deepEqual(terminal, { accepted: false, reason: 'active_run_closed' }); }); +test('steer acceptance stays pending until the exact native user message is consumed', async () => { + const wire = new ProtocolWire(); + let dispatcher; + const reads = []; + const observed = []; + const client = new CodexAppServerClient({ wire }); + const output = (async () => { + for await (const event of client.run({ + prompt: frozenPrompt('initial work'), + thread: { kind: 'start' }, + activeRunDispatch: { + invocationId: 'read-invocation', + register: (candidate) => { + dispatcher = candidate; + }, + }, + })) + observed.push(event); + })(); + await waitFor(() => dispatcher !== undefined); + assert.equal( + ( + await dispatcher.dispatch( + { + text: 'read this exact supplement', + messageIds: ['source-1'], + onInputRead: async () => { + reads.push('source-1'); + }, + }, + { force: false, expectedInvocationId: 'read-invocation' }, + ) + ).accepted, + true, + ); + const id = wire.writes.find((message) => message.method === 'turn/steer').params.clientUserMessageId; + assert.deepEqual(reads, [], 'turn/steer acknowledgement proves acceptance only'); + const item = { + type: 'userMessage', + id: 'native-user-1', + clientId: id, + content: [{ type: 'text', text: 'read this exact supplement' }], + }; + for (const params of [ + { threadId: 'foreign-thread', turnId: 'turn-1', item }, + { threadId: 'thread-1', turnId: 'foreign-turn', item }, + { threadId: 'thread-1', turnId: 'turn-1', item: { ...item, clientId: 'unknown-input' } }, + { threadId: 'thread-1', turnId: 'turn-1', item: { ...item, type: 'agentMessage' } }, + ]) + wire.inbox.push({ method: 'item/completed', params }); + wire.inbox.push({ method: 'item/started', params: { threadId: 'thread-1', turnId: 'turn-1', item } }); + await waitFor(() => observed.some((event) => event.type === 'item.started' && event.item?.id === item.id)); + assert.deepEqual(reads, []); + const consumed = { method: 'item/completed', params: { threadId: 'thread-1', turnId: 'turn-1', item } }; + wire.inbox.push(consumed); + wire.inbox.push(consumed); + await waitFor(() => reads.length > 0); + wire.inbox.push({ + method: 'turn/completed', + params: { threadId: 'thread-1', turn: { id: 'turn-1', status: 'completed' } }, + }); + await output; + assert.deepEqual(reads, ['source-1'], 'only the matching source is observed once'); +}); + +test('read receipt persistence never blocks native turn completion', async () => { + const wire = new ProtocolWire(); + let dispatcher; + let observing = false; + let releaseRead; + const readGate = new Promise((resolve) => { + releaseRead = resolve; + }); + const client = new CodexAppServerClient({ wire }); + const output = collect( + client.run({ + prompt: frozenPrompt('work'), + thread: { kind: 'start' }, + activeRunDispatch: { + invocationId: 'nonblocking-read', + register: (candidate) => { + dispatcher = candidate; + }, + }, + }), + ); + await waitFor(() => dispatcher !== undefined); + await dispatcher.dispatch( + { + text: 'supplement', + messageIds: ['source-1'], + onInputRead: async () => { + observing = true; + await readGate; + }, + }, + { force: false, expectedInvocationId: 'nonblocking-read' }, + ); + const id = wire.writes.find((message) => message.method === 'turn/steer').params.clientUserMessageId; + wire.inbox.push({ + method: 'item/completed', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { type: 'userMessage', id: 'native-input', clientId: id }, + }, + }); + await waitFor(() => observing); + wire.inbox.push({ + method: 'turn/completed', + params: { threadId: 'thread-1', turn: { id: 'turn-1', status: 'completed' } }, + }); + try { + assert.equal(await Promise.race([output.then(() => 'completed'), delay(1_000).then(() => 'blocked')]), 'completed'); + } finally { + releaseRead(); + } + await output; +}); + test('a rejected steer cannot bind another turn', async () => { const wire = new ProtocolWire(); const originalWrite = wire.write.bind(wire); diff --git a/packages/api/test/helpers/1398-failed-response-fixture.mjs b/packages/api/test/helpers/1398-failed-response-fixture.mjs index 56f89abb80..ac70250a94 100644 --- a/packages/api/test/helpers/1398-failed-response-fixture.mjs +++ b/packages/api/test/helpers/1398-failed-response-fixture.mjs @@ -14,6 +14,7 @@ export async function failedResponseFixture( ) { const queue = new InvocationQueue(ledger); const drains = []; + const preflightCalls = []; const input = await messages.append({ from: { kind: 'agent', catId: 'codex' }, userId: 'owner', @@ -83,18 +84,21 @@ export async function failedResponseFixture( socketManager: { emitToUser() {}, broadcastAgentMessage() {} }, log: { info() {}, warn() {}, error() {} }, routingDispatchPreflight: { - preflight: async (request) => ({ - v: 1, - ownerId: request.ownerId, - observedAt: 120, - resolverState: 'ready', - targets: request.targetCatIds.map((targetCatId) => ({ - targetCatId, - disposition: rejected ? 'rejected' : 'accepted', - reasons: [], - alternatives: [], - })), - }), + preflight: async (request) => { + preflightCalls.push(request); + return { + v: 1, + ownerId: request.ownerId, + observedAt: 120, + resolverState: 'ready', + targets: request.targetCatIds.map((targetCatId) => ({ + targetCatId, + disposition: rejected ? 'rejected' : 'accepted', + reasons: [], + alternatives: [], + })), + }; + }, }, }; function recovery({ messageStore = messages, queueOwner = queue, afterCommit } = {}) { @@ -124,5 +128,5 @@ export async function failedResponseFixture( ), }); } - return { messages, turns, ledger, queue, input, response, deps, drains, recovery }; + return { messages, turns, ledger, queue, input, response, deps, drains, recovery, preflightCalls }; } diff --git a/packages/api/test/invoke-single-cat-turn-execution.test.js b/packages/api/test/invoke-single-cat-turn-execution.test.js index 7bdab15b84..533f07581c 100644 --- a/packages/api/test/invoke-single-cat-turn-execution.test.js +++ b/packages/api/test/invoke-single-cat-turn-execution.test.js @@ -289,7 +289,7 @@ describe('invokeSingleCat durable child execution lifecycle', () => { assert.doesNotMatch(JSON.stringify(terminal), /secret-token-should-not-persist/); }); - test('observes success only after the canonical durable terminal and carries the exact preflight decision', async () => { + test('persists success without global availability observation', async () => { const store = new InMemoryTurnExecutionStore(); const observed = []; const exactDecision = routingDecision(); @@ -321,15 +321,11 @@ describe('invokeSingleCat durable child execution lifecycle', () => { }), ); - assert.equal(observed.length, 1); - assert.equal(observed[0].status, 'succeeded'); - assert.equal(observed[0].observationId, 'child-routing-success'); - assert.equal(observed[0].evidenceRef, 'turn-execution:child-routing-success'); - assert.equal(observed[0].preflightDecision, exactDecision); - assert.equal(observed[0].observedAt, (await store.get('child-routing-success')).endedAt); + assert.equal((await store.get('child-routing-success')).status, 'succeeded'); + assert.deepEqual(observed, []); }); - test('maps structured provider failures after persistence and keeps observer failure advisory', async () => { + test('persists provider failure without disabling a member globally', async () => { const store = new InMemoryTurnExecutionStore(); const observed = []; const service = { @@ -373,9 +369,7 @@ describe('invokeSingleCat durable child execution lifecycle', () => { ); assert.equal((await store.get('child-routing-failure')).status, 'failed'); - assert.equal(observed.length, 1); - assert.equal(observed[0].status, 'failed'); - assert.equal(observed[0].failureClass, 'quota_exhausted'); + assert.deepEqual(observed, []); }); test('provider iterator ending without terminal done is interrupted, never succeeded', async () => { diff --git a/packages/api/test/invoke-single-cat.test.js b/packages/api/test/invoke-single-cat.test.js index 605f7057fb..99dba2da5a 100644 --- a/packages/api/test/invoke-single-cat.test.js +++ b/packages/api/test/invoke-single-cat.test.js @@ -6934,7 +6934,7 @@ describe('invokeSingleCat audit events (P1 fix)', () => { assert.ok(promptsSeen[0].includes('user message'), 'original user prompt still present'); }); - it('F293: fresh sparse routing context reaches a resumed provider session with deterministic intent', async () => { + it('normal delivery does not resolve or inject routing availability advice on resume', async () => { const promptsSeen = []; const resolutions = []; const service = { @@ -6970,12 +6970,12 @@ describe('invokeSingleCat audit events (P1 fix)', () => { }), ); - assert.deepEqual(resolutions, [{ ownerId: 'owner-f293', intent: 'review' }]); - assert.ok(promptsSeen[0].includes('F293-SPARSE')); + assert.deepEqual(resolutions, [], 'normal delivery does not consult the availability projection'); + assert.ok(!promptsSeen[0].includes('F293-SPARSE')); assert.ok(!promptsSeen[0].includes('static identity here'), 'dynamic projection is independent of static identity'); }); - it('F293: projection failure omits dynamic bytes, preserves invocation, and writes a bounded audit event', async () => { + it('normal delivery does not invoke or audit a failing legacy availability projection', async () => { const promptsSeen = []; const service = { l0CompilerFn: dummyL0CompilerFn, @@ -7008,12 +7008,7 @@ describe('invokeSingleCat audit events (P1 fix)', () => { const { getEventAuditLog } = await import('../dist/domains/cats/services/orchestration/EventAuditLog.js'); const events = await getEventAuditLog().readByThread('thread-routing-context-projection-failure'); const failure = events.find((event) => event.type === 'routing_context_projection_failed'); - assert.ok(failure, 'projection failure must be auditable'); - assert.deepEqual(failure.data, { - catId: 'opus', - invocationId: 'inv-1', - errorName: 'RangeError', - }); + assert.equal(failure, undefined, 'unused availability projection cannot create invocation audit noise'); }); it('F053: Gemini (sessionChain=true) skips systemPrompt on resume like other cats', async () => { diff --git a/packages/api/test/queue-processor.test.js b/packages/api/test/queue-processor.test.js index 6aa81bf452..2d2ddd801e 100644 --- a/packages/api/test/queue-processor.test.js +++ b/packages/api/test/queue-processor.test.js @@ -2118,22 +2118,18 @@ describe('F117 soak: a waiting entry does not stop its thread’s queue', () => .filter((call) => String(call.arguments[1]).includes('waits for its retry time')) .map((call) => call.arguments[0]); - /** - * A route whose first attempt has actual-send routing refuse `refusedCatIds` the way the real routes - * do: the refusal callback with routing's retry time, then the error, and no response receiver. - */ - const routeRefusingFirstAttempt = (attempts, refusedCatIds, automaticRetryAt) => + // Actual backend failure before a response receiver exists; no availability lookup. + const routeFailingBeforeAcceptance = (attempts, refusedCatIds) => async function* (...args) { const [userId, content, threadId, , targetCats, , options] = args; attempts.push({ at: Date.now(), content, targetCats: [...targetCats] }); const refused = attempts.length === 1 ? targetCats.filter((catId) => refusedCatIds.includes(catId)) : []; for (const catId of refused) { - options.onRoutingDispatchRejected?.({ catId, automaticRetryAt: automaticRetryAt() }); yield { type: 'error', catId, - errorCode: 'routing_preflight_rejected', - error: '本次未执行:成员当前不可用。恢复后可重试原消息。', + errorCode: 'backend_start_failed', + error: 'backend failed before accepting the source', timestamp: Date.now(), }; } @@ -2151,12 +2147,12 @@ describe('F117 soak: a waiting entry does not stop its thread’s queue', () => } }; - it('retries a member refused at actual send when routing will accept it again', async () => { + it('retries an actual backend failure before input acceptance', async () => { const attempts = []; let automaticRetryAt; const harness = createHarness({ processorOptions: { retryDeferral: { baseDelayMs: 20 } }, - routeExecution: routeRefusingFirstAttempt(attempts, ['opus'], () => automaticRetryAt), + routeExecution: routeFailingBeforeAcceptance(attempts, ['opus']), }); automaticRetryAt = Date.now() + 400; const refused = await admitMessage(harness, { targetCats: ['opus'] }); @@ -2165,19 +2161,94 @@ describe('F117 soak: a waiting entry does not stop its thread’s queue', () => await waitFor(() => deferralLogs(harness).length === 1); const [deferral] = deferralLogs(harness); assert.equal(deferral.entryId, refused.entry.id); - assert.equal(deferral.retryAt, automaticRetryAt, 'the wait ends at routing’s retry time, not the shorter backoff'); + + assert.ok(deferral.retryAt < automaticRetryAt, 'availability cannot impose an extra wait'); await waitFor(() => attempts.length === 2); - assert.ok(attempts[1].at >= automaticRetryAt - 5, 'not retried before routing accepts the member again'); + await waitFor(() => queuedIds(harness).length === 0); }); - it('waits for routing’s retry time when the member a targetless source resolved to is refused', async () => { + it('a cross-thread callback uses normal admission and recovers an actual failed backend handoff', async () => { + const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); + const attempts = []; + const retryAt = Date.now() + 200; + const harness = createHarness({ + processorOptions: { retryDeferral: { baseDelayMs: 20 } }, + routeExecution: routeFailingBeforeAcceptance(attempts, ['opus']), + }); + const trigger = harness.messageStore.append({ + from: { kind: 'agent', catId: 'opus' }, + userId: 'user-1', + threadId: 'thread-1', + content: 'Cross-thread delivery must survive temporary refusal', + mentions: ['opus'], + origin: 'callback', + timestamp: Date.now(), + extra: { crossPost: { sourceThreadId: 'other-thread', effectClass: 'coordinate' }, targetCats: ['opus'] }, + }); + const receipt = await enqueueA2ATargets( + { + invocationQueue: harness.queue, + messageStore: harness.messageStore, + queueProcessor: harness.processor, + socketManager: harness.socketManager, + log: harness.log, + routingDispatchPreflight: { + async preflight(input) { + return { + v: 1, + ownerId: input.ownerId, + observedAt: Date.now(), + resolverState: 'fresh', + targets: [ + { + targetCatId: 'opus', + disposition: 'rejected', + automaticRetryAt: retryAt, + reasons: [ + { code: 'provider_timeout', summary: 'temporarily unavailable', sourceRefs: ['turn:other'] }, + ], + alternatives: [], + }, + ], + }; + }, + }, + }, + { + targetCats: ['opus'], + content: trigger.content, + threadId: 'thread-1', + userId: 'user-1', + ownerAuthProvenance: 'unknown', + callerCatId: 'opus', + triggerMessage: trigger, + }, + ); + assert.deepEqual(receipt.enqueued, ['opus'], 'the exact source must get durable Queue custody'); + await waitFor(() => deferralLogs(harness).length === 1); + assert.equal(attempts.length, 1); + assert.deepEqual(attempts[0].targetCats, ['opus']); + assert.equal( + (await harness.messageStore.getById(trigger.id)).lifecycle?.dispatchRefs?.length ?? 0, + 0, + 'a refused attempt must not forge a dispatch/read receipt', + ); + assert.equal(queuedIds(harness).length, 1, 'refused source stays queued'); + await waitFor(() => attempts.length === 2); + + assert.deepEqual(attempts[1].targetCats, ['opus'], 'recovery preserves target, without another message or reroute'); + await waitFor(() => queuedIds(harness).length === 0); + assert.equal((await harness.messageStore.getById(trigger.id)).lifecycle.dispatchRefs.length, 1); + }); + + it('uses backend backoff for a failed targetless source handoff', async () => { const attempts = []; let automaticRetryAt; const harness = createHarness({ processorOptions: { retryDeferral: { baseDelayMs: 20 } }, - routeExecution: routeRefusingFirstAttempt(attempts, ['opus'], () => automaticRetryAt), + routeExecution: routeFailingBeforeAcceptance(attempts, ['opus']), }); automaticRetryAt = Date.now() + 400; const targetless = await admitMessage(harness, { targetCats: [] }); @@ -2192,20 +2263,21 @@ describe('F117 soak: a waiting entry does not stop its thread’s queue', () => ); const [deferral] = deferralLogs(harness); assert.equal(deferral.entryId, targetless.entry.id); - assert.deepEqual(deferral.refusedTargets, ['opus']); - assert.equal(deferral.retryAt, automaticRetryAt, 'the wait ends at routing’s retry time, not the shorter backoff'); + assert.equal(deferral.refusedTargets, undefined); + + assert.ok(deferral.retryAt < automaticRetryAt, 'availability cannot impose an extra wait'); await waitFor(() => attempts.length === 2); - assert.ok(attempts[1].at >= automaticRetryAt - 5, 'the first retry does not come before routing’s retry time'); + await waitFor(() => queuedIds(harness).length === 0); }); - it('makes a pair wait for routing’s retry time for the member refused after its sibling was handed off', async () => { + it('retries only the sibling whose backend handoff actually failed', async () => { const attempts = []; let automaticRetryAt; const harness = createHarness({ processorOptions: { retryDeferral: { baseDelayMs: 20 } }, - routeExecution: routeRefusingFirstAttempt(attempts, ['codex'], () => automaticRetryAt), + routeExecution: routeFailingBeforeAcceptance(attempts, ['codex']), }); automaticRetryAt = Date.now() + 400; const pair = await admitMessage(harness, { targetCats: ['opus', 'codex'] }); @@ -2218,11 +2290,11 @@ describe('F117 soak: a waiting entry does not stop its thread’s queue', () => ['codex'], 'only the refused member is left in the Queue', ); - assert.equal(deferralLogs(harness)[0].retryAt, automaticRetryAt); + assert.ok(deferralLogs(harness)[0].retryAt < automaticRetryAt); await waitFor(() => attempts.length === 2); assert.deepEqual(attempts[1].targetCats, ['codex']); - assert.ok(attempts[1].at >= automaticRetryAt - 5, 'the refused member is not retried before routing’s retry time'); + await waitFor(() => queuedIds(harness).length === 0); }); diff --git a/packages/api/test/route-parallel-a2a-failure-report.test.js b/packages/api/test/route-parallel-a2a-failure-report.test.js index bf9e3ed66e..316b6558ac 100644 --- a/packages/api/test/route-parallel-a2a-failure-report.test.js +++ b/packages/api/test/route-parallel-a2a-failure-report.test.js @@ -1,6 +1,10 @@ import './helpers/setup-cat-registry.js'; import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; +import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; +import { InMemoryQueueLedgerStore } from '../dist/domains/cats/services/agents/invocation/queue-ledger/InMemoryQueueLedgerStore.js'; +import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; +import { commitFailedResponseAndEnqueueA2ACaller } from '../dist/routes/callback-a2a-trigger.js'; function failingService(catId) { return { @@ -54,69 +58,113 @@ function depsFor(services) { }; } -describe('routeParallel failed A2A reporting', () => { - test('each failed lifecycle response reports durably to the exact predecessor', async () => { - const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js'); - const reports = []; - const services = { - bengal: failingService('bengal'), - lihua: failingService('lihua'), - }; +describe('provider failure returns through the durable response transaction', () => { + for (const [name, file, exported] of [ + ['serial', 'route-serial', 'routeSerial'], + ['parallel', 'route-parallel', 'routeParallel'], + ]) { + test(`${name}: each failed provider response persists and enqueues only its exact predecessor`, async () => { + const { [exported]: route } = await import(`../dist/domains/cats/services/agents/routing/${file}.js`); + const messageStore = new MessageStore(); + const invocationQueue = new InvocationQueue(new InMemoryQueueLedgerStore()); + let availabilityChecks = 0; + const triggerDeps = { + messageStore, + invocationQueue, + queueProcessor: { requestDrain: async () => {}, registerCallerDispatchInitialTargets() {} }, + socketManager: { emitToUser() {}, broadcastAgentMessage() {} }, + log: { info() {}, warn() {}, error() {} }, + routingDispatchPreflight: { + preflight: async () => { + availabilityChecks++; + throw new Error('must not check availability'); + }, + }, + }; + const reports = []; + const services = { + bengal: failingService('bengal'), + lihua: failingService('lihua'), + }; - for await (const _event of routeParallel( - depsFor(services), - ['bengal', 'lihua'], - 'ideate from Fable', - 'owner-1', - 'thread-a2a-parallel-failure', - { - ownerAuthProvenance: 'strict', - a2aTriggerMessageId: 'source-from-fable', - a2aCallerCatId: 'fable', - parentInvocationId: 'parent-invocation', - onLifecycleInvocationStarted: async ({ catId, invocationId, startedAt }) => ({ - responseMessageId: `response-${catId}`, - priorFrontierMessageId: null, - activeRun: { - threadId: 'thread-a2a-parallel-failure', - targetId: catId, - invocationId, - responseMessageId: `response-${catId}`, - inputEntryIds: [], - inputMessageIds: ['source-from-fable'], - privateInputEntryIds: [], - startedAt, + for await (const _event of route( + depsFor(services), + ['bengal', 'lihua'], + 'ideate from Fable', + 'owner-1', + 'thread-a2a-parallel-failure', + { + ownerAuthProvenance: 'strict', + a2aTriggerMessageId: 'source-from-fable', + a2aCallerCatId: 'fable', + parentInvocationId: 'parent-invocation', + onLifecycleInvocationStarted: async ({ catId, invocationId, startedAt }) => { + const response = messageStore.append({ + from: { kind: 'agent', catId }, + userId: 'owner-1', + threadId: 'thread-a2a-parallel-failure', + content: '', + mentions: [], + timestamp: startedAt, + lifecycle: { + kind: 'response', + orderKey: `${startedAt}:${catId}`, + targetId: catId, + invocationId, + inputEntryIds: [], + inputMessageIds: ['source-from-fable'], + status: 'processing', + startedAt, + }, + }); + return { + responseMessageId: response.id, + priorFrontierMessageId: null, + activeRun: { + threadId: 'thread-a2a-parallel-failure', + targetId: catId, + invocationId, + responseMessageId: response.id, + inputEntryIds: [], + inputMessageIds: ['source-from-fable'], + privateInputEntryIds: [], + startedAt, + }, + }; + }, + commitFailedA2AReport: async (input) => { + reports.push(input); + return commitFailedResponseAndEnqueueA2ACaller(triggerDeps, input); }, - }), - commitFailedA2AReport: async (input) => { - reports.push(input); - return { - id: input.responseMessageId, - ...input.message, - lifecycle: { kind: 'response', ...input.terminal }, - }; }, - }, - )) { - // exhaust both failed children - } + )) { + // exhaust both failed children + } - assert.deepEqual( - reports - .map(({ reporterCatId, predecessorCatId, responseMessageId }) => ({ - reporterCatId, - predecessorCatId, - responseMessageId, - })) - .sort((left, right) => left.reporterCatId.localeCompare(right.reporterCatId)), - [ - { reporterCatId: 'bengal', predecessorCatId: 'fable', responseMessageId: 'response-bengal' }, - { reporterCatId: 'lihua', predecessorCatId: 'fable', responseMessageId: 'response-lihua' }, - ], - ); - for (const report of reports) { - assert.equal(report.terminal.status, 'failed'); - assert.equal(report.message.content, 'configured model unavailable'); - } - }); + assert.deepEqual( + reports + .map(({ reporterCatId, predecessorCatId }) => ({ reporterCatId, predecessorCatId })) + .sort((left, right) => left.reporterCatId.localeCompare(right.reporterCatId)), + [ + { reporterCatId: 'bengal', predecessorCatId: 'fable' }, + { reporterCatId: 'lihua', predecessorCatId: 'fable' }, + ], + ); + for (const report of reports) { + assert.equal(report.terminal.status, 'failed'); + assert.equal(report.message.content, 'configured model unavailable'); + assert.equal(messageStore.getById(report.responseMessageId).lifecycle.status, 'failed'); + assert.equal(messageStore.getById(report.responseMessageId).content, report.message.content); + } + const rows = await invocationQueue.listAllDurable('thread-a2a-parallel-failure'); + assert.equal(rows.length, 2); + for (const row of rows) { + assert.deepEqual(row.targets, ['fable']); + assert.equal(row.sourceCategory, 'a2a_failure'); + assert.equal(row.execution.a2aParentInvocationId, 'parent-invocation'); + assert.ok(reports.some((report) => report.responseMessageId === row.payload.messageId)); + } + assert.equal(availabilityChecks, 0); + }); + } }); diff --git a/packages/api/test/routing-dispatch-preflight.test.js b/packages/api/test/routing-dispatch-preflight.test.js index e3a2e623c6..422d8d622f 100644 --- a/packages/api/test/routing-dispatch-preflight.test.js +++ b/packages/api/test/routing-dispatch-preflight.test.js @@ -66,18 +66,18 @@ function routeDeps(services, routingDispatchPreflight, invocationExtras = {}) { }; } -describe('F293 actual-send routing preflight', () => { - test('serial and parallel classify human attempts from strict ingress, preserving queued origin', async () => { +describe('F117 normal dispatch without availability gating', () => { + test('serial and parallel do not consult availability for any ingress origin', async () => { const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js'); for (const route of [routeSerial, routeParallel]) { - for (const [origin, queueSource, auth, expected] of [ - ['direct_owner', undefined, 'strict', true], - ['queue_replay', 'user', 'strict', true], - ['queue_replay', 'agent', 'strict', false], - ['queue_replay', 'connector', 'strict', false], - ['callback', 'user', 'strict', false], - ['direct_owner', undefined, 'unknown', false], + for (const [origin, queueSource, auth] of [ + ['direct_owner', undefined, 'strict'], + ['queue_replay', 'user', 'strict'], + ['queue_replay', 'agent', 'strict'], + ['queue_replay', 'connector', 'strict'], + ['callback', 'user', 'strict'], + ['direct_owner', undefined, 'unknown'], ]) { const seen = []; const deps = routeDeps( @@ -96,16 +96,12 @@ describe('F293 actual-send routing preflight', () => { })) { /* drain real route */ } - assert.equal( - seen[0].ownerRequestedAttempt === true, - expected, - `${route.name}: ${origin}/${queueSource}/${auth}`, - ); + assert.equal(seen.length, 0, `${route.name}: ${origin}/${queueSource}/${auth}`); } } }); - test('all rejected targets produce truthful failed dispositions without any child invocation', async () => { + test('availability cannot suppress explicitly requested child invocations', async () => { const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js'); const { PerCatTerminalDispositionCollector } = await import( @@ -120,32 +116,52 @@ describe('F293 actual-send routing preflight', () => { ); for await (const event of route(deps, ['opus', 'codex'], 'request', 'owner-1', 'rejected-thread')) collector.observe(event); - assert.equal(calls.length, 0, `${route.name} must not start a rejected child invocation: ${calls.join(',')}`); - assert.deepEqual(collector.getSuccessfulCatIds(), []); - assert.ok(collector.getPrimaryTerminalError(), `${route.name} must expose a retryable failure`); + assert.deepEqual([...calls].sort(), ['codex', 'opus'], `${route.name} must attempt both exact targets`); + assert.deepEqual([...collector.getSuccessfulCatIds()].sort(), ['codex', 'opus']); + assert.equal(collector.getPrimaryTerminalError(), undefined); } }); - test('a rejected original request persists its exact retry source for refresh and thread switching', async () => { + test('provider failure produces its normal failed response without an availability notice', async () => { const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); - const stored = []; - const deps = routeDeps({}, { preflight: async (input) => decision(input, { opus: 'rejected' }) }); - deps.messageStore.append = async (input) => { - const message = { ...input, id: `notice-${stored.length}` }; - stored.push(message); - return message; - }; - const emitted = []; - for await (const event of routeSerial(deps, ['opus'], 'original', 'owner-1', 'thread-1', { - parentInvocationId: 'parent-1', - currentUserMessageId: 'message-1', - })) - emitted.push(event); - const notice = stored.find((message) => message.extra?.systemInfo?.payload.type === 'routing_preflight'); - assert.ok(notice, 'the notice must survive hydration'); - assert.equal(notice.extra.systemInfo.payload.retryInvocationId, 'parent-1'); - assert.equal(notice.extra.systemInfo.payload.sourceMessageId, 'message-1'); - assert.equal(emitted.find((event) => event.type === 'system_info')?.messageId, notice.id); + const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js'); + const { PerCatTerminalDispositionCollector } = await import( + '../dist/domains/cats/services/agents/invocation/PerCatTerminalDispositionCollector.js' + ); + for (const route of [routeSerial, routeParallel]) { + const calls = []; + let preflightCalls = 0; + const deps = routeDeps( + { + opus: { + async *invoke() { + calls.push('opus'); + throw new Error('native provider quota exhausted'); + }, + }, + }, + { + preflight: async () => { + preflightCalls++; + throw new Error('availability must not be consulted'); + }, + }, + ); + const events = []; + const collector = new PerCatTerminalDispositionCollector({ targetCatIds: ['opus'] }); + for await (const event of route(deps, ['opus'], 'original', 'owner-1', 'thread-failure', { + parentInvocationId: 'parent-1', + currentUserMessageId: 'message-1', + })) { + events.push(event); + collector.observe(event); + } + assert.deepEqual(calls, ['opus']); + assert.equal(preflightCalls, 0); + assert.equal(collector.getTerminalStatus('opus'), 'failed'); + assert.match(collector.getPrimaryTerminalError(), /native provider quota exhausted/); + assert.ok(!events.some((event) => event.type === 'system_info' && event.content?.includes('routing_preflight'))); + } }); test('last-resort degradation deduplicates targets and stays total for an empty target set', async () => { @@ -217,7 +233,7 @@ describe('F293 actual-send routing preflight', () => { assert.deepEqual(catalogInputs, [{ ownerId: 'owner-1' }, { ownerId: 'owner-1' }, { ownerId: 'owner-1' }]); }); - test('serial mixed targets reject only the unavailable child and let warned target proceed unchanged', async () => { + test('serial starts every requested target without consulting availability', async () => { const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); const calls = []; const preflightInputs = []; @@ -237,28 +253,23 @@ describe('F293 actual-send routing preflight', () => { events.push(event); } - assert.ok(!calls.includes('opus'), 'rejected target must not create a provider child'); + assert.ok(calls.includes('opus'), 'requested target must create its provider child'); assert.ok(calls.includes('codex'), 'warned target must retain the original target'); assert.deepEqual( preflightInputs.slice(0, 2).map((input) => ({ targetCatIds: input.targetCatIds, intent: input.intent })), - [ - { targetCatIds: ['opus'], intent: 'review' }, - { targetCatIds: ['codex'], intent: 'review' }, - ], + [], ); const receipts = events .filter((event) => event.type === 'system_info' && event.content?.includes('routing_preflight')) .map((event) => JSON.parse(event.content)); - assert.ok( - receipts.some((receipt) => receipt.target.targetCatId === 'opus' && receipt.target.disposition === 'rejected'), - ); + assert.deepEqual(receipts, []); assert.ok( !receipts.some((receipt) => receipt.target.targetCatId === 'codex'), 'fail-open infrastructure degradation stays in routing telemetry instead of becoming chat content', ); }); - test('consumer failure degrades to warned and preserves the exact original target', async () => { + test('unused availability consumer cannot prevent the original target from executing', async () => { const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); const calls = []; const deps = routeDeps( @@ -278,7 +289,7 @@ describe('F293 actual-send routing preflight', () => { ); }); - test('completed response preflights before atomic ledger admission and leaves no rejected row', async () => { + test('completed response atomically admits every requested target without availability feedback', async () => { const { commitCompletedResponseAndEnqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); @@ -345,16 +356,9 @@ describe('F293 actual-send routing preflight', () => { assert.equal(stored.lifecycle.status, 'completed'); assert.deepEqual( queue.list('thread-deferred', 'owner-1').flatMap((entry) => entry.targets), - ['terra'], - ); - assert.ok( - broadcasts.some( - ({ message }) => - message.type === 'system_info' && - message.content?.includes('routing_preflight') && - JSON.parse(message.content).target.targetCatId === 'codex', - ), + ['codex', 'terra'], ); + assert.equal(broadcasts.filter(({ message }) => message.content?.includes('routing_preflight')).length, 0); }); test('failed A2A response atomically admits one idempotent Queue-only wake for the exact caller', async () => { @@ -394,6 +398,9 @@ describe('F293 actual-send routing preflight', () => { }, }, socketManager: { broadcastAgentMessage() {}, emitToUser() {} }, + routingDispatchPreflight: { + preflight: async (input) => decision(input, { fable: 'rejected' }), + }, log: { error() {}, warn() {}, info() {} }, }; const input = { @@ -440,7 +447,7 @@ describe('F293 actual-send routing preflight', () => { ); }); - test('parallel mixed targets never invoke the rejected child', async () => { + test('parallel starts every requested target without availability notices', async () => { const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js'); const calls = []; const deps = routeDeps( @@ -452,19 +459,19 @@ describe('F293 actual-send routing preflight', () => { events.push(event); } - assert.ok(!calls.includes('opus')); + assert.ok(calls.includes('opus')); assert.ok(calls.includes('codex')); const receipts = events .filter((event) => event.type === 'system_info' && event.content?.includes('routing_preflight')) .map((event) => JSON.parse(event.content)); assert.deepEqual( receipts.map((receipt) => [receipt.target.targetCatId, receipt.target.disposition]), - [['opus', 'rejected']], + [], 'parallel fail-open degradation stays in routing telemetry instead of becoming chat content', ); }); - test('serial and parallel report each refused requested target with routing’s retry time', async () => { + test('serial and parallel do not defer requested targets using routing retry times', async () => { const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js'); const automaticRetryAt = Date.now() + 5 * 60_000; @@ -490,12 +497,12 @@ describe('F293 actual-send routing preflight', () => { })) { /* drain real route */ } - assert.deepEqual(rejections, [{ catId: 'opus', automaticRetryAt }], `${route.name} reports the exact refusal`); - assert.deepEqual(calls, ['codex'], `${route.name} still starts the accepted target`); + assert.deepEqual(rejections, [], `${route.name} has no availability refusal path`); + assert.deepEqual([...calls].sort(), ['codex', 'opus'], `${route.name} starts all requested targets`); } }); - test('serial and parallel invocations retain the exact actual-send decision through durable terminal observation', async () => { + test('serial and parallel terminals do not publish global availability from local execution', async () => { const { routeSerial } = await import('../dist/domains/cats/services/agents/routing/route-serial.js'); const { routeParallel } = await import('../dist/domains/cats/services/agents/routing/route-parallel.js'); const { InMemoryTurnExecutionStore } = await import( @@ -535,34 +542,19 @@ describe('F293 actual-send routing preflight', () => { // exhaust the route so both durable terminal observers run } - assert.equal(returnedDecisions.length, 2); - assert.equal(observed.length, 3); - assert.equal(observed[0].preflightDecision, returnedDecisions[0]); - assert.equal(observed[1].preflightDecision, returnedDecisions[1]); - assert.equal(observed[2].preflightDecision, returnedDecisions[1]); - assert.deepEqual( - observed.slice(0, 1).map(({ catId, status }) => [catId, status]), - [['opus', 'succeeded']], - ); - assert.deepEqual( - observed - .slice(1) - .map(({ catId, status }) => [catId, status]) - .sort(([left], [right]) => left.localeCompare(right)), - [ - ['codex', 'succeeded'], - ['opus', 'succeeded'], - ], - ); + assert.equal(returnedDecisions.length, 0); + assert.deepEqual(observed, []); + assert.deepEqual([...calls].sort(), ['codex', 'opus', 'opus']); }); - test('callback queue partitions mixed targets before creating queue entries and returns the complete receipt', async () => { + test('callback queue and Append never consult or expose availability', async () => { const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); const queue = new InvocationQueue(); const messageStore = new MessageStore(); const broadcasts = []; + const appendedTargets = []; const triggerMessage = messageStore.append({ from: { kind: 'agent', catId: 'terra' }, threadId: 'thread-callback', @@ -576,7 +568,12 @@ describe('F293 actual-send routing preflight', () => { { invocationQueue: queue, messageStore, - queueProcessor: { async requestDrain() {} }, + queueProcessor: { + async requestDrain() {}, + async tryAutoAppendExactEntry(input) { + appendedTargets.push(input.targetCatId); + }, + }, socketManager: { broadcastAgentMessage(message, threadId) { broadcasts.push({ message, threadId }); @@ -600,17 +597,12 @@ describe('F293 actual-send routing preflight', () => { }, ); - assert.deepEqual(result.enqueued, ['codex']); - assert.deepEqual( - result.routingPreflight.targets.map(({ targetCatId, disposition }) => ({ targetCatId, disposition })), - [ - { targetCatId: 'opus', disposition: 'rejected' }, - { targetCatId: 'codex', disposition: 'warned' }, - ], - ); + assert.deepEqual(result.enqueued, ['opus', 'codex']); + assert.deepEqual(appendedTargets, ['opus', 'codex'], 'every requested target may Append'); + assert.equal(result.routingPreflight, undefined); assert.deepEqual( queue.list('thread-callback', 'owner-1').flatMap((entry) => entry.targets), - ['codex'], + ['opus', 'codex'], ); assert.deepEqual( broadcasts @@ -620,7 +612,7 @@ describe('F293 actual-send routing preflight', () => { target: JSON.parse(message.content).target.targetCatId, disposition: JSON.parse(message.content).target.disposition, })), - [{ threadId: 'thread-callback', target: 'opus', disposition: 'rejected' }], + [], ); }); }); diff --git a/packages/api/test/routing-dispatch-signal-adapter.test.js b/packages/api/test/routing-dispatch-signal-adapter.test.js index c0026fe08e..4b4239e63d 100644 --- a/packages/api/test/routing-dispatch-signal-adapter.test.js +++ b/packages/api/test/routing-dispatch-signal-adapter.test.js @@ -104,10 +104,10 @@ describe('RoutingDispatchSignalAdapter', () => { assert.equal(classifyRoutingDispatchFailure({ cliReasonCode: 'quota_exceeded' }), 'quota_exhausted'); assert.equal(classifyRoutingDispatchFailure({ cliReasonCode: 'auth_failed' }), 'authentication_rejected'); assert.equal(classifyRoutingDispatchFailure({ cliReasonCode: 'network_error' }), 'provider_unreachable'); - assert.equal(classifyRoutingDispatchFailure({ cliReasonCode: 'cli_stall_timeout' }), 'provider_timeout'); + assert.equal(classifyRoutingDispatchFailure({ cliReasonCode: 'cli_stall_timeout' }), undefined); assert.equal(classifyRoutingDispatchFailure({ providerErrorCode: 'runtime_disconnected' }), 'provider_unreachable'); - assert.equal(classifyRoutingDispatchFailure({ providerErrorCode: 'stream_idle_stall' }), 'provider_timeout'); - assert.equal(classifyRoutingDispatchFailure({ terminalReason: 'invocation_timeout' }), 'provider_timeout'); + assert.equal(classifyRoutingDispatchFailure({ providerErrorCode: 'stream_idle_stall' }), undefined); + assert.equal(classifyRoutingDispatchFailure({ terminalReason: 'invocation_timeout' }), undefined); assert.equal(classifyRoutingDispatchFailure({ providerErrorCode: 'server_overloaded' }), undefined); assert.equal(classifyRoutingDispatchFailure({ providerErrorCode: 'context_overflow' }), undefined); assert.equal(classifyRoutingDispatchFailure({ providerErrorCode: 'contains quota_exceeded text' }), undefined); @@ -116,7 +116,7 @@ describe('RoutingDispatchSignalAdapter', () => { it('asserts stable failures only against the exact cat with bounded validity', async () => { const telemetryEvents = []; const { adapter, store } = createHarness({ record: (event) => telemetryEvents.push(event) }); - const input = terminal({ status: 'failed', failureClass: 'provider_timeout' }); + const input = terminal({ status: 'failed', failureClass: 'provider_unreachable' }); await adapter.observeTerminal(input); await adapter.observeTerminal(input); @@ -124,7 +124,7 @@ describe('RoutingDispatchSignalAdapter', () => { assert.equal(store.events.length, 1); assert.deepEqual(store.events[0].subjectRef, { type: 'cat', catId: 'sol' }); assert.equal(store.events[0].source, 'provider_error'); - assert.equal(store.events[0].reasonCode, 'provider_timeout'); + assert.equal(store.events[0].reasonCode, 'provider_unreachable'); assert.equal(store.events[0].state, 'unavailable'); assert.equal(store.events[0].validUntil, NOW + 1_000 + 5 * 60_000); assert.deepEqual(telemetryEvents, [ @@ -133,6 +133,24 @@ describe('RoutingDispatchSignalAdapter', () => { ]); }); + it('a local execution timeout never asserts global member unavailability', async () => { + const { adapter, store } = createHarness(); + for (const evidence of [ + { cliReasonCode: 'cli_response_timeout' }, + { cliReasonCode: 'cli_stall_timeout' }, + { providerErrorCode: 'stream_idle_stall' }, + { providerErrorCode: 'turn_budget_exceeded' }, + { providerErrorCode: 'provider_timeout' }, + { terminalReason: 'timeout' }, + { terminalReason: 'invocation_timeout' }, + ]) { + const failureClass = classifyRoutingDispatchFailure(evidence); + assert.equal(failureClass, undefined); + assert.deepEqual(await adapter.observeTerminal(terminal({ status: 'failed', failureClass })), []); + } + assert.deepEqual(store.events, [], 'other threads must not inherit this invocation’s timeout'); + }); + it('ignores canceled, interrupted, and unclassified failed terminals', async () => { const telemetryEvents = []; const { adapter, store } = createHarness({ diff --git a/packages/api/test/routing-human-recovery.test.js b/packages/api/test/routing-human-recovery.test.js index 6553e40b94..ced93bbc23 100644 --- a/packages/api/test/routing-human-recovery.test.js +++ b/packages/api/test/routing-human-recovery.test.js @@ -95,7 +95,7 @@ function harness(catIds = ['sol', 'terra']) { } describe('F293 controlled human recovery journey', () => { - it('runs real dispatch across three threads with a failing then recovered fake provider, without replaying old work', async () => { + it('runs each requested thread despite an earlier provider failure or legacy availability signal, without replaying old work', async () => { const h = harness(['opus', 'codex']); h.time(Date.now()); let providerFails = true; @@ -118,6 +118,20 @@ describe('F293 controlled human recovery journey', () => { }, }; const { deps, messages } = routingJourneyDeps(h.preflight, h.adapter, provider); + let preflightCalls = 0; + let terminalObservations = 0; + deps.routingDispatchPreflight = { + preflight: async (input) => { + preflightCalls++; + return h.preflight.preflight(input); + }, + }; + deps.invocationDeps.routingDispatchSignalObserver = { + observeTerminal: async (input) => { + terminalObservations++; + return h.adapter.observeTerminal(input); + }, + }; const send = async (threadId, human = false) => { h.time(Date.now()); const result = []; @@ -131,37 +145,31 @@ describe('F293 controlled human recovery journey', () => { h.time(Date.now()); return result; }; - await send('thread-a', true); + const firstFailure = await send('thread-a', true); assert.equal(calls, 1); - assert.equal((await h.decide()).targets[0].disposition, 'rejected', 'real failed terminal updates routing truth'); + assert.ok(firstFailure.some((event) => event.type === 'error' && event.error === 'quota exhausted')); + assert.equal(h.events.length, 0, 'an invocation failure must not publish global member availability'); + await h.negative('legacy-quota', { observedAt: Date.now(), validUntil: Date.now() + 300_000 }); for (const threadId of ['thread-b', 'thread-c']) { const events = await send(threadId); - assert.ok( - events.some((event) => { - if (event.type !== 'system_info') return false; - try { - const receipt = JSON.parse(event.content); - return receipt.type === 'routing_preflight' && receipt.target?.disposition === 'rejected'; - } catch { - return false; - } - }), - ); + assert.ok(events.some((event) => event.type === 'error' && event.error === 'quota exhausted')); + assert.ok(!events.some((event) => event.type === 'system_info' && event.content?.includes('routing_preflight'))); } - assert.equal(calls, 1, 'automatic retries are blocked before invoking the provider'); + assert.equal(calls, 3, 'each cross-thread request reaches the provider and receives its actual failure'); providerFails = false; - await send('thread-a', true); - assert.equal(calls, 2, 'a human attempt reaches the recovered provider'); - assert.equal((await h.decide()).targets[0].disposition, 'allowed'); - assert.equal(calls, 2, 'recovery does not replay either blocked request'); + for (const threadId of ['thread-a', 'thread-b', 'thread-c']) { + const events = await send(threadId, threadId === 'thread-a'); + assert.ok(events.some((event) => event.type === 'text' && event.content.includes('completed'))); + } + assert.equal(calls, 6, 'only the six explicit requests execute; success does not replay failed work'); + assert.equal(preflightCalls, 0, 'ordinary delivery never queries legacy availability'); + assert.equal(terminalObservations, 0, 'ordinary terminal outcomes never update global availability'); + assert.equal(h.events.length, 1, 'the legacy signal remains readable without affecting ordinary dispatch'); assert.equal( messages.filter((message) => message.extra?.systemInfo?.payload.retryInvocationId).length, - 2, - 'only rejected automatic sends persist retryable receipts; a permitted owner attempt does not add noise', + 0, + 'actual provider failures must not manufacture availability rejection receipts', ); - await send('thread-b'); - await send('thread-c'); - assert.equal(calls, 4, 'both other threads can send again through the same resolver'); }); it('warns and really permits an owner attempt while the same automatic target remains rejected', async () => { diff --git a/packages/api/test/routing-signal-observation.test.js b/packages/api/test/routing-signal-observation.test.js index 55a061be50..17b5ad18e5 100644 --- a/packages/api/test/routing-signal-observation.test.js +++ b/packages/api/test/routing-signal-observation.test.js @@ -38,7 +38,7 @@ function dispatchTerminal(overrides = {}) { evidenceRef: 'turn-execution:inv-1', catId: 'sol', status: 'failed', - failureClass: 'provider_timeout', + failureClass: 'provider_unreachable', preflightDecision: { v: 1, ownerId: 'owner-1', @@ -140,7 +140,7 @@ describe('F293 automatic routing observation contracts', () => { ); assert.equal( routingSignalObservationV1Schema.safeParse( - dispatchTerminal({ status: 'succeeded', failureClass: 'provider_timeout' }), + dispatchTerminal({ status: 'succeeded', failureClass: 'provider_unreachable' }), ).success, false, ); diff --git a/packages/web/src/components/AppendedInputReceipts.tsx b/packages/web/src/components/AppendedInputReceipts.tsx index c9b3504622..e37e427c0c 100644 --- a/packages/web/src/components/AppendedInputReceipts.tsx +++ b/packages/web/src/components/AppendedInputReceipts.tsx @@ -6,7 +6,6 @@ import { useMeasuredOverflow } from '@/components/content-overflow/useMeasuredOv import { ChevronIcon } from '@/components/hub-icons'; import type { CatData } from '@/hooks/useCatData'; import { useCoCreatorConfig } from '@/hooks/useCoCreatorConfig'; -import { catColorVar } from '@/lib/cat-slug'; import { resolveMessageSender } from '@/lib/resolve-sender'; import type { ChatMessage } from '@/stores/chat-types'; import { focusLineageMessage } from '@/utils/focusLineageMessage'; @@ -195,21 +194,20 @@ export function AppendedInputReceipts({ response, timelineMessages, getCatById } : undefined } > - {renderedInputs.map((source) => ( - toggleRow(source.id)} - /> - ))} + {renderedInputs.map((source) => { + const sender = resolveMessageSender(source, getCatById, coCreator); + return ( + toggleRow(source.id)} + /> + ); + })} {canExpand && (