Skip to content

feat(turn): admit one executing Turn per Turn lane - #4485

Merged
huangruiteng merged 1 commit into
mainfrom
codex/turn-lane-single-executor-20260916
Sep 16, 2026
Merged

huangruiteng merged 1 commit into
mainfrom
codex/turn-lane-single-executor-20260916

Conversation

@huangruiteng

Copy link
Copy Markdown
Collaborator

What changed

A Turn lane -- one agent working one goal -- could run two executors at once.
Each invoked its own host, wrote its own delivery, and spent its own quota slot,
so one bounded question got two answers: a local probe driving two concurrent
turn run-once processes for the same goal and agent produced two
validated_progress deliveries and two quota spends.

The Turn driver now admits exactly one executing Turn per lane. The lane is
fenced for the whole executing section by a kernel lock in
loopx/control_plane/turn_driver/lane_fence.py, and the second Turn stops
before the journal, the host, and quota with a typed refusal:

{
  "status": "unavailable",
  "reason": "turn_lane_in_flight",
  "remediation": ["wait_for_in_flight_turn"],
  "host": {"executable": "not_invoked", "kind": "<planned kind>"},
  "in_flight": {"agent_id": "...", "operation": "loopx_turn_lane", "pid": 0, "acquired_at": "..."},
  "effects": {"host_invoked": false, "state_written": false, "quota_spent": false, "scheduler_acknowledged": false},
  "quota_slot_spend_count": 0
}

Changed surfaces

Runtime/API: loopx/control_plane/turn_driver/lane_fence.py (new -- lane
identity, the kernel-lock single-flight, the public-safe holder readback, and
the refusal packet), loopx/control_plane/turn_driver/executor.py (one import,
one decorator, and the projection of the lane readback; the entry module stays
inside its 1500-line budget), loopx/semantics/inventory_v0.json
(regenerated).

Docs: docs/reference/protocols/loopx-turn-v0.md gains "One Executor Per Turn
Lane" -- the lane definition, the typed refusal, kernel-lock crash release, the
preview exemption, and lane independence.

No frontend change is needed: the Chat and Dashboard surfaces already render
the run-once status, reason, and remediation this refusal reports, and no field
of the managed-executor readback changed.

Behavior change disclosure

Two overlapping Turns for one lane no longer both execute; the second is
refused until the first settles. A preview (execute=false) takes no fence and
always answers, and lanes are scoped per goal and agent, so an unrelated goal
or agent Turn is unaffected.

The refusal fails closed on identity: an envelope without an agent_id still
gets a lane (unattributed) rather than no fence at all, because two
unattributed Turns on one goal are exactly the overlap this exists to refuse.

Crash release is the kernel lock's: a killed or crashed Turn releases the lane
instead of leaving a claim no later Turn can enter.

Validation

  • two-process concurrency probe (local): before, two validated_progress
    deliveries and two quota spends for one lane; after, one committed (exit 0)
    and one unavailable (exit 1), with exactly one delivery marker and one
    quota spend in the run index
  • pytest tests/test_turn_lane_fence.py tests/test_loopx_turn_driver.py tests/test_loopx_turn_managed_step.py tests/test_dsh_goal_mode.py tests/test_turn_default_host_binding.py tests/test_turn_managed_executor_binding.py -> 198 passed
  • public smokes: loopx-turn-dsh-e2e, loopx-turn-dsh-builtin-host-e2e,
    loopx-turn-managed-default-flow, loopx-turn-managed-executor-binding,
    loopx-turn-managed-step-self-heal, dsh-turn-host-adapter (11 checks) ->
    passed
  • ruff check on the changed modules -> clean;
    scripts/generate_semantic_inventory.py --check -> up to date;
    examples/docs-governance-smoke.py -> ok;
    examples/control_plane/control-plane-maintainability-ratchet-smoke.py -> ok
  • canary premerge --from-git-diff -> see the review below for the recorded
    result

Future-facing pass

Applied. The fence and its refusal packet live in their own module
(lane_fence.py) rather than in the executor entry, and the readback follows
the existing *_projection(journal) seam, so a later Turn refusal adds its
field in one place. The related bounded refactor considered here -- moving the
all-false effect vocabulary out of the entry module -- was judged out of this
change's boundary and is not included.

Two executing Turns for one goal and agent each invoke a host, write a
delivery, and spend a quota slot, so one lane could answer one bounded question
twice: a local probe running two concurrent `turn run-once` processes produced
two deliveries and two quota spends.

The executor now fences the lane for the whole executing section with a kernel
lock and refuses the second Turn with the typed `turn_lane_in_flight` refusal
before the journal, the host, and quota. The refusal projects the holder
(agent, operation, pid, acquired_at) and never the runtime path, lock id, or
lock policy; previews take no fence, and lanes stay agent- and goal-scoped.

The fence lives in `turn_driver/lane_fence.py` so the executor entry keeps its
line budget: the driver grows by one import, one decorator, and the projection
of the lane readback, and the semantic inventory is regenerated for the new
named constants.

Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>

@huangruiteng huangruiteng left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Approval conclusion (author-owned PR; GitHub blocks formal self-approval)

动机

一个 Turn lane(一个 agent 做一个 goal)原本可以同时跑两个执行器:各自调自己的 host、各自写一条投递、各自花一个 quota slot,于是同一个 lane 对同一个有界问题给出两个答案。这不是推演:本地探针在同一个 goal+agent 上并发起了两个 turn run-once,结果拿到两条 validated_progress 投递和两次 quota spend。已经落地的 #4481 解决了「一个管家绑定只能有一个托管执行器」,但 Turn 入口本身仍然可以并发进入。这个 PR 把「一个 lane 同时只允许一个执行中的 Turn」变成可执行的契约:第二个 Turn 在碰 journal、host、quota 之前就以 typed refusal 结束,并且这个 refusal 能说出是谁正持有这个 lane。

改动思路

  1. 这块契约单独交给 loopx/control_plane/turn_driver/lane_fence.py:lane 身份(goal + agent;envelope 没有 agent_id 时用 unattributed 而不是「不设围栏」)、内核锁 single-flight、公开安全的 holder 读回(agent_id / operation / pid / acquired_at,绝不带锁 id、运行时路径、锁策略)、以及 refusal 记录。
  2. 围栏以 decorator 形式挂在 run_loopx_turn_once 入口,跨整个执行段持有,因为「一个执行器」是一个关于整段执行的断言,而不是某一个 phase 的断言。只有 execute=True 才取锁:preview 不调 host、不花钱,必须永远能回答。
  3. refusal 复用现有拒绝语义,不发明第二套:status: unavailable + reason: turn_lane_in_flight + remediation: [wait_for_in_flight_turn] + host.executable: not_invoked(没有构建过的 host 不冒名)+ 四项 effects 全 false + quota_slot_spend_count: 0;in_flight 读回走 executor 既有的 *_projection(journal) 缝,payload 形状仍只有一个 owner。
  4. 协议文档新增 "One Executor Per Turn Lane",写清 lane 定义、拒绝报文、崩溃即释放(进程被杀,内核锁自动释放,不会留下后来者进不去的陈旧 claim)、preview 豁免、以及 lane 之间互不影响。

具体改动

  • 新增 loopx/control_plane/turn_driver/lane_fence.py:lane 命名(<agent>-<hash12>.lane)、turn_lane_singleflight()、turn_lane_holder_readback()、turn_lane_in_flight_record()、turn_lane_in_flight_projection()、single_executor_per_turn_lane()。
  • loopx/control_plane/turn_driver/executor.py:一个 import、一个 decorator、一行 readback projection。entry 模块仍留在 1500 行预算内——自 review 的第一遍修复就是把整个 fence 从 executor 搬进 lane_fence,否则 maintainability ratchet 会红(该模块在我动手前是 1498 行,已经很接近上限)。
  • tests/test_turn_lane_fence.py:4 个用例——第二个执行 Turn 被拒且带 holder 读回、preview 不取围栏、lane 按 goal+agent 隔离(含 unattributed 回退)、holder 读回只含公开安全字段。
  • docs/reference/protocols/loopx-turn-v0.md 新增章节;loopx/semantics/inventory_v0.json 因新增命名常量而重生成。

关键内容讲解

  1. 围栏是「执行中」而不是「计划中」:execute=False 的决策不取锁。这条不是省事,而是语义:preview 不调 host、不写 journal、不花 quota,它没有需要被串行化的副作用,让它因为别人在执行而无法回答,只会把只读查询变成随机失败。
  2. 拒绝发生在有副作用之前,并且读回是公开安全的:refusal 在 journal、host、quota 之前返回,所以它必须如实声明 host_invoked/state_written/quota_spent/scheduler_acknowledged 全 false、quota_slot_spend_count: 0。holder 读回只投影 agent/operation/pid/acquired_at;锁文件里的 lock id、运行时路径、策略名都不出进程,测试里也钉了这一点。
  3. 失败关闭在身份缺失处:没有 agent_id 的 Turn 仍然拿到一个 unattributed lane。反过来做(没有身份就不设围栏)会让最需要保护的场景——两个都不带归因的 Turn 打同一个 goal——正好绕过围栏。

对主干的风险

advisory(本机围栏的边界):围栏是 runtime root 下的内核锁,所以它串行化的是同一台机器上的执行器;两台机器共享同一个 goal+agent 不会被它拒绝。协议文档写了这个 blast radius;跨机 lease 属于另一个变更(真正需要它的是共享 runtime root 的部署形态,不是本地管家)。这是本 PR 最需要在后续被记住的一点。

advisory(refusal 多了 in_flight 字段):执行中的第二个 Turn 现在返回 status: unavailable,并在原有 reason/remediation 之外多一个 in_flight。字段只在拒绝路径上追加,tests/test_turn_lane_fence.py 同时钉住了 preview 与 committed 路径保持原样。

未验证面:真实 DeepSeek 端点的并发(本路径 hermetic,验证的是围栏与读回,不是端点行为);跨机共享 runtime root 的并发(见上);非本机文件系统的锁语义。变更范围是 5 个文件(新增模块 + entry 接线 + 测试 + 协议文档 + 重生成的语义清单),没有 CLI 参数、schema、权限或前端变化。

我的整体评价

APPROVE。这是一个边界清楚、代价很小、并且带负向证据的并发修复:用已有的内核锁与已有的拒绝/读回缝解决问题,把新机制放在自己的模块里,让 entry 模块留在预算内,并且明确写出了它不覆盖的场景。验证是真实的:并发探针在修复前拿到两条投递、修复后拿到一条 committed + 一条 typed refusal(run index 里只有一个投递标记、一次 quota spend);198 项聚焦测试、6 个公开冒烟、ruff、语义清单检查、docs 治理冒烟、maintainability ratchet 全绿;canary premerge --from-git-diff 11 项选中检查 0 失败。change-quality receipt cqr_7410bf12ba7476017bbe 覆盖同一 scope fingerprint(7410bf12…),其中两条 advisory 风险就是上面的本机围栏边界与新增字段。可以按现在的形态自合并。

English verdict: APPROVE at cd83ea4. A Turn lane could run two executors at once, and a two-process probe on one goal+agent reproduced the consequence directly: two validated_progress deliveries and two quota spends for one bounded question. The fix admits one executing Turn per lane through a kernel lock held for the whole executing section, and refuses the second with status: unavailable, reason: turn_lane_in_flight, remediation: [wait_for_in_flight_turn], host.executable: not_invoked, all-false effects, and quota_slot_spend_count: 0, plus a public-safe holder readback (agent, operation, pid, acquired_at) that never leaks the lock id, runtime path, or policy. Previews take no fence because they invoke no host and spend nothing; an envelope without an agent still gets an unattributed lane rather than no fence, and crash release is the kernel lock's. Same-host serialization is the disclosed boundary; cross-host sharing of one runtime root is not covered here. Verified: post-fix probe gives exactly one committed delivery plus one typed refusal and one quota spend, 198 focused tests, six public smokes, ruff, semantic inventory check, docs governance smoke, maintainability ratchet (the entry module stays inside its 1500-line budget after the fence moved to lane_fence.py), and canary premerge --from-git-diff passed 11 selected checks with 0 failures. Receipt cqr_7410bf12ba7476017bbe.

@huangruiteng
huangruiteng merged commit 887961b into main Sep 16, 2026
21 of 22 checks passed
@huangruiteng
huangruiteng deleted the codex/turn-lane-single-executor-20260916 branch September 16, 2026 01:06
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.

1 participant