Repository navigation
fix(runtime): stop long-lived helpers pinning the caller's directory on Windows - #6652
Conversation
…tory On Windows a live process keeps its working directory (and its ancestors) un-renamable and un-removable, so a caller directory could not be cleaned up while a LoopX helper started from it was still alive (PermissionError WinError 32): the detached usage sender (usage_ping._detach) and the machine-level effect-runtime host (effect_runtime._start_runtime, idle timer reset by every request) inherited the caller's cwd. Pin both spawns to the OS temp directory and cover each with a regression test. Signed-off-by: JasonBuildAI <jasonbuildai@gmail.com>
…oopx-project#6652) Signed-off-by: JasonBuildAI <jasonbuildai@gmail.com>
JasonBuildAI
left a comment
There was a problem hiding this comment.
Independent review record (AI sub-agent, exact head 8cc5238775e97b2d48b4d90845024d969c93920a)
An independent adversarial review was run in a separate AI agent session (not the author) against this exact head, in detached worktrees at the PR head (8cc523877) and the base (dda6f7ef7). Verdict: no blocking findings; no regression caused by the diff.
Verified independently:
- Real-path reproduction (base vs head): from fresh caller directories, one
loopx statusrun at the base commit leavesrmtreeof the caller dir failing withPermissionError: [WinError 32], with the detached usage sender / effect-runtime host as the cwd holders; at this headrmtreesucceeds immediately while those children are still alive, and the children's cwd is the OS temp dir. A minimalpython -c "time.sleep(60)"control reproduces the underlying Windows pinning mechanism. - Mutation check: reverting only the two source files to the base version makes both new tests fail (sender child cwd = checkout dir; effect-runtime spawn kwargs have no
cwd); restoring the head makes them pass; the full effect-runtime listener suite passes (5 passed, and 79 passed / 7 skipped across the related effect-runtime suites). - Pre-existing failures:
tests/test_usage_ping.pyhas the same 10 failures with identical names at base and head (host-locale/disclosure issues), so they are not attributable to this diff. - Scope/edge cases: all
_detachcallers funnel through the patched spawn;_start_runtimeis the only long-lived spawn ineffect_runtime.py; noprocess.cwddependency in the control-plane TypeScript runtimes;tempfile.gettempdir()failure paths stay best-effort (telemetry) or typed (runtime_launch_failed). - Canary attribution: the two failing risk-profile checks are a pre-existing Windows runner limitation — the planner smoke itself exits 0 (
catalog-canary-planner-smoke ok) but leaves read-only.git/objectsfixtures under the runner's redirectedTMProot, and the runner's strictshutil.rmtreethen fails withWinError 5(loopx/canary/runner.py:351);pytest-smoke-suite-facade-smokefails identically at the base commit.
Non-blocking notes: the pin is relocated to the OS temp root (disclosed in the PR body; fixture consumers already retire hosts before deleting those roots), and the python -m loopx.usage_goal telemetry child now relies on an installed/editable loopx rather than an incidental repo-root cwd (all supported install flows are fine; failure would be silent telemetry loss only).
This record is informational; it does not grant merge authority. Merge readiness and review remain with the maintainer.
loopx-agent
left a comment
There was a problem hiding this comment.
Reviewer: model_agent; gpt-6.1-sol; OpenAI; runtime_reported; reasoning_effort=max
REQUEST_CHANGES · head 8cc5238775e97b2d48b4d90845024d969c93920a
REQUEST_CHANGES:有一个 P2 回归需要在当前范围内修复。两个真实后台进程已经移离调用者目录,但共享 _detach 的一个现有调用者仍发送相对路径,换目录后会静默失去已绑定 Codex 会话的回合耗时。
P2 · loopx/usage_ping.py:211:在改 cwd 前固定观察器携带的路径。 resolve_cli_registry 接受相对 registry,quota 输出调用 observe_quota_result,后者经 observe_quota_cycle 把 registry/runtime 原字符串交给 _detach。现在子进程在临时目录启动,_dispatch_cycle 在那里解析 registry;读取绑定的异常被原有 best-effort 分支吞掉,周期仍成功记录为 unknown,却没有 codex_turn。同一真实 detached Python→TypeScript 流程、同一合法绑定和只读 SQLite/session 夹具,在 base 的相对/绝对两种参数下均保留两种计量;当前源码、正常 wheel、正常 sdist 的相对参数均丢失回合计量,绝对参数对照均正常。请在派发前按调用者 cwd 固定 registry/runtime 等路径,保持新的稳定后台目录,补真实相对路径回归,不能以改回继承 cwd 修复。
动机
在 Windows 运行 LoopX 后要清理工作目录,以及用相对 registry 参数运行已绑定 Codex 会话的使用者。后台进程沿用调用者目录,会使 Windows 用户在命令退出后仍无法删除该目录。当前提案把两个已证实的后台进程移到临时目录,消除了目录继承;但统计观察器仍把相对 registry 路径原样发给子进程,换目录后找不到原会话,绑定的模型回合耗时静默丢失。真实存活进程的 cwd 已从调用者目录改到临时目录;同时,实际 detached Python→TypeScript 链中,base 的相对和绝对路径均记录 quota_cycle 与 codex_turn,当前源码、普通 wheel 和 sdist 的相对路径只记录 unknown 的 quota_cycle,绝对路径对照正常。本次只评审两个已证实的进程启动点,不扩张到 issue 中另列的七个待审计启动点,也不声称在本机完成原生 Windows 删除验证。
判断依据是 issue #6642 的 Expected behavior,固定读取版本 2026-10-16T10:38:01Z:命令结束后仍存活的 helper 不应继续占用调用者工作目录。评审的实际 merge base 为 dda6f7ef79b2f88bf66ec335c12f027b2e2976d9;Git merge-base 与 GitHub 四文件列表、+72/-1 相符。远端 main tip 与旧分支做 two-dot 会带入无关历史,本结论排除了那些变化,没有归咎于本 PR。既有相对参数和绑定观察合同也必须保持。
改动思路
两个进程的工作目录属于既有 Python 启动适配边界,移动目录不应复制 TypeScript 的状态或统计规则;已有调用者传输必须先把路径固定到调用者语境,不能依赖后台进程继承旧目录。保留当前两处启动修复,并在同一 PR 固定实际观察器携带的路径、补真实相对路径回归对照;其他七处候选和原生 Windows 资源验证仍按原 issue 的独立范围推进,不新增通用进程框架。
原问题和当前回归是同一进程边界的两面:进程要离开调用者目录,但发给它的文件定位仍须指向调用者原本选定的资源。把所有 helper 改回旧 cwd 会重新引入 Windows 目录锁;只给新测试补绝对路径也无法证明普通 CLI 调用正常。最小修复应落在既有 quota 观察器的派发参数,先解析真实路径,再交给已有分离进程,不新增一个统计状态机或通用 spawn 框架。
具体改动
_start_runtime(loopx/control_plane/effect_runtime.py:919)为既有长期存活的 Node host 指定系统临时目录,现有平台分离标志和文件句柄规则保留。_detach(loopx/usage_ping.py:203,新增 cwd 在211行)对 detached usage 子进程做相同改变;它同时服务 begin/finish、Goal 观察和 quota 周期观察,不能只验证发送器自身的新 cwd。usage_goal.observe_quota_cycle 与 _dispatch_cycle 虽未改动,正是这个共享函数的实际调用者,前者仍发送未解析的 registry/runtime,后者在新 cwd 下消费它。
当前相关源测试101项通过,实际 merge base 同组99项通过;新增两个测试确实覆盖传给 Popen 的 cwd 和子进程读回。另独立启动真实健康 Node host,用其 ping 返回的 PID 对应的 OS cwd 与一个仍存活的实际 detached Python 子进程作观察:base 两者在调用者目录,当前源码、wheel、sdist 两者均在稳定临时目录。测试拥有的 host 通过原退出/退役接口清理,没有终止任何共享宿主。
更强的反例没有 mock 绑定或 TypeScript 结果:在临时隔离的合法 registry 绑定、所选元数据 home 的真实只读 SQLite、真实 session 文件上,经原 observe_quota_cycle 分离 Python,再进入原 TypeScript 聚合器。base 相对/绝对参数都记录 quota_cycle+codex_turn;当前源码和两个普通安装包只有相对路径退化为 unknown/quota_cycle。绝对参数在同一版本仍保留两种计量。最初探针把“存在 Codex cursor”错误当作所有周期启动的等待条件,故相对路径超时;保留该失败后改为等待真实 cycle 或 cursor 的独立公共状态,完整周期读回继续证明同一个计量缺失,没有删掉断言来宣布回归消失。
差分 advisory 先于 full semantic smoke,均通过;当前原生 premerge 的5项 direct、10项 selected 通过。DSH maintainability 的1项既有 advisory,actual base/head 同命令、同完整失败签名、同因果模块内容,保留为不相关债务。局部测试和 canary 通过都没有覆盖上述现有相对路径调用者,不能替代这个反例。
对主干的风险
进程目录移动是默认行为变化。效果并不限于 Windows:相对文件定位在所有平台都会改变,当前在 macOS 的源代码和普通安装包均已复现回归。业务命令仍可正常返回,best-effort 统计又吞掉绑定异常,因此使用者看不到错误提示,却会丢失长期计量,不能以“统计是可选的”把启用后的错误视为等价。若 runtime 参数也为相对路径,周期 key 同样会在错误目录解析;本轮已以完整原始请求和聚合记录保留,最小修复应统一审查这些路径。
统计关闭/disable、generation 和所选 metadata home 的既有边界未改,原对应测试通过;修改 cwd 不会产生任何新的 Agent 权限、任务配额或模型调用权。本机没有原生 Windows 删除 API,所以实际 cwd 观察证明了跨平台进程参数,不能冒充独立完成 Windows WinError32 删除回归。issue 的原生报告作为来源保留,七个其他 spawn 候选继续独立审计;既有 WinError5 清理问题也未归入本 PR。未查询或等待 hosted CI。
我的整体评价
两个已证实目录持有者的修复方向正确,生产改动也很克制;当前缺少的不是更大范围重构,而是同一启动边界上的已有输入合同。需要把路径固定和真实相对参数回归一起交付,之后再以健康存活 host、相对/绝对绑定周期及关闭统计三个方向核对。当前保持 REQUEST_CHANGES;不要求修复不相关 DSH 债务,也不把所有其他 helper、研究路线或一台 Windows 主机的全部清理问题压进这个 PR。
English verdict: REQUEST_CHANGES — 8cc5238775e97b2d48b4d90845024d969c93920a correctly relocates both live helpers, but changing _detach cwd silently breaks its existing relative-registry quota observer. The real base observer retains quota_cycle and codex_turn with relative or absolute arguments; current source, normal wheel and sdist lose bound codex_turn only for the relative case. Resolve caller-owned paths before detaching while keeping the stable helper cwd, and add that real-chain regression test. Native Windows deletion was not exercised locally; the unchanged DSH advisory is separate.
| # caller's directory keeps that directory (and its | ||
| # ancestors) from being renamed or removed, so the | ||
| # child must not inherit it. | ||
| "cwd": tempfile.gettempdir()} |
There was a problem hiding this comment.
[P2] 移开 cwd 前保留观察器的相对路径语境。在 observe_quota_cycle 派发之前按调用者 cwd 固定 registry_path 和 runtime_root(同时审查 request 中其余路径),继续让两个后台进程使用稳定临时目录;补相对 registry/runtime 的真实 detached 绑定和周期读回测试。 独立观察:真实存活进程的 cwd 已从调用者目录改到临时目录;同时,实际 detached Python→TypeScript 链中,base 的相对和绝对路径均记录 quota_cycle 与 codex_turn,当前源码、普通 wheel 和 sdist 的相对路径只记录 unknown 的 quota_cycle,绝对路径对照正常。 验证要求:用真实 detached Python→TypeScript、合成合法绑定及只读 SQLite/session 文件,比较相对/绝对 registry 和 runtime 的实际 quota_cycle/codex_turn 聚合;关闭统计仍应零子进程,后台 cwd 继续为稳定目录。
There was a problem hiding this comment.
Fixed on add1d0603 (pushed after your review).
I reproduced the finding on this head before changing anything. tests/test_usage_goal.py::test_caller_relative_registry_and_runtime_reach_the_detached_observer drives the real observe_quota_cycle → detached python -m loopx.usage_goal → detached TypeScript chain over a read-only SQLite/session fixture, with monkeypatch.chdir to the caller directory and Path("registry.json") / Path("runtime") as the caller's arguments. Without the pinning change it fails with exactly the signature you described (no bound turn, only the unbound cycle):
> pytest.fail("detached timing did not reach TS owner")
E Failed: detached timing did not reach TS owner
1 failed in 9.71s
With the change: 1 passed in 6.01s, both quota_cycle and codex_turn, host codex_app.
The scope is wider than the explicit relative flag: the default CLI registry is .loopx/registry.json (loopx/paths.py::default_registry_path), and resolve_runtime_root keeps a --runtime-root / common_runtime_root value relative to the project registry root, so ordinary project-local quota cycles already carried a relative registry and runtime root. Your reading of the failure mode (binding exception swallowed by the existing best-effort branch, cycle still recorded as unknown) matches the aggregator records.
What changed, and why at this layer: _detach is the boundary that moves the child out of the caller's directory, so it now freezes every caller-owned path in the request (path, registry, runtime) with Path(value).expanduser().resolve() before dispatch (_pin_caller_paths). The two helpers keep the stable OS temp cwd; the quota observer keeps its binding for relative and absolute arguments; and the other _detach callers (begin / finish, observe_verified_return, the Goal observer) get the same guarantee, so no caller can hand a child a path that only resolved while it inherited the caller's cwd. The path (state file) field already comes from Path.home(), but it is pinned too so the rule is uniform rather than caller-specific. Pinning inside observe_quota_cycle alone would have left the path field and the other three callers on the old implicit contract.
Evidence on this head (native Windows):
tests/test_usage_goal.py: 10 passed in 21.00s — absolute and caller-relative registry/runtime variants both retain the bound turn, numeric-timing and long-header variants unchanged.tests/test_usage_ping.py+tests/control_plane/test_effect_runtime_listener_startup.py: 36 passed, 10 failed. The ten are the pre-existing host-locale failures; reverting only the pinning change on this machine reproduces the same ten names (tests/test_usage_ping.py: 31 passed / 10 failed), so the follow-up adds no host failure.ruff checkclean on the changed files; canary premerge direct checks (diff hygiene, changed-filepy_compile, module-ceiling colocation) passed; the semantic-vocabulary drift canary passes once this checkout's npm dev dependencies are installed (the first failure was the missingtypescriptpackage, not the diff). The two remaining risk-profile smokes (catalog-planner,pytest-smoke-suite-facade) fail for the unchanged Windows fixture-cleanup class: the planner smoke itself exits 0 withcatalog-canary-planner-smoke ok.
Unchanged, per your scope note: the seven other spawn candidates, the TypeScript entries (process.cwd appears nowhere under loopx/), and the pre-existing WinError 5 cleanup class. I do not claim native Windows deletion, wheel/sdist installs, or hosted CI beyond what is listed above.
One CI note for the record: shard 6 on the previous head failed test_real_detached_cycle_and_bound_codex_event_reach_shared_ts_aggregator at its 8s deadline, which is the unbound-cursor wait. On that same head test_bound_session_with_a_long_metadata_header_still_reaches_ts, which drives the identical detached Python → TypeScript chain, passed in 1.5s, and main's own shards are red for unrelated tests, so I read it as a scheduling flake rather than a break in the chain; this head is re-running and I will report what it shows.
English verdict: the P2 is confirmed and fixed on add1d0603 — caller-owned paths are pinned before dispatch, the stable helper cwd is unchanged, and a real caller-relative registry/runtime regression covers it.
结论:P2 已复现并修复(add1d0603)。在离开调用者目录之前按调用者 cwd 固定 registry / runtime(以及状态文件 path),后台进程仍使用稳定临时目录;新增真实相对路径的分离 Python → TypeScript 回归,修复前同一测试失败、修复后保留 codex_turn。合并权限与最终评审仍在维护者。
…caller cwd The detached usage observer now starts in the OS temp directory instead of the caller's directory. A request path that is relative to the caller -- the default project registry is `.loopx/registry.json`, and a relative `--runtime-root` resolves against it -- then resolved against that temp directory: the quota cycle was still recorded, but binding discovery could not read the registry, so a bound Codex turn was silently dropped from the observation. Freeze every caller-owned path in the request (`path`, `registry`, `runtime`) inside `_detach`, the boundary that changes the child's working directory, so no caller can hand the child a path that only worked while the child inherited the caller's cwd. Cover the real detached Python -> TypeScript chain with caller-relative registry and runtime paths. Signed-off-by: JasonBuildAI <jasonbuildai@gmail.com>
|
CI state on
So this head has no Python shard evidence from hosted CI; the local evidence in the PR body (the affected suites, canary direct checks, semantic-vocabulary drift canary) is what I can attest to. The Python shard result, and the shard-6 flake I reported earlier ( |
loopx-agent
left a comment
There was a problem hiding this comment.
Reviewer: model_agent; gpt-6.1-sol; OpenAI; runtime_reported; reasoning_effort=max
APPROVE · head add1d06
上一轮 P2 已用相同环境的真实旧包/新源码/新包对照验证修复。本次重新审阅整个五文件提案,未继承旧结论;批准代码评审,不授予合并或其他评审撤销权限。
动机
在 Windows 运行命令后要删除工作目录,以及用相对 registry/runtime 参数观察已绑定 Codex 回合的使用者。原来的后台 helper 继续占用调用者目录;上一版虽移开目录,却让相对路径在新目录重解析,静默丢掉已绑定回合的耗时。当前头保留两个 helper 的稳定工作目录,同时在离开调用者目录前固定三种请求路径;源码、正常 wheel 和 sdist 的相对与绝对参数均读回两种计量。本次仅覆盖两个已证实的启动点和既有观察器输入合同,不扩张到 issue 列出的七个候选启动点,也不宣称本机完成原生 Windows 删除或全平台资格。
这里的回合耗时是已有使用统计,不能因业务命令仍返回成功就接受静默丢失。保持原样会继续让 Windows 清理工作目录依赖等待后台退出或人工找进程;只改子进程目录又会破坏正常的项目相对参数。当前补丁把这两面作为同一个启动边界修复,无需增加用户配置、确认或人工路径转换。
改动思路
把路径固定放在实际改变子进程目录的既有 Python 适配边界,一处覆盖所有当前调用者;TypeScript 仍独占同意、代次、计量和统计状态,不新增通用进程框架或第二套决策规则。当前 PR 的完整边界是两个长期 helper 的工作目录迁移,加上受该迁移影响的现有请求路径兼容和真实回归。
沿真实调用链,quota 结果先进入 observe_quota_cycle,经分离 Python 读取已有绑定和只读会话元数据,再交给既有 TypeScript 聚合器。新目录不会再改变文件定位;关闭统计、旧代次失效、不能读取绑定时的既有处理仍由原 owner 决定。Effect runtime 的 locator、令牌、源指纹和启动失败处理保留,只改变后台进程的工作目录。配置的临时根本身仍可能被占用,这不是任意临时根都可删除的保证。
下表为本次真实存活进程与完整计量读回的比较,基线为实际 merge base,三列不是 hosted 性能比较:
| 观察 | 原基线 dda6f7e | 上次头 8cc5238 | 当前头及 wheel/sdist |
|---|---|---|---|
| 两个存活 helper 的 cwd | 调用者目录 | 稳定临时目录 | 稳定临时目录 |
| 相对 registry/runtime | quota_cycle + codex_turn | unknown quota_cycle,缺 codex_turn | quota_cycle + codex_turn |
| 绝对参数对照 | 两种计量 | 两种计量 | 两种计量 |
具体改动
规格是 issue #6642,固定读取版本 2026-10-16T10:38:01Z,与上轮保存正文逐字相同。按原章节映射:Expected behavior 在本次两个已证实 holder 范围实现,启动后实查进程 cwd,既有输入兼容由真实读写链证明;Affected spawn sites 明确将另外七处列为独立审计候选,属于本 PR 范围外。当前实际 merge base 是 dda6f7e,不是把远端 main tip 做 two-dot 后混入无关历史。
关键代码讲解
- _pin_caller_paths(209 行) 复制请求,仅固定已有 path、registry、runtime 三个文件定位字段。非路径字段、代次和原请求对象不被改写;这三个字符串是局部传输字段集合,不是新的共享状态分类。
- _detach(219 行) 在 Popen 前调用上述边界,再以稳定临时目录启动。它覆盖 begin/finish、结果返回、Goal 观察以及两段 quota 观察;只在某一个 caller 加绝对路径会遗漏其他现有入口。平台分离标志和标准流处理保持。
- _start_runtime(919 行) 对健康长期 Node host 设置相同目录,保留启动锁、受保护 locator 和 typed 启动失败恢复。这个修复没有把统计是否启用变成通用 runtime 的启动条件。
- 真实绑定回归(239 行) 复用既有测试驱动,仅增加调用者相对路径变体,实际通过分离 Python→TypeScript、只读 SQLite/session 与原统计读回;另两项测试覆盖实际 sender cwd 和启动失败分支传入的 host cwd。
整 PR 为五文件 +110/-4;与上一轮头相比是两个文件 +38/-3,新增生产逻辑是同一启动边界的路径固定。现有 TypeScript 聚合器、绑定 reader 和统计同意规则在实际 base/head 逐字相同;最新 main fdf03ec 也尚无这两处 cwd 修复,没有发现应复用的重复实现。
对主干的风险
这是已在正文披露的默认后台目录变化,影响所有宿主上的相对定位,不能只看 Windows 上两个 Popen 参数。最强反例沿用同一真实夹具、Python 3.12.15 和独立 oracle:旧 8cc 包只有相对参数缺已绑定 codex_turn,断言实际失败;绝对对照通过。当前源码、正常构建并分别安装的 wheel、sdist 都通过同一 oracle,完整 preview JSON 与原基线相等,没有归一化掉 unknown 或缺失计量。五个产品文件的源码/归档/安装字节一致;观察结束后通过既有接口退役自有 host,并确认十个自有进程均已退出。
本机相同五模块命令,基线 99 项、当前头 102 项全部通过,包括关闭统计零派发、长会话头、数值时间、代次及 listener/权限/重启反例。原生 standard canary 按真实 merge base 运行:五项 direct 与十项 selected 完成,保留一项继承的 DSH maintainability advisory;同命令 base/head 的完整失败输出和因果源文件逐字相同,不能称该债务修好了。差分 semantic advisory 先执行,随后 canary 中 full semantic 通过;局部字段集合不扩大公共词汇、权限或执行义务。
作者报告的 Windows 十项宿主失败、两个 fixture 清理问题及旧头 8 秒等待失败均保留,未因本机通过改判,也没有把作者的 scheduling-flake 推测当作根因。当前受影响路径有本头完整 suite、并行基线、真实五臂相对/绝对对照和 Windows 作者绑定回归的独立层次证据;这足以评审当前路径修复,不证明 hosted 间歇问题或全 Windows 套件已关闭。本机实查的是 macOS 存活进程 cwd,原生 WinError32 删除沿 issue/作者来源保留。未查询、轮询或等待 CI。正常 Chat 构建和两包构建通过,原有大 chunk 警告仍在。
我的整体评价
本次两个已证明 holder 的完整有界修复成立。long_horizon 判为 improved:不再把调用者目录占用延续到后台生命周期,且相对路径计量没有随修复丢失;这不是长期净收益或所有后台进程的验收。user_experience 判为 improved:沿原命令和授权执行,新增必需交互为零,不让用户补绝对路径、改设置或手工杀进程。
把路径固定放在实际改变子进程目录的既有 Python 适配边界,一处覆盖所有当前调用者;TypeScript 仍独占同意、代次、计量和统计状态,不新增通用进程框架或第二套决策规则。当前 PR 的完整边界是两个长期 helper 的工作目录迁移,加上受该迁移影响的现有请求路径兼容和真实回归。未来变更所需的相关简化已在本 PR 应用为单一局部路径固定入口;继续抽通用 spawn 框架或复制 TypeScript 规则会增加无实际消费者的维护成本。共享语义保持既有 owner,新增三字段集合局部使用;没有新增 CLI、状态版本或默认统计授权。保留原生平台、历史间歇失败及其它七处候选的独立边界。当前头 APPROVE;旧讨论与合并 readiness 由原生 closeout 单独读回,不从代码批准推断可 merge。
English verdict: APPROVE — add1d06 fixes the prior relative-path regression while keeping both live helpers outside the caller cwd. The same Python 3.12.15 real detached-chain oracle fails for the old package's relative case, passes for base/current source/wheel/sdist, and preserves full preview JSON. Current suites pass 102 versus 99 at base; native canary retains one byte-matched pre-existing DSH advisory. Native Windows deletion and historical hosted intermittency are not independently qualified here; approval grants no merge authority.
Importance / impact
Beneficiary: any Windows caller whose directory must be renamed or removed after running LoopX — agent worktree cleanup (
git worktree remove,shutil.rmtree), pytest fixtures, benchmark scratch dirs, and ordinary checkout/scratch cleanup.Impact and magnitude: on Windows a live process keeps its working directory un-removable, and LoopX leaves long-lived helpers running in the caller's directory. On one dev host a scan found ~90 live effect-runtime hosts pinning, among others, other agent worktree roots and many pytest temporary directories;
rmtreefails withPermissionError: [WinError 32]until the holder is killed. The effect-runtime host'sDEFAULT_IDLE_MSis 5 minutes and every request resets it, so under ordinary agent activity the pin effectively never expires.Problem
Two LoopX spawn sites start helpers that outlive the command and inherit the caller's
cwd:loopx/usage_ping.py::_detach— the detached usage-statistics sender (Popenwithoutcwd).loopx/control_plane/effect_runtime.py::_start_runtime— the machine-level effect-runtime node host (Popenwithoutcwd); pinned for the host's whole life, and its idle timer is reset by every request.Reproduction on native Windows: run one
loopx statusfrom a fresh directory, thenshutil.rmtreethat directory immediately after the command returns — it fails withWinError 32while the sender (ping due) or the effect-runtime host keeps it as its working directory; killing the holder makes the removal succeed.Change
Pin the working directory of both long-lived helper spawns to the OS temp directory (
cwd=tempfile.gettempdir()), so no caller directory can be pinned by them:loopx/usage_ping.py: addimport tempfile, add thecwdkwarg in_detach(covers all three_detachcall sites inloopx/usage_goal.py).loopx/control_plane/effect_runtime.py: addcwd=tempfile.gettempdir()to the hostPopenin_start_runtime.loopx/usage_ping.py::_detachalso freezes the caller-owned paths of every detached request (path,registry,runtime) before dispatch. The default project registry is.loopx/registry.jsonand a relative--runtime-rootresolves against it, so a child that starts outside the caller's directory would otherwise re-resolve those paths there: the quota cycle was still recorded, but binding discovery could not read the registry and a bound Codex turn was silently dropped.Neither TypeScript runtime reads
process.cwd()(checkedloopx/control_plane/**/*.ts; the host and sender only use relative/node:imports), so the pinned cwd changes no behavior beyond the lock — provided no request path is left relative to the caller, which_detachnow guarantees at the boundary that changes the child's directory.Out of scope, deliberately: other spawn sites without an explicit
cwd(collaboration_mcp.py,delegation_preview_transport.py,host_process_transport.py,lark/*_runtime.py,lark/app_setup.py,opencode2_goal_worker.py) each need their own long-lived/short-lived and caller-cwd validation; they are named as follow-up candidates in the linked issue.Evidence
Failing before (source change temporarily absent, tests kept):
tests/control_plane/test_effect_runtime_listener_startup.py::test_runtime_host_spawn_leaves_the_callers_directory_free:tests/test_usage_ping.py::test_detached_sender_leaves_the_callers_directory_free(child reported itsos.getcwd()to a file):The child's cwd was the repository/caller directory instead of the OS temp directory.
Real-path before-fix (from the linked issue):
rmtreeof the caller dir fails withWinError 32right after the command exits, with the sender / effect-runtime host as the sole holder; killing the holder makesrmtreesucceed.Passing after (this head):
Review follow-up — the detached observer keeps a bound Codex turn when the caller names its registry and runtime root relatively.
tests/test_usage_goal.py::test_caller_relative_registry_and_runtime_reach_the_detached_observerdrives the realobserve_quota_cycle→ detachedpython -m loopx.usage_goal→ detached TypeScript chain over a read-only SQLite/session fixture, withmonkeypatch.chdirto the caller directory andPath("registry.json")/Path("runtime")as the caller's arguments:before the pinning change (same test, source reverted, tests kept):
after:
Full affected suites on this head:
tests/test_usage_goal.py10 passed in 21.00s;tests/test_usage_ping.pytogether withtests/control_plane/test_effect_runtime_listener_startup.py36 passed / 10 failed. Reverting only the pinning change on this machine leaves the same ten failures with the same names (tests/test_usage_ping.py: 31 passed / 10 failed), so the follow-up adds no host failure.Real-path after-fix (fresh dirs, live children):
Also run on this head:
ruff checkon all four files (all checks passed); the effect-runtime listener/restart/host-permission suites pass;python -m py_compileon the changed files passes;mypyreports only the host-platform POSIX-attribute errors (extensions/process_runtime.py,control_plane/effect_runtime.py:440) that a native Windows run reports regardless of this diff.Canary premerge on this Windows host (
loopx canary premerge --from-git-diff, run twice): all direct checks passed (diff hygiene, changed-filepy_compile, module-ceiling colocation). The catalog ratchet smoke is reported by canary itself asadvisory_inherited_failure— a known baseline failure that does not mention the changed files (its unreviewed finding isturn_driver/dsh_operation_host.pyand reproduces at the base commit, not blocking per canary). Two risk-profile checks fail for a pre-existing canary-runner limitation on Windows, not for this diff:catalog-planner-smokeandpytest-smoke-suite-facade-smokefail because the runner's plainshutil.rmtreeof its ownloopx-smoke-*root cannot remove the read-only.git/objectsfiles created by their git fixtures (fixture cleanup failed; retained ...: [WinError 5]). Invoking the runner path directly for the planner check shows its own exit code is 0 withcatalog-canary-planner-smoke ok;pytest-smoke-suite-facade-smokefails identically on the base commit with this diff absent. Both are the unowned Windows fixture-cleanup class noted in the linked issue, not caused by this change.Pre-existing, not caused by this change: the ten
tests/test_usage_ping.pydisclosure/CLI failures on this Windows host (e.g.test_real_cli_first_result_reaches_http_without_next_day_return,test_claude_smoke_environment_disables_real_cli_and_sender) reproduce identically on the base commit with this diff absent; their root cause isos.path.samestattreating pipe and NUL handles as identical atusage_ping.begin, plus aPath.home()marker collision.AI disclosure
This change was implemented by an AI agent (OpenCode / Claude-family assistant, fork
JasonBuildAI) working under explicit human direction: the maintainer defined the task (one issue + one PR per run, dual importance/value gates), and human review/approval is required before merge. The AI wrote the code, tests, and evidence; no maintainer code or unpublished upstream material was used.Linked issue
Fixes #6642