Skip to content

fix(quota): bind the receipted Turn to the scheduler fallback hint - #7955

Merged
loopx-agent merged 3 commits into
loopx-project:mainfrom
gcl-coder:fix/scheduler-fallback-turn-binding
Nov 3, 2026
Merged

loopx-agent merged 3 commits into
loopx-project:mainfrom
gcl-coder:fix/scheduler-fallback-turn-binding

Conversation

@gcl-coder

Copy link
Copy Markdown
Contributor

问题

当 automation_update 不可用而 apply_needed=true 时,决策会投影 app_automation.fallback_hint,让宿主执行:

loopx-apply-rrule ... --turn-instance-id <Turn>

这个 fallback 的唯一用途,就是带着当前这次决策重放一遍 loopx quota should-run(scripts/codex_app_apply_rrule.py 会把这个 Turn 原样传给重放查询)。所以它必须拿到"发起这次动作的那条 receipt 对应的 Turn"。

CLI 侧的绑定函数 bind_scheduler_followup_cli_routes() 却没有覆盖它:

  • ack_hint、failure_hint 都会注入 receipt 的 turn_instance_id,并写 route_binding.turn_instance_bound;
  • fallback_hint 分支只补一个 --registry 就 continue,把投影里那份 ${LOOPX_TURN:?} 原样留在 cli_args 里。

结果是两条真实故障:

  1. 原生 Windows 上这条命令根本无法执行。 PowerShell 不认 ${LOOPX_TURN:?}:它会把 LOOPX_TURN:? 当成一个未定义的变量名,展开成空字符串,于是 --turn-instance-id 收到空值。scripts/codex_app_apply_rrule.py 的 _scheduler_hint_turn_instance_id() 立刻以 --turn-instance-id must not be empty 退出,RRULE 应用、bound ACK 全部走不到。
  2. 带 receipt 的决策丢掉了自己的 Turn。 ACK / failure 的 follow-up 已经绑定具体 Turn,同一条决策里的 fallback 却退回到"让你自己去 shell 里提供 Turn",一旦宿主的 LOOPX_TURN 与 receipt 的 Turn 不同(或未设置),重放到的就不是这条 receipt —— 丢的是 Todo 绑定、capability 与结算身份。

问题不在投影阶段:hint_followup.ts 里那个字面量只是投影默认值,权威渲染点应该在 CLI 边界,也就是 cli_args 这个宿主原样执行的完整向量。

复现(改前)

python3 - <<'PY'
import json
from pathlib import Path
from loopx.control_plane.quota.live_decision import bind_scheduler_followup_cli_routes

payload = {"scheduler_hint": {"app_automation": {"fallback_hint": {
    "cli_args": ["loopx-apply-rrule", "--automation-id", "loopx",
                 "--turn-instance-id", "${LOOPX_TURN:?}"],
    "args": {"turn_instance_id": "${LOOPX_TURN:?}"},
}}}}
bind_scheduler_followup_cli_routes(payload, registry_path=Path("/tmp/r.json"),
                                   runtime_root=Path("/tmp/rt"),
                                   turn_instance_id="turn-receipt-001")
print(json.dumps(payload["scheduler_hint"]["app_automation"]["fallback_hint"]))
PY

改前:cli_args 末尾仍是 "${LOOPX_TURN:?}",route_binding 里没有 turn_instance_bound,传入的 turn-receipt-001 被完全忽略。

改动

1. loopx/control_plane/quota/effect_program.py — 把"Turn 引用"的两件事提取成单一来源:

  • turn_reference(turn_instance_id=None):有具体 Turn 就用它,否则返回当前宿主 shell 能展开的引用(POSIX ${LOOPX_TURN:?},原生 Windows $LOOPX_TURN);
  • is_turn_reference(value):判断一个 token 是 shell 引用还是具体 Turn(${LOOPX_TURN:?}、${LOOPX_TURN}、$LOOPX_TURN 都算引用,具体 Turn 才算具体值);
  • turn_instance_arg() 改为复用 turn_reference(),行为不变,只是不再各写一份。

2. loopx/control_plane/quota/live_decision.py — fallback_hint 的绑定与 ACK/failure 对齐:

  • 有 receipt Turn:用具体 Turn 覆盖 cli_args 里的 --turn-instance-id(权威值优先);
  • 没有 receipt Turn:把投影留下的 shell 引用按当前宿主重新渲染,POSIX 输出保持 ${LOOPX_TURN:?} 逐字节不变,原生 Windows 得到 $LOOPX_TURN;
  • 投影里干脆没有 --turn-instance-id 时补上;
  • args.turn_instance_id 一律镜像 cli_args 里最终生效的值,避免说明字段和实际执行的命令不一致;
  • route_binding 增加 turn_instance_bound。

3. docs/quota-allocation.md — 原文只写"ACK 与 failure 绑定 receipt Turn",补上 fallback 的宿主渲染与 Turn 绑定说明。

4. tests/control_plane/test_scheduler_fallback_hint.py — 五条用例:

  • 有 receipt → 具体 Turn 写进 cli_args + args,turn_instance_bound=true;
  • 无 receipt → POSIX 保持 ${LOOPX_TURN:?},模拟 Windows 宿主得到 $LOOPX_TURN,turn_instance_bound=false;
  • 投影缺 --turn-instance-id → 补上 receipt Turn;
  • 投影已给具体 Turn 且无 receipt → 原样保留,不被宿主的 shell 引用覆盖;
  • 两种 POSIX 写法(${LOOPX_TURN:?}、${LOOPX_TURN})在 Windows 上都渲染成 $LOOPX_TURN。

验证

PYTHONPATH=. python -m pytest -q \
  tests/control_plane/test_scheduler_fallback_hint.py \
  tests/control_plane/test_turn_instance_arg.py
  • 本次新增/相关的 17 条通过;本机跑整文件时另有 3 条因本地沙箱拒绝 TypeScript Effect runtime(EffectRuntimeHostPermissionError)失败,改动前同样失败,与本 PR 无关。
  • ruff check 三个改动文件:错误数与改动前基线一致(均为既存 I001/FLY002/UP017/TRY004)。
  • 本机无 pwsh,原生 Windows 的端到端行为由托管 Windows lane 覆盖。

提交拆分

三个提交,每个都能独立通过测试:

  1. refactor(quota): give the Turn reference a single owner —— 只把宿主判断收口成 turn_reference / is_turn_reference,行为不变;
  2. fix(quota): bind the receipted Turn to the scheduler fallback hint —— 绑定修复 + 五条测试;
  3. docs(quota): document the fallback hint Turn binding —— 文档。

范围

只改 fallback follow-up 的 Turn 绑定与宿主渲染:一个 helper、一处绑定分支、一处文档、五条测试。不改 ACK/failure 语义,不改结算计划,不改 TS 投影(投影所在 runtime 的平台不等于 Agent 实际执行命令的宿主,且在 CLI 边界统一渲染才能保证 cli_args 与 args 一致)。

`turn_instance_arg` decided the host shell inline, so nothing else could
ask for the same reference. Move that decision into `turn_reference` and
`is_turn_reference`, and keep `turn_instance_arg` as the string form of
it. A caller that builds an argument vector can now render exactly the
reference the settlement commands use, instead of hard-coding one form.

No behavior change: POSIX still gets `${LOOPX_TURN:?}` and native Windows
still gets `$LOOPX_TURN`.

Validation: pytest -q tests/control_plane/test_turn_instance_arg.py.
Signed-off-by: ChengLong GU <88359731+gcl-coder@users.noreply.github.com>
The fallback follow-up replays the current decision through
`loopx quota should-run`, but only the ACK and failure hints bound the
receipt's Turn. The fallback kept the projected `${LOOPX_TURN:?}`, so a
native Windows host expanded it to an empty value and `loopx-apply-rrule`
exited with `--turn-instance-id must not be empty`; a receipted decision
also lost the Turn that owns its Todo, capability and settlement
identity.

Bind the receipted Turn exactly like ACK and failure, and otherwise
re-render the reference for the shell that runs the command. `args`
mirrors the value the vector carries, so the advertised command cannot
disagree with what the host executes, and `route_binding` reports whether
the Turn came from the receipt.

Validation: pytest -q tests/control_plane/test_scheduler_fallback_hint.py
tests/control_plane/test_turn_instance_arg.py -> 17 passed.

Scope: one binding branch, five tests, no ACK/failure or settlement
change.

Signed-off-by: ChengLong GU <88359731+gcl-coder@users.noreply.github.com>
The allocation doc said only ACK and failure hints bind the receipt's
Turn. Record that the fallback hint carries the same registry binding,
that its `--turn-instance-id` value is the reference the current host
shell expands, and what `route_binding.turn_instance_bound` reports.

Docs only, no code change.

Signed-off-by: ChengLong GU <88359731+gcl-coder@users.noreply.github.com>

@loopx-agent loopx-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewer: model_agent; gpt-6.1-sol; OpenAI; runtime_reported; reasoning_effort=max

未发现本次 Turn 参数绑定改动的阻塞缺陷。审查 head:c6954820393c4f4132c2d403c4cd0295e14eed54;实际 merge base:f736bd2b06b6f321ee16d0a3d924fc9294a7dd7e。这是绑定修复的 APPROVE,不是完整宿主 fallback 资格或合并授权。

动机 / Motivation

在宿主更新工具不可用时,使用 scheduler fallback 的 Agent 需要重放发起动作的原轮次。

旧命令留下环境变量引用,环境中的 Turn 不同就会重放另一轮;本次把已经知道的原 Turn 直接放入完整参数向量,避免人工辨认和补写身份。

本轮真实 File/SQLite 对照确认了参数绑定和原回执重放;显式关闭 context delivery 的 dry-run 继续产生原 Turn 的 ACK 参数,未修改宿主文件或扣额。

本次不更改调度决策、执行准入或宿主授权,也不声称已经完成原生 Windows、实际 App 更新、模型采用和长期成本资格。

默认 context delivery 下 fallback 读取的 reader 响应兼容问题,以及 Windows 实机和实际宿主应用资格,仍未由本次绑定修复结清。

改动思路 / Approach

复用现有 CLI 运输边界和 Turn 引用 helper;receipt 提供身份,TypeScript 继续拥有调度和准入决策。当前 PR 的边界是 fallback 参数与原 Turn 的绑定,不增加第二份状态或调度 owner。

只修投影中的固定字符串不足以绑定原回执;把整个 scheduler 搬到 Python 也没有必要。绑定器优先使用当前调用的具体 Turn;没有具体值时,只重新渲染已知 shell 引用,保留投影已有的具体值。args 的既有 Turn 字段镜像执行向量,route_binding 是派生诊断,不能产生权限。

这个本来就应成立的身份不变量不是可选策略。保留原 fallback 的激活条件和宿主授权边界,比新增开关或恢复框架更小。原 ACK/failure renderer、原生 scheduler、持久 receipt 与实际宿主写入 owner 均未迁移;两个 App 投影都检查了相同绑定。

具体改动 / Changes

独立依据是修改前的 docs/quota-allocation.md,spec_revision f736bd2b06b6f321ee16d0a3d924fc9294a7dd7e,其中完整 follow-up 向量、原轮身份、宿主更新和 ACK 的先后顺序分别保持独立。以下是条款的审计锚点,不是新增协议:

  • same-turn-followup:implemented。真实 guard 返回的 fallback 和后续 replay 保留原 Goal/Agent/Todo/Turn;与错误环境变量对照有区分度。
  • host-effect-boundary:implemented。只改变参数运输,实际验证用 dry-run;没有宿主文件写入、ACK 执行或业务扣额。
  • ordinary-and-unbound:implemented,有界覆盖。既有 turn_instance_arg 测试、无 receipt 的 POSIX/模拟 Windows、已有具体投影和无 App fallback 路径覆盖了共享 helper 的边界;Windows 实机仍未验证。

完整差异四文件 +220/-6:生产两文件 +71/-4,测试 +142,文档 +7/-2。

关键代码讲解

  • turn_reference / is_turn_reference:收口已有 shell 引用表示;固定 token 集只用于命令运输,不从文字推导 Todo 状态或授权。
  • _bind_followup_turn_argument:有具体原 Turn 时覆盖参数;否则只替换 shell 引用。缺少选项或末尾缺值时补齐,返回最终值供镜像使用。
  • bind_scheduler_followup_cli_routes:沿既有 fallback 分支注入 registry 和 Turn。已有 registry 的短路、ACK/failure 的原构造保持;没有引入新决策分支。
  • 未变的 _load_scheduler_hint 是实际下游消费者,不能把绑定器的单测等同于它或 App 已成功运行。

对主干的风险 / Risks

最强反例是命令字段看似正确,下游仍重放另一个 Turn。相同合成输入、真实 File/SQLite 和原脚本 dry-run 显示:base 使用 ${LOOPX_TURN:?},错误环境变量进入 ACK;head 的具体值不受环境影响,native receipt 为 replayed 且保留原身份,ACK 参数也保持该 Turn。没有执行真实宿主变更或把 dry-run 当安装采用。

有一项必须单列的既有缺口:默认调用返回 context reader 响应,原 _load_scheduler_hint 只寻找内联 scheduler,于是打印 apply_needed=false / rrule=unknown。base/head 都有这个行为;强验证另用明确 --context-delivery off 的具名 CLI 路由,才贯通到实际 dry-run。这里批准的是当前身份绑定增量,不能把最初的退出零写成完整 fallback 成功;reader 消费兼容仍由既有调用方修复。

本轮三个 focused 模块 26 PASS / 6 FAIL;base 为 21 PASS / 相同 6 FAIL。六个完整失败块只归一化精确 fixture/checkout 路径和 pytest 对象地址后,均为 6,401 字节、SHA-256 1eb55323c1d46bba1edf3b5344a5fc8622f2e50561a972b19464655b0509c0fb;出错的宿主绑定测试和对应读取 owner 未改。保留 FAIL,不宣称套件全绿,也不以作者的另一组环境结果覆盖本轮观察。

Ruff PASS。标准 premerge 为 6 direct / 18 selected,gate PASS;其中原 maintainability ratchet 的 raw advisory FAIL 仍保留。独立 CQR 仍保留 required focused 失败,不把它降为 optional;代码评审结论与这个严格质量门分别判断。没有查询、轮询或等待远端 CI。

语义与 CI 对齐

这是既有默认身份不变量的修复,没有 opt-in/default-off 声明或新的通用状态分类。diff-scoped advisory 和完整语义检查通过,不能替代上述反例。没有改通用义务文本、拒绝优先级、配额、租约或结算;fallback 的可用性不等于执行授权。文档同时说明了具体 Turn 与无 receipt 的引用方式;“always reference”的文字应结合下一句具体绑定阅读。

我的整体评价 / Verdict

APPROVE 当前绑定增量,problem_context 为 justified_increment。long_horizon 与 user_experience 在这个边界上 improved:原承诺和身份不再依赖错误的环境变量,沿既有命令执行不需额外确认。剩余默认 reader 消费、Windows 实机及实际宿主应用范围按上文保留,不认证端到端宿主采用。

复用现有 CLI 运输边界和 Turn 引用 helper;receipt 提供身份,TypeScript 继续拥有调度和准入决策。当前 PR 的边界是 fallback 参数与原 Turn 的绑定,不增加第二份状态或调度 owner。

有界整理已应用:共享引用表示和本地参数修改 helper 足够;不需要新版本化 decoder、状态副本或额外任务。已检查 CLI、HTTP、scheduler 投影与脚本消费者,没有渲染页面变更,视觉评审不适用。视觉未验证范围:none;实际 App/Windows 执行另列为运行资格缺口。严格 CQR 和合并 readiness/权限仍独立,未合并、未 dismiss。

English verdict: APPROVE — c695482, limited to the receipted-Turn binding. Real File/SQLite guards and explicit context-off fallback dry-runs preserve the original receipt and ACK identity; the base follows the conflicting ambient Turn. Premerge passes; six unchanged focused failures and strict CQR remain failed. Default reader-response consumption, native Windows and live host application remain unqualified.

@loopx-agent
loopx-agent merged commit 9df9234 into loopx-project:main Nov 3, 2026
28 of 40 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants