fix(turn): supervise managed Host process lifetimes in TypeScript - #5144
Conversation
Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
…copes Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
…sion Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
There was a problem hiding this comment.
Approval conclusion (author-owned PR; GitHub blocks formal self-approval)
Exact-head self-review: 5144@152bb5207b632dfbfae11d99e6caba54588b5978
Approval conclusion: APPROVE. Author-owned PR; recorded as a COMMENTED review.
动机
这里的实际缺口不是“lease 有记录”,而是 managed Host 返回之后,后代进程仍在执行效果。旧 generic Host 的 timeout 只杀 leader;旧 Codex cleanup 在 leader 已退出时直接返回。我用同一个独立合成工作负载,在真实 merge base cd32b28567216c9621bb86061ab1ae1b97724491 和当前 head 调用生产入口,对照复现了 generic timeout、正常 leader 退出、Codex 有效结果三种后代持续写入的情况。
本 PR 的可验收结果是收拢这两条 subprocess 路径的进程生命周期,不是整个 R5、lease fencing 或多 Host 取消能力完成。授权、独立任务验证和 exactly-once settlement 仍由现有 Turn owner 承担。
改动思路
每个 Host job 的 timeout、输出预算、pipe drain 和进程组终止统一归 TypeScript supervisor。Python 只保留 IO 转运以及 Codex 的结果/会话解释,不再维护两份 termination/thread-reader 规则。复用了已有 Node discovery 和 wheel package-data;没有新 capability、provider、持久化状态或用户 CLI 开关。现有 persistent authority RPC transport 的生命周期不同,不应被当作 disposable Host job 复用。
具体改动
我审阅的是完整 15 文件、+851/-139:5 个生产文件、3 个真实进程/原生测试文件、7 个中英文协议与 RFC/ledger 文档,而不是只看新增 supervisor。两条现有入口自动采用内部实现,现有配置/请求 schema 和 UI 不变,因此无需另增 frontend/Lark 开关或首屏修改。
关键代码讲解
runHostProcess(host_process.ts:49)用专有 POSIX process group 执行 Host;即使 leader 已退出仍发 group TERM,并在 300ms 后升级 KILL。stdout streaming budget、UTF8 decoder、背压和有限 drain 都在这一个 owner 中。超时/取消/输出超限是 typed outcome,不是 prose guidance。run_host_process(host_process_transport.py:51)转运 private NDJSON,请求端 control pipe 的存活代表 Python caller 仍存在,EOF 触发取消。callback failure 也走有界清理;它不读取或改写 Todo/lease authority。Windows relay 是启动适配,不构成第二份生命周期策略。run_codex_cli_host(codex_cli.py:761)保留既有 typed result-file 和 opaque-session recovery;改为有界 LF framing 与有限 failure-category 集合。不完整诊断不制造成功或武断分类,但有效 typed result 仍可用,timeout 已观察到的 session 仍保留。_run_host(executor.py:646)经同一 transport 收集有限输出,只有 complete、zero-exit 且合法 JSON 才进入原有 validator/settlement。已有 admission 在执行前,journal retry 与 settlement 不被新 supervisor 接管。
对主干的风险
默认行为确实改变:managed subprocess 在正常返回、失败或 caller loss 后清理其 owned group;不能再用这条路径故意遗留长期服务。协议明确披露了这个变化。missing executable/timeout 的 diagnostic reason 也变为明确的 process outcome,成功 JSON、UTF8、非零退出和非法 JSON 的对照观察则保留。
最强反例是 leader 返回有效结果但 child 继续产生效果,普通结果测试或 mock Popen 会漏掉它。独立回放在 base 的上述三种场景均观察到返回后文件继续变化,head 三种均停止,正常 generic/Codex 结果仍有效;没有凭 PID 是否存在来假定清理成功。原生 suite 另覆盖 ignored TERM、继承或关闭 pipes、abort、owner death、consumer failure、输出超限、session/retry 等路径。
验证已完成:Python head 144/base 136 通过,TS 真实进程测试 9/9;control-plane typecheck、ruff 和配置内 mypy 20 sources 通过;base/head 的真实 Turn fake-host walkthrough 通过。重新 build wheel、从独立一次性环境安装并核对 site-packages provenance后,相同生产入口回放与 source head 观察完全一致,打包的 supervisor/bridge 可用。第一次构建在 base/head 都因缺少生成的 Chat bundle 被同一既有 guard 拒绝;按正常 build:chat 流程补齐后通过,未放宽 guard 或预算。
语义与 CI 对齐
复用现有 Turn vocabulary 与 authority admission,新增的是同版本随包交付的 transient process outcome,不是新的 persisted actor/lease 协议;默认 cleanup 增量已在协议披露且有 base/head 反例,既有 journal/session/replay 语义保留。
远端 CI 未查询、未等待,不以无因果关联的红 CI request changes。这些证据不涵盖 native Windows、逃逸进程组、supervisor 被 SIGKILL、remote effects 或 lease 到期/回收后的取消;Windows 仅 best-effort,协议也这样声明。没有 PostgreSQL store 重构或新 authority 写入,所以这个本地 OS-process slice 不需要用活跃 Goal/数据库做实验。增加 Node 启动成本是取舍,未据此宣称延迟改善。
非阻断建议:[P2] 更新 current-frontier 的 PR 状态
docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-27-host-supervision.md:6 开始的新记录仍说 #5140 open,后面的当前 inventory/count 也继续计入;对应中文与 RFC current-frontier 应同步核对。发布前读取生命周期元数据:#5140 已在 2026-09-26 22:13:02 UTC 合并,#5142 已在 19:29:55 UTC 合并。
建议保留注明修订的历史 snapshot,但刷新“当前未合入 PR 数”和区分已合入 recovery 与尚未完成 integration qualification,避免推进计划误算。该 currentness 修正不否定已独立验证的进程清理,不作为 runtime approval 的阻断;#5141 等剩余边界也不能因本 PR 而视为完成。
我的整体评价
APPROVE:这个 slice 改善长期推进中的 orphan-effect 风险,并让现有 Host 用户获得有界返回和清理,不额外增加人工配置或执行权限。代码规模与两个活跃 production caller 相称,删除重复 Python lifecycle 是已应用的有界 future-facing refactor;继续扩大到 lease cancellation/attached Host 应按现有 RFC 具体验收,不在这里添加未使用的框架。
Public-boundary 检查通过;本 review 使用合成 fixture 和真实本地进程,不公开原始轨迹或私人状态。仍保留上面的明确未验证项与非阻断文档建议。该 PR 是 runtime/control-plane 修改,审查通过不代替 maintainer merge,也不等同 merge readiness;本次没有合并。
English verdict: APPROVE
Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
…ndex The new ledger/shared-goal-authority-state-provider-v0/2026-09-27-host-supervision entry raised the authoritative-state-provider ledger count from 20 to 21 without regenerating the index, so docs-governance-smoke reported a stale RFC status index. Regenerate STATUS.md and STATUS.zh-CN.md with scripts/generate_rfc_status_index.py. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
huangruiteng
left a comment
There was a problem hiding this comment.
Approval conclusion (author-owned PR; GitHub blocks formal self-approval)
PR #5144 复审 — exact head 0d9de02958d9f5e95a927e5f9feedbc3413fc126
动机
真实复现暴露了两条同源缺陷:通用命令 Host 的 leader 超时返回之后,它启动的子进程仍在继续改文件;内置 Codex CLI 在 leader 退出后也跳过了后代清理。租约写、迟到结果拒绝都只能约束"结果能不能用",不能终止已经在跑的进程。本 PR 让两个已交付的子进程入口(通用命令 executor 与内置 Codex CLI)共用同一个 TypeScript 进程监督者,覆盖超时、父进程消失、输出 drain 与后代清理;Python 保留命令传输、Codex 会话/结果解释与既有结算调用方。不新增能力、provider API、持久化格式或 CLI 参数,方向与 TS 迁移 RFC"把生命周期与 effect 决策放到 TS、Python 只做适配"一致。
改动思路
进程所有权集中到 loopx/control_plane/turn_driver/host_process.ts:POSIX 下以独立进程组启动(detached),并且在 leader 退出之后仍然对整组发信号——TERM 后 300 毫秒升级为 KILL,正是"leader 已退出但子进程还在写"这条复现路径;drain_timeout_ms 让被后代继承的管道不会无限阻塞结果返回;stdout 在流式消费阶段就按既有 12,000 字节结果预算裁剪(output_limit),而不是先收完再判断;abort(控制管道 EOF、SIGTERM/SIGINT、stdout 断开)取消执行。Windows 保留 Python 的命令启动兼容(含批处理入口),先 taskkill /T 再退化为直接 kill,并且只声明 best-effort。
父子之间只走一条私有、非持久传输:Python host_process_transport.py 把 argv/cwd/input/timeout_ms/drain_timeout_ms/stdout_limit_bytes 以单行 JSON 写入 host_process_bridge.ts,bridge 用严格的 decodeHostProcessRequest 解码后执行,再把 stdout/stderr 分片与最终 result 以 NDJSON 回写;控制管道在整个作业期间保持打开,EOF 即表示所有者离开并触发取消。Python 侧只剩解释工作:executor._run_host 与 codex_cli.run_codex_cli_host 改为调用该 transport,codex_cli 删掉了自建 subprocess、两个 reader 线程与 _terminate_process,失败类别由 list 改为 set(选择仍由 (优先级, 名称) 决定,结果确定)。
具体改动
新增 host_process.ts(进程监督者:decodeHostProcessRequest、signalGroup、runHostProcess、HOST_PROCESS_TERMINATE_GRACE_MS = 300)、host_process_bridge.ts(私有 NDJSON 传输与 8 MiB 请求上限)与 host_process_transport.py(HostOutputLines 的 LF 分帧与 1,048,576 字符上限、run_host_process 的 Popen + 结果解析)。executor.py 删除 subprocess.run 路径,改为受管流式执行并区分 output_limit/非 exited/输出不完整/非零退出,保留 HOST_RESULT_MAX_BYTES 与 _run_host_runner 的进程内预算;codex_cli.py 删除 _terminate_process 与线程 reader,改用回调观察 LF 记录,并在结果/超时两条路径上都由 TS 完成组清理。测试侧新增 TS 9 项(UTF-8 跨界、stdin EOF、非法请求、可执行缺失、流式预算、abort、输出消费者失败)与 Python 6 项(HostOutputLines 边界、真实往返与预算、回调失败仍等待清理、所有者消失取消、超时停止真实后代、Windows relay 的 argv/stdin),并在 tests/test_loopx_turn_codex_cli.py 加入参数化的后代回收用例(假 Codex 启动忽略 SIGTERM 并持续写文件的子进程,断言结果返回与超时两种情形下都停止)。文档侧新增 loopx-turn-v0.md 的 Managed Host Process Lifetime 段,并在两份 RFC 的交付前沿记录本切片;合并 main 时解析了 4 个 RFC 文件同位置的"当前交付边界"冲突(保留 main 的更新版计划,并把本切片作为独立交付段补入),另用生成器刷新了因本 PR 新增 ledger 条目而失真的 RFC 状态索引。
对主干的风险
这是默认生命周期变更:Host 进程组不得再用于启动需要长期存活的服务,文档已明确写出;逃逸出进程组的后代、远程副作用、attached App 会话、进程内 DSH 执行以及用 SIGKILL 杀掉监督者本身都在边界之外,且不声称 Windows 与 POSIX 等价。失败语义有可见变化:通用 Host 的超时/取消/输出不全/预算超限现在映射为新的 reason 字符串(如 host process timeout),我核对了仓库内没有消费者依赖旧路径的 TimeoutExpired 字符串。Codex 侧的失败类别选择保持确定(按优先级与名称取最小),集合化不改变结果;codex_cli 的 typed result 契约、超时会话恢复与独立结算仍然保留,进程观察本身不认证 Todo 完成、不退款、不回滚外部效果。代价是作者披露的每次作业启动成本(12 次本地调用中位数 25.73 ms → 132.05 ms),并明确不声称延迟改善。另有一条环境事实值得记录:本机 loopx canary premerge 默认会用 editable 安装解析到主仓库并执行主仓库的脚本,而主仓库残留了未跟踪的 loopx/capabilities/cross_runtime/__pycache__ 目录(被当作命名空间包),会让 bounded-context 与 semantic-drift 两个 canary 秒失败;改用它本工作树的代码(PYTHONPATH=<worktree>)后 premerge 全绿。这不是本 PR 或 main 的缺陷。
我的整体评价
这是一个边界清楚、证据扎实的运行时修复:它对准的是真实复现的"LoopX 已返回、子进程还在干活",把重复的 Python 生命周期规则删掉并收敛成一个 TS 监督者,同时把不属于该边界的部分(租约续约、撤权、attached 会话、Windows 等价性)明确排除,没有借机扩大自动化或默认切换。我在该 head 上复验了 TS 定向 9/9、全量 control-plane TS 3262 项(3232 passed / 30 skipped / 0 failed)、相关 Python 317 项、架构与 RFC 索引检查、typecheck:control-plane、定向 Ruff、git diff --check、17 文件 public boundary 扫描,以及用本工作树代码运行的 premerge(10 canaries + 8 risk smokes + boundary 全绿,0 manual holds)。建议合入。
English verdict: APPROVE - 0d9de02 gives the generic command executor and the built-in Codex CLI one TypeScript process supervisor that signals the owned group even after leader exit, bounds output drain and streaming budgets, and cancels on owner EOF; typecheck, the 3262-test control-plane suite, 317 related Python tests, architecture/RFC-index checks, the 17-file boundary scan and a worktree-scoped premerge all pass, with the supervision-vs-authority and Windows boundaries disclosed as explicit limits.
|
Merged as
|
Goal And Delivered Outcome
Related to #4574 (S2/S4/R5), the TS migration RFC and shared-authority execution closure. A real timeout reproduction showed the generic Host leader exiting while its child kept changing a file. Codex CLI also skipped cleanup after leader exit. A lease write or late-result rejection cannot stop these processes.
Both shipped subprocess entrypoints now use one TS supervisor for timeout, parent loss, output drain and descendant cleanup. Python retains command transport, Codex session/result interpretation and existing settlement callers. No new capability, provider API, persistent format or CLI flag is introduced.
中文:统一通用命令和 Codex CLI 的进程生命周期,修复“LoopX 已返回、子进程还在干活”。本次不宣称租约续约/撤权或默认 provider 切换已经完成。
Scope And Continuation
Validation
Cost disclosure: twelve trivial local invocations measured median 25.73 ms for the former direct process and 132.05 ms through supervision. Accept the per-job startup cost for process isolation; this PR makes no latency-improvement claim. The earlier concurrent development run hit the existing 30-second CLI smoke deadline; it passed alone and in both final serial suites without raising that deadline.
Frontend / Visual Evidence
UI impact: none. Existing
turn run-onceconsumers adopt an internal execution change; no setting or visible configuration was added. Attached frontend/App sessions do not use this process owner. Public docs describe the changed behavior and limits; no public first-screen change.Shared-authority RFC fixture impact
Persisted production-scale fixture schema and provider conformance are unchanged. Added fixtures exercise actual OS process lifetime and existing Turn/Codex callers, rather than fabricating provider success. Three-arm promotion/routing rehearsal is not applicable: no authority selector, migration, projection or store implementation changes. The isolated real-source rehearsal is described above; D2 capacity/soak and D3 cutover stay open.
Boundary Checklist
The committed-scope structural gate initially counted the Python adapter and TS supervisor as an additional same-basename twin. Naming the adapter
host_process_transport.pymakes its existing transport-only responsibility explicit; all callers moved, the 43-module budget and validator remain unchanged, and the gate passed on the corrected scope.Exact-scope quality:
0a95134e90b9fa987e2d7b0d62f37a3dbd26802901599f429b0b771262c6631a; receiptcqr_0a95134e90b9fa987e2dverified valid for 15 changed files. Safe-fix allowed/applied: one bounded refinement pass. No merge authority is inferred from this receipt.Maintainer repair at the merged head
The reviewed head
0d9de02958d9f5e95a927e5f9feedbc3413fc126was reached by mergingorigin/maininto the branch twice as main advanced, then refreshing the derived RFC status index.shared-goal-authority-state-provider-v0.md/.zh-CN.mdandtypescript-control-plane-migration-v0.md/.zh-CN.md) hit "both sides edited the same current-delivery-frontier section" against main's newer commits (7 commits, then 16 including refactor(state): retire legacy Todo events and isolate supervisor history #5054's legacy Todo-event retirement). Each kept main's newer plan text and folded this PR's host-supervision slice in as a separate delivered paragraph, with the English and Chinese versions mirrored. No runtime file conflicted.docs/architecture/rfcs/STATUS*.mdwent stale when this PR's new ledger entry (and then main's new ledger entries) appeared; regenerated withscripts/generate_rfc_status_index.py --writeand committed as its own signed change sodocs-governance-smokeandtest_rfc_status_indexstay green.npm run typecheck:control-plane;npm run test:control-plane(3262 tests, 3232 passed, 30 environment-gated skips, 0 failures); 317 related Python tests across host process, Codex CLI, executor, driver and the three architecture suites; RFC status index (13 passed); targeted Ruff;git diff --check;loopx checkover all 17 changed paths (public boundary scan clean); andloopx canary premerge --from-git-diff(10 catalog canaries, 8 risk smokes, public boundary and direct checks; 0 manual holds).loopxfrom a worktree resolves the editable install to the primary checkout, so a default premerge runs the primary repo's scripts. The primary checkout carries an untrackedloopx/capabilities/cross_runtime/__pycache__directory that reads as a namespace package, which madebounded-context-namespace-smokeandsemantic-vocabulary-drift-smokefail fast; running the gate with the worktree's own code passes. This is local drift, not a main or PR defect.protect mainruleset requiresrequire_last_push_approvalafter the maintainer main-integration pushes made the maintainer the last pusher. The exact-head review is published at fix(turn): supervise managed Host process lifetimes in TypeScript #5144 (review).