Files
PolyGateway/research-wiki/plans/plan-issue14-admission-wait-policy.md
iomgaa c5b2b3fade docs: correct how a wait-mode call actually dies on a dead source
Branch review caught the docs claiming something the code does not do.
CHANGELOG, README and the design's behaviour matrix all said a
force-opened source under circuit_open=wait waits out the full stall
window. It does not: the probe let through after each cooldown is a
real attempt, so it burns a max_attempts slot like any other, and a
401 source usually runs out of retry budget first -- reason is
retry_exhausted, not stalled. Which budget wins depends on
max_attempts against the cooldowns and the stall window.

The behaviour is right; only the prose was wrong. Charging the probe
to the retry budget is exactly the split issue #8 settled: the
question is who spends max_attempts, and a probe does send a real
request. A test now pins it so the claim cannot drift again.

Also drops the planned "woke up" log line. Each wait round already
logs on entry with its duration, and a still-blocked wake-up logs the
next round immediately, so a second line would only double the volume.
2026-08-20 01:00:47 -04:00

24 KiB

实现计划: 熔断拒绝补齐等待档(issue #14)

  • 设计: research-wiki/designs/2026-08-19-issue14-admission-wait-policy-design.md(人类已确认 + Codex 已审)
  • 分支: feat/issue-14-circuit-open-policy
  • 版本: 1.3.0(新增配置键 + retry_after_s 语义变更)

目标

让"源不健康"不再等同于"这次调用当场判死"——补上 {SCOPE}__CIRCUIT_OPEN=fail_fast|wait 这一格准入策略,并把 retry_after_s 的语义在两个后端的五个出口上定死。

方案概述

三件事环环相扣: ①把 retry_after_s 定义为"距离确定可再试的时刻还有多久",HALF_OPEN 与准入允许一律 0.0(顺带修掉源冷却备忘被探针租约污染的 bug);②新增 circuit_open 策略键,wait 档下不抛 CircuitOpenError 而按 retry_after 睡、由 stall 预算兜底;③前置把三条治理循环里逐字复制的准入逻辑收敛成一份,否则本次修复会在 embedding/ocr 留下两个行为分叉的角落。

涉及技术: Python 3.11 asyncio、Redis Lua(EVALSHA)、pytest 双后端参数化契约测试。

保真校验适用性

适用。熔断状态机是 ARCHITECTURE.md §1.4 关键资产(蓝本 reference/Video-Tree-TRM5/adapters/breaker.pyreference/CHSAnalyzer/app/coordination/provider_gate.py),准入循环蓝本为 reference/CHSAnalyzer/app/providers/governance.py:107-285。T1 与 T2/T3 各带保真校验检查点。

文件结构

文件 动作 职责
src/polygateway/middleware/admission.py 新建 SourceAdmission(准入与无源可跑的处置,三条循环共用)+ 模块级 settle_and_release
src/polygateway/middleware/retry.py 修改 删除本地 _pick_runnable/_on_no_runnable/_settle_and_release,改用 SourceAdmission;主循环与 _attempt 不动
src/polygateway/embedding.py 修改 同上
src/polygateway/ocr.py 修改 同上(注意 _settle_and_release 原签名只有 permit)
src/polygateway/backends/memory/breaker.py 修改 _remaining(g),三处出口共用;HALF_OPEN 与授予探针恒 0.0
src/polygateway/backends/redis/breaker.py 修改 五个 Lua 出口同步(TRY_ENTER 两处、RECORD_SUCCESS/RECORD_FAILURE/RELEASE_PROBE 各一处、RETRY_AFTER 一处)
src/polygateway/config.py 修改 _CIRCUIT_OPEN 常量、GatewaySettings.circuit_open 字段、_validate_backends 元组、from_env 装载
src/polygateway/client.py 修改 构造签名 + 透传
src/polygateway/errors.py 修改 GatewayUnavailableError docstring 职责边界
tests/contracts/test_breaker_contract.py 修改 按五个出口逐个钉 retry_after_s
tests/integration/test_redis_governance_time.py 修改 Redis 真实等待变体补 HALF_OPEN 出口
tests/unit/test_backpressure.py 修改 circuit_open 行为矩阵、备忘污染回归、_nap 上界
tests/unit/test_config.py 修改 新键的合法域、缺省、两条装配路一致

关键接口(跨任务消费,此处定死)

SourceAdmission 构造与两个方法:

class SourceAdmission:
    def __init__(self, *, scope: str, sources: list[SourceConfig],
                 selector: SourceSelector, quota: QuotaGate, breaker: BreakerGate,
                 memo: SourceCooldownMemo, backpressure: BackpressurePolicy,
                 quota_full: str, circuit_open: str,
                 pacer: AdaptivePacer | None = None,
                 health_view: Callable[[str], float] | None = None,
                 now=time.monotonic, sleep=asyncio.sleep, rng=random.random) -> None: ...

    async def pick(self, reasons: dict[str, str], attempt_fails: dict[str, int]
                   ) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]: ...

    async def on_no_runnable(self, gate_rejections: int, reasons: dict[str, str],
                             clock: StallClock) -> None: ...

    async def stalled(self, clock: StallClock) -> bool: ...

quota/breaker/pacer/selector/sources 均为调用方传入的同一实例(不在 admission 内新建),因为三处 _attempt 仍需引用它们;memo 则由 admission 独占。health_view 对应 chat 的 self._health_view(由 isinstance(selector, OutcomeAwareSelector) 在 RetryMW 构造期判定一次),embedding/ocr 传 None

模块级结算函数(三处 _attempt 的 finally 与 admission 共用):

async def settle_and_release(permit: Permit, actual: int) -> None:
    """finally 专用: settle 后必 release;失败降级 warning,绝不掩盖主异常/取消。"""

睡眠时长(T5 实现,写死在 SourceAdmission._nap):

def _nap(self, hint: float, clock: StallClock) -> float:
    jitter = self._bp.poll_interval_s * (0.5 + 0.5 * self._rng())
    budget = self._bp.stall_window_s - clock.stalled_s() + self._bp.poll_interval_s
    wait = hint + jitter if hint > 0 else jitter
    return max(jitter, min(wait, budget))

hint == 0 时该式退化为 jitter,即现有 quota-wait 行为逐字不变(tests/unit/test_backpressure.py 已钉 [0.5p, 1.0p])。下界取 jitter 而非 poll_interval_s(实施期修正): 后者会把 rng → 0 那半边从 0.5p 抬到 1.0p,既有的 test_poll_jitter_bounds 当场变红;jitter 同样能在预算为负时兜住不返回负数、不忙循环。budget 加一个 poll_interval_s 是因为 _stalled 判据是 > 而非 >=(retry.py:368),恰好夹到窗口不会判死。

调用约束: _nap 必须在 stalled() 判定之后调用。若已 stall 超窗才进来,budget 为负,外层 max(poll_interval_s, ...) 会兜成一个 poll 间隔(不会返回负数),但那意味着本该判死却又睡了一轮——顺序由 on_no_runnable 保证(两条路汇合后统一判 stalled() 再 sleep)。验算示例: hint=60, stall_window=300, 已 stall 290, poll=0.05jitter∈[0.025,0.05]budget=10.05 → 返回 10.05,醒来累计约 300.05 > 300,下一轮判死。

任务清单

T0 — 分支与基线

  • 建分支 feat/issue-14-circuit-open-policy(从 main)
  • 记录基线: conda run -n PolyGateway python -m pytest tests/ -qmake check + lint-imports 全绿,记下本机本环境的用例计数(执行时实测,2026-08-19 为 988 passed / 32 deselected)。该数只作同环境参照——addopts = "-m 'not slow'" 与 Redis 可达性都会改变它,不作硬验收

验证: conda run -n PolyGateway python -m pytest tests/ -q → 全 PASS;git rev-parse --abbrev-ref HEAD → 分支名正确


T1 — 纯重构: 准入逻辑三处收敛(回滚点)

: 新建 src/polygateway/middleware/admission.py;改 middleware/retry.pyembedding.pyocr.py

要实现的行为: 把 _pick_runnable/_on_no_runnable/_stalled/_settle_and_release 从三处搬进 SourceAdmission 与模块级 settle_and_release,三条循环改为持有 SourceAdmission 实例并调用其方法。本任务不引入 circuit_open 参数(构造签名先只收 quota_full,T4 再加),控制流一字不改。

三条循环的差异只用注入表达,不留 if 分支:

差异 处理 等价性依据
调用内降权(仅 chat) attempt_failspick() 入参,内部无条件调 _demote_call_failures 传空 dict 时 demoted 为空 → return ordered 原对象返回,恒等(retry.py:148-150)
AIMD pacer(仅 chat) pacer: AdaptivePacer | None = None None 时跳过 admit()enter() 两个调用点,无副作用
_settle_and_release 签名 OCR 原为 (permit)、体内恒 settle(0);改为调 settle_and_release(permit, 0) 逐字等价
warning 文案三处都不同 归一为 "permit 结算/释放失败(不掩盖主异常)" chat retry.py:536 已是该文案;embedding embedding.py:411 为 "embedding permit …"、OCR ocr.py:448 为 "OCR permit …" 将被归一(Codex 审查补,原稿只承认了 OCR)。这是本任务唯一的可见行为变化,须在提交信息里点名
_stalled 形态 chat 已抽成方法,embedding/ocr 为内联表达式 两者语义逐字相同(已 diff 核实),统一用 SourceAdmission.stalled()

搬走 vs 共享(自审修正,这一条决定 T1 能否成立): 三处 _attempt 仍在引用 self._breaker(记账写回)、self._quota(mark_progress)、self._pacer(leave)、OCR 还有 self._selector(健康喂数,ocr.py:426)。因此这些字段不搬走,而是共享同一实例——循环保留自己的引用,构造 SourceAdmission 时把同一对象传进去(AdaptivePacer 有在途计数状态,必须是同一实例而非新建,否则 admit/enterleave 分裂到两个计数器上)。真正搬走的只有 _pick_runnable/_on_no_runnable/_stalled 三个方法与 self._memo(仅被 pick 消费)。

_attempt 的唯一改动: self._settle_and_release(permit, actual) → 模块级 settle_and_release(permit, actual),OCR 侧由 (permit) 变为 (permit, 0)。除此之外 _attempt 一行不动。原稿"三处 _attempt 本体不在边界内"的说法与"搬走 _settle_and_release"自相矛盾,此处更正。

不在边界内、须原样保留: chat 主循环顶部那次额外的 _stalled 预判(retry.py:286)、OCR 的 _gate_on_terminal(ocr.py:412)与健康喂数。

保真校验检查点: 对照 reference/CHSAnalyzer/app/providers/governance.py:107-285,确认搬运后 _pick_runnable 的候选跳过顺序(备忘 → pacer → 配额 → 熔断门)、gate_rejections 的计入规则(备忘与熔断门计入,pacer 与配额不计入)、_on_no_runnable 的三段判定顺序逐段未变。

测试要求(本任务特殊): 不新增行为用例。全套件绿是必要条件而非充分条件——它证明不了"逐字不变",故本任务额外要求一次机械差异审查: 把搬迁前后的 pick/on_no_runnable 逐语句对照,确认候选跳过顺序、gate_rejections 计入规则、reasons[]=setdefault 用法(两者语义不同,不可互换)一字未变。

已知会碰到的既有测试: tests/unit/test_health_selector.py:146 断言 client._terminal._pacer._ceiling,tests/unit/test_client.py:380 断言 ._terminal._emitter._text_cap——这两个字段必须留在 RetryMW 上(与上面"共享而非搬走"一致),否则这些用例会红。

验证:

conda run -n PolyGateway python -m pytest tests/ -q      # 期望: 全 PASS,计数与 T0 同环境基线一致
conda run -n PolyGateway make check                      # 只读: ruff format --check + ruff check
conda run -n PolyGateway lint-imports                    # 依赖铁律

不要用 make lint 做验证——它带 --fix 会自动改文件(Makefile:11),只读验证用 make check + lint-imports。用例计数只作同环境参照,不作硬验收: pytest 默认 -m 'not slow'(pyproject.toml:51),且无 REDIS_URL 时 Redis 用例 skip,计数随环境浮动。

import-linter 层级(pyproject.toml:76)允许 middleware/admission.py 依赖 ports/types/errors/sources(更内层),但不得 import 任何 backends/transports/telemetry/。搬迁后须清理三个原文件中失去引用的 import(CircuitOpenErrorQuotaGateBreakerGateSourceCooldownMemo 等),否则 ruff 报未使用导入。

  • 提交: refactor: 把三条治理循环的准入逻辑收敛为 SourceAdmission

T2 — retry_after_s 语义统一(两个后端一次到位)

: src/polygateway/backends/memory/breaker.pysrc/polygateway/backends/redis/breaker.pytests/contracts/test_breaker_contract.pytests/integration/test_redis_governance_time.py

为什么两个后端必须同一个提交(Codex 审查修正): 原稿把 memory 与 redis 拆成 T2/T3 两次提交,中间 redis 侧契约用例会处于 red。但 .claude/settings.json 注册的 pre-commit-guard.sh 在检测到 git commit 时会跑 pytest tests/ --tb=line -q(pre-commit-guard.sh:61),红态直接卡住提交。且两者本就是同一个契约的两个实现,分开提交没有独立意义。

要实现的行为: retry_after_s = "距离确定可再试的时刻还有多久"。HALF_OPEN 下探针随时可能出结果,不存在确定时刻,故 0.0;准入被允许时同样恒 0.00 = 可立即重试 是库既有约定(errors.py 与现有契约用例"健康 → 0、冷却到期 → 0")。

memory 侧: 抽私有纯方法 _remaining(g: _SourceGate) -> float(OPEN 返回 max(0.0, g.open_until - now),其余状态含 HALF_OPEN 返回 0.0),try_enter 的 HALF_OPEN 拒绝分支(memory:148)与 retry_after_s()(memory:267)改用它。_snapshot(memory:169)与授予探针(memory:114)已符合新契约,保持不变。

redis 侧共六个返回格,逐处点名(改前先确认行号仍对得上):

脚本 位置 现状 改为
TRY_ENTER HALF_OPEN 拒绝 redis:44 probe_until - now 0
TRY_ENTER 授予探针 redis:53 tonumber(ARGV[2])(= probe TTL) 0
RECORD_SUCCESS fencing 未命中 redis:124 half_open 取 probe_until half_open 记 0(只 OPEN 取 open_until - now)
RECORD_FAILURE fencing 未命中 redis:155 同上 同上
RELEASE_PROBE fencing 未命中 redis:255 同上 同上
RETRY_AFTER redis:275 half_open 取 probe_until half_open 记 0

后四行修的是既有的双后端语义分叉(memory _snapshot 对非 OPEN 一律 0.0),与本 issue 同源,由契约测试盲区掩护至今——现有用例只钉"第二个进入者被拒",没钉它拿到什么数。

保真校验检查点: 状态机转换、双通道开路判据、_cooldown_eff 指数退避、epoch fencing 匹配条件、Lua 的原子性结构与 redis.call('TIME') 服务器时钟口径一律不动——本任务只改"对外报几"这一件事,即 return 元组里 retry_after_ms 那一格。改完逐脚本与 memory 实现对照走一遍状态机。

测试要求(先失败后通过,tests/contracts/ 双后端参数化,一次覆盖 memory + redis):

  • HALF_OPEN 被拒: decision.retry_after_s == 0.0decision.state is GateState.HALF_OPEN
  • 授予探针的决定: retry_after_s == 0.0
  • record_* 在 fencing 未命中且门处于 HALF_OPEN: GateUpdate.retry_after_s == 0.0(须同时断言 applied is Falsestate is HALF_OPEN,否则用例可能在别的分支上误绿)
  • gate.retry_after_s(("s1",)) 探针在途时返回 0.0
  • 现有 test_retry_after_semantics / test_retry_after_takes_min_across_sources 保持绿(OPEN 语义未变)

Redis 时间语义变体: 契约层用 clock.advance() 的用例在 redis 参数下会 skip(conftest.py:39SkipClock 哨兵),故须在 tests/integration/test_redis_governance_time.py 补 1:1 真实等待变体(既有约定: 不缩放时长)。该文件的 test_meta_variants_cover_all_time_cases(:56)会机械拦截漏配,漏了就红。

验证:

conda run -n PolyGateway python -m pytest tests/contracts/test_breaker_contract.py -q     # 双后端全 PASS
conda run -n PolyGateway python -m pytest tests/integration/test_redis_governance_time.py -m slow -q

第二条必须带 -m slow: pyproject.toml:51addopts = "-m 'not slow'" 默认排除真实等待变体,不加就是空跑(该文件单跑 12-15 分钟)。需真实 Redis(db3),不 mock Lua 行为。

  • 提交: fix: 把 retry_after_s 定义为确定可再试时刻,HALF_OPEN 归零(双后端)

T3 — (已并入 T2)

原计划把 redis 侧拆为独立任务,因 pre-commit hook 会拦截中间红态而合并进 T2。此编号保留以免后续引用错位。


T4 — 新配置键 {SCOPE}__CIRCUIT_OPEN

: src/polygateway/config.pysrc/polygateway/client.pysrc/polygateway/middleware/admission.pyembedding.pyocr.pytests/unit/test_config.py

要实现的行为: 与 quota_full 逐项同构,不发明新形状。

位置 改动
config.py 常量区 _CIRCUIT_OPEN = frozenset({"wait", "fail_fast"}),紧邻 _QUOTA_FULL
GatewaySettings 新增字段 circuit_open: str,无默认值(与该类全部既有字段一致),位置紧随 quota_full
_validate_backends 校验元组加一行 ("circuit_open", _CIRCUIT_OPEN)
from_env circuit_open=_load_choice(env, f"{scope_u}__CIRCUIT_OPEN", _CIRCUIT_OPEN, "fail_fast")
client.py GatewayClient.__init__circuit_open: str = "fail_fast";from_settings 透传 settings.circuit_open
admission.py 构造收 circuit_open,同 quota_full 做构造期域校验并抛 ValueError
embedding.py / ocr.py 两个客户端的构造签名与"从 GatewayClient 派生"路径(embedding.py:561ocr.py:574 邻域)各透传一处

缺省取 fail_fast(人类 2026-08-19 决策): 保证控制流对存量下游不变。

测试要求(先失败后通过):

  • 缺省档: 不设该键时 settings.circuit_open == "fail_fast"
  • 合法域: 设为 "nope"from_env 与直接构造两条路都抛 ValueError 且消息点出键名/字段名
  • 两条装配路一致: from_env 与直接构造同一取值产出同一行为
  • dataclasses.replace(settings, circuit_open="wait") 仍通过全部装配守卫
  • 透传链: 从 GatewaySettings 一路到三条循环的 SourceAdmission 实例上取值正确

验证:

conda run -n PolyGateway python -m pytest tests/unit/test_config.py tests/unit/test_client.py -q
  • 提交: feat: 新增 {SCOPE}__CIRCUIT_OPEN 策略键(缺省 fail_fast)

T5 — on_no_runnable 按原因分派 + _nap

: src/polygateway/middleware/admission.pytests/unit/test_backpressure.py

要实现的行为: 把现状串行的两个分支改为按拒绝原因分派(伪码见设计 §3.3)。要点:

  1. gate_rejections == len(sources)(全部因熔断类原因被拒)时,fail_fastCircuitOpenError(现行为),waithint = await breaker.retry_after_s(names)不抛;
  2. 否则(至少一源是被配额/AIMD 挡的)走 quota_full 分支,hint = 0.0;
  3. 两条路汇合后统一判 stalled(),再 await sleep(self._nap(hint, clock))

必须避免的坑: 若只把第一分支改成"wait 时不抛"而不做分派,控制流会掉进 quota_full 分支——quota_full=fail_fast 的调用方会看到熔断等待被误报成 reason="quota_exhausted"

可观测性: wait 档每轮进入等待时 logger.info 一条(scope、per_source_reasons、本次睡眠秒数)。只此一条,不打"醒来"那条(实施期决定): 每一轮等待各自留痕,时间线已可完整还原,而醒来后若仍被拒会立刻打下一条——补一条"醒来"只会让日志量翻倍且信息重复。不新增遥测列(等待期不发请求,无 attempt 行可记;调用级总耗时下游可自测)。

计时归属: 睡眠发生在 clock.attempting() 之外,自动计入 stall 账,与 ARCH §7.3"熔断冷却属非生产性等待"一致——无需改 StallClock

取消穿透: _nap 只做算术,睡眠是裸 await self._sleep(...),不得包 try/except

测试要求(先失败后通过,注入时钟/睡眠/rng 保持确定性):

  • circuit_open=wait + 全源开路 → CircuitOpenError,而是按 retry_after 睡;冷却结束后拿到探针并成功返回
  • circuit_open=wait + quota_full=fail_fast + 全源开路 → quota_exhausted(这是上面那个坑的钉子)
  • circuit_open=wait + 冷却比 stall 预算还长 → 抛 AllSourcesExhausted(reason="stalled"),per_source_reasonscircuit_open,累计墙钟 ≤ stall_window_s + poll_interval_s
  • circuit_open=wait + 源持续 force_openretry_exhausted 而非 stalled(整分支审查发现,原稿写错): 冷却结束后放行的探针是真实尝试,失败照样烧一格 max_attempts,故两个预算里先耗尽的那个决定 reason
  • 混合原因(部分 circuit_open + 部分 rate_limited)→ 走 quota 分支,per_source_reasons 如实混合
  • hint == 0 时睡眠落在 [0.5p, 1.0p](现有 quota-wait 行为逐字不变)
  • wait 档等待中收到 CancelledError → 逐字穿透,in-flight permit 已释放
  • circuit_open=fail_fast(缺省)下,全部现有用例逐字绿
  • 备忘污染回归(issue #14 §1.3): 探针成功后 memo.active(源名) 为 False,该源立即重新可选——此用例由 /tmp/.../probe_repro.py 的复现脚本转化而来,在 T2 之前必然 red

验证:

conda run -n PolyGateway python -m pytest tests/unit/test_backpressure.py tests/unit/test_retry.py -q
conda run -n PolyGateway python -m pytest tests/ -q      # 全套件
  • 提交: feat: circuit_open=wait 下熔断拒绝改为等待而非当场判死

T6 — errors.py 职责边界补写

: src/polygateway/errors.py

要实现的行为: 改写 GatewayUnavailableError 的 docstring。现文"业务侧 catch 本类做延期重投(CHS arq 模式)"读起来像鼓励每个下游各写一份重试逻辑;改为明确边界——调用级的重试/退避/换源/等待全部在库内,本异常表示库的调用级预算(重试预算或 stall 预算)已耗尽;下游若要再投,那是任务级重试,语义与调用级重试不同(ARCH §7.2 单层重试原则)。

retry_after_s 那句保留并补一句: 它是"距离确定可再试的时刻",0 表示无确定等待(可立即重试)。

测试要求: 纯 docstring,无行为变更。验收为 tests/unit/test_errors.py 保持绿。

验证: conda run -n PolyGateway python -m pytest tests/unit/test_errors.py -q

  • 提交: docs: 收回 GatewayUnavailableError 的重试职责边界

T7 — 文档同步

: research-wiki/ARCHITECTURE.mdREADME.mdCHANGELOG.md、Gitea wiki。

目标 内容
ARCH §7.4 增补本次决策: 三条缺陷的成因、retry_after_s 的契约定义(五个出口)、circuit_open 策略键与缺省理由
ARCH §9 配置面 登记 {SCOPE}__CIRCUIT_OPEN
README 配置表新增该键;明写"单源 scope 建议配 wait"——缺了这句,这个开关等于不存在;核对安装命令的版本约束是否需要跟着改
CHANGELOG 记 1.3.0,retry_after_s 语义变更给"请先读这一条"待遇(缺省档下 CircuitOpenError.retry_after_s 在全源 HALF_OPEN 时由探针租约剩余变为 0)
Gitea wiki research-wiki/docs-convention.md §2 清单同步

验证: 人工逐项核对上表;grep -n "CIRCUIT_OPEN" README.md research-wiki/ARCHITECTURE.md 各有命中。

  • 提交: docs: 记录熔断等待档与 retry_after_s 契约

T8 — 合并前独立验证

  • 全新上下文 verifier subagent(verification-before-completion),逐条核对: 设计每一节是否有对应实现、五个 retry_after_s 出口是否都改到、三条循环行为是否一致、测试证据是否都是"先失败后通过"
  • conda run -n PolyGateway make check + conda run -n PolyGateway lint-imports 全绿(不用 make lint,它带 --fix 会改文件)
  • conda run -n PolyGateway make test 全套件绿 + 覆盖率 ≥ 80%
  • Redis integration 套件在真实 Redis 上绿,含 -m slow 的时间语义变体(默认 addopts 会排除它)
  • requesting-code-review 走一次整分支审查
  • finishing-a-development-branch: --no-ff 合并 main,合并后在 main 上重跑 lint 与全套件

: 发布(tag/构建/上传 registry/建 Release)按 CLAUDE.md §4.4.1 九步走,不在本计划范围,需人类确认后单独执行。

自审记录

  • 设计每一节到任务的映射: §3.1→T2+T3、§3.2→T4、§3.3→T5、§3.4→T1、§3.5→T4(缺省值)+T7(文档)、§3.6→T6、§4 行为矩阵→T5 测试、§5 测试策略→T2/T3/T5、§6 非功能→T5(取消/计时/上界)
  • 无 TBD/TODO/"适当的错误处理"类占位
  • 跨任务消费的 SourceAdmission 签名、settle_and_release_nap 公式已在"关键接口"写出实际代码
  • 任务顺序有硬依赖: T1(收敛)必须先于 T5(在单一位置加语义)。原 T2/T3 拆分已合并——pre-commit hook 跑全套件,任何跨提交的红态都会被拦