Repository navigation
[Bug]: every stop refusal is reported as a CODEX_HOME ownership mismatch, hiding respawnable_service and looping the operator #4169
Description
Client or integration
ocx CLI on Windows (Task Scheduler service backend). Observed on an installed 2.43.0; the code path is unchanged on 2.49.0.
Area
Service lifecycle
Summary
When the proxy refuses a stop, stopProxy reports every refusal as a CODEX_HOME/OPENCODEX_HOME ownership mismatch. The management API distinguishes three different 409 refusals, but the CLI collapses them into one message that names a cause the operator can neither confirm nor act on.
In my case the homes matched exactly, and the server's actual answer was respawnable_service. Because the printed message said the opposite, I spent three attempts re-exporting CODEX_HOME before probing POST /api/stop directly and seeing the real code.
The advice also forms a loop for the operator:
POST /api/stop→ 409respawnable_service: "the stop must be run byocx stop"ocx stop→ "a service installed under a different CODEX_HOME/OPENCODEX_HOME owns it. Run the stop from that home."
Each command points at the other, and neither names the Task Scheduler wrapper that is actually refusing.
Reproduction
- On Windows, install the service backend (Task Scheduler) so the proxy is respawnable.
- Run
ocx update(orocx stop) from the sameCODEX_HOMEthe service was installed with. - Observe the printed failure.
Observed:
🛑 Service manager stopped.
❌ Failed to stop proxy (PID <pid>).
The running proxy refused to stop: a service installed under a different
CODEX_HOME/OPENCODEX_HOME owns it. Run the stop from that home.
The server's real answer, probed directly with the admin token:
$ curl -s -o body -w "HTTP %{http_code}\n" -X POST http://127.0.0.1:10100/api/stop -H "Authorization: Bearer <token>"
HTTP 409
$ cat body
{"success":false,"code":"respawnable_service","message":"This proxy is managed by a Task Scheduler wrapper that can respawn it, so the stop must be run by `ocx stop`, which verifies the respawn window. Nothing was changed."}
Ownership was not mismatched. ~/.opencodex/service-state.json:
{
"version": 2,
"codexHome": "C:\\Users\\<user>\\.codex",
"opencodexHome": "C:\\Users\\<user>\\.opencodex",
"backend": "scheduler"
}…and the command ran with CODEX_HOME=C:\Users\<user>\.codex, i.e. equal to state.codexHome.
Version
2.43.0 (observed). Re-checked against 2.49.0 and dev (2.50.0): the message strings and branching are unchanged.
Operating system
Windows 11 (Task Scheduler service backend). Codex runtime 0.153.4.
Expected
The refusal should name the cause the server actually reported. A respawnable_service or service_state_unknown refusal should not be described as an ownership mismatch, and the recommended next command should not be the command the operator just ran.
Where it comes from
src/server/management-api.ts returns three distinct refusal codes for POST /api/stop:
respawnable_serviceself_unload_serviceservice_state_unknown
src/lib/process-control.ts keeps the server text in lastRefusalMessage and uses it when present, but the fallback hardcodes the ownership wording for all three, and the surrounding comments state ownership as fact:
// The proxy refused on purpose (foreign service owns it). Forcing would strip shared
// config while that service keeps the proxy alive.
throw new ProxyOwnershipRefusedError(
lastRefusalMessage
?? "The running proxy refused to stop: a service installed under a different "
+ "CODEX_HOME/OPENCODEX_HOME owns it. Run the stop from that home.",
);The same assumption appears at the 409 branch of stopProxyGracefully ("a service installed under another home owns it and would respawn it anyway") and in the ProxyOwnershipRefusedError name itself.
So there are two separable defects:
- The fallback asserts a specific wrong cause. Whenever
lastRefusalMessageis absent, the operator is told aboutCODEX_HOMEregardless of which of the three refusals occurred. - In my run the server message was present but not shown. The printed text was the hardcoded fallback even though
POST /api/stopreturned a non-emptymessagemoments later under the same conditions. I have not isolated whylastRefusalMessagewas null on that path, so I am reporting (1) as the actionable defect and (2) as an observation, not a diagnosis.
What I am not claiming
- I did not reduce (2) to a standalone reproducer, and I did not verify it on 2.49.0 — only that the message strings and branching are unchanged there.
- The eventual workaround on my side was terminating the proxy process directly and letting the scheduler respawn it on the new package, which is outside what this report is about.
Suggested direction
Carry the server's code alongside the message and select the fallback wording from it, so a refusal without a body still names the right family (respawnable wrapper / self-unload service / unreadable scheduler state) rather than defaulting to ownership. Renaming ProxyOwnershipRefusedError to something cause-neutral would keep call sites honest.
Happy to open a PR against dev if this direction looks right.
Checks
- I searched existing issues and documentation.
- This request describes a concrete OpenCodex workflow rather than merely naming a desired technology.
- I removed secrets and personal data.
Activity
github-actions commentedon Sep 10, 2026
Issue reopened
The report now contains the information required by the automated check. Thanks for updating it.
yeongjunyoo commentedon Sep 10, 2026
Added the missing Version and Operating system sections that the quality check flagged — 2.43.0 observed on Windows 11 with the Task Scheduler backend, re-checked against 2.49.0 and dev (2.50.0) where the branching is unchanged.
PR #4170 implements the direction described here.
yeongjunyoo commentedon Sep 10, 2026
The two flagged fields are now filled in — Version: 2.43.0 observed, re-checked against 2.49.0 and dev (2.50.0) where the strings and branching are unchanged; Operating system: Windows 11, Task Scheduler service backend, Codex runtime 0.153.4.
I do not have permission to reopen, so flagging it here for re-evaluation.
PR #4170 implements the direction suggested in the report.
fix(stop): carry the refusal cause per attempt, not in module state
lidge-jun commentedon Sep 10, 2026
리뷰 · 우선순위 63 / 80
이 이슈는 Windows Task Scheduler 서비스 백엔드에서
ocx stop/ 업데이트 경로가 프록시 정지 거절을 전부CODEX_HOME/OPENCODEX_HOME소유권 불일치로 인쇄하는 버그입니다. 지금devHEAD(c15a98caa)의src/server/management-api.tsPOST /api/stop은 409를 최소 세 코드로 나눕니다.respawnable_service(리스폰 가능한 스케줄러 래퍼),self_unload_service(프록시가 곧 설치된 서비스, #4023),service_state_unknown(스케줄러 상태를 못 읽음). 별도로 진짜 다른 홈 소유 서비스는isServiceOwnershipError경로의 409입니다. 그런데src/lib/process-control.tsstopProxy는 거절 시lastRefusalMessage가 있으면 그걸 쓰고, 없으면 소유권 불일치 문장으로 고정 fallback합니다. 주석도 “foreign service owns it”을 사실처럼 적습니다.실제 피해는 리포터 재현과 같습니다. 집이 이미 같은데 API는
respawnable_service(“ocx stop으로 하라”)인데, CLI는 “다른 집에서 돌리라”고 해서 서로 가리키는 루프가 됩니다. 운영자는 CODEX_HOME을 몇 번이고 다시 맞춥니다. 리포터는 본문에 message가 direct curl에는 있었는데 CLI fallback이 찍힌 관측도 남겼습니다. 그건 전달/파싱 구멍일 수 있어 열린 관찰이고, 당장 고칠 actionable 결함은 “본문이 비었을 때조차 소유권으로 단정하는 fallback”입니다.좋은 소식은 수정 PR이 이미 있습니다. #4170
fix(stop): name the real refusal cause instead of asserting ownership이 Fixes #4169로 열려 있고(Draft), grok-bot 리뷰도 올라간 상태입니다. 그 PR은 거절 code를 같이 저장하고 code별 fallback을 고르며, 기본 문장에서 CODEX_HOME을 빼는 방향입니다. 최근dev의 Lane A(#4152 서비스 가드, #4164 launchctl)와도 같은 정지 경로입니다. types/config 분할과 무관합니다.라인 문제:
src/lib/process-control.ts 라인 179-182 -
ProxyOwnershipRefusedErrorfallback이 세 거절을 모두 소유권 문장으로 덮습니다. 본문이 비는 순간 오안내가 확정됩니다.src/lib/process-control.ts 라인 131-134 주석 - 409를 “다른 집이 소유”로만 설명합니다.
respawnable_service/self_unload_service/service_state_unknown과 어긋납니다.src/server/management-api.ts · ownership 409 - message만 있고 code가 없는 응답이 있습니다. 클라이언트가 code 기반으로 fallback하려면 여기도 code가 필요합니다(#4170 리뷰에서 후속로 제안됨).
관측 (2) lastRefusalMessage가 null - 작성자도 재현을 못 잠갔습니다. #4170이 “틀리게 단정하지 않기”를 먼저 막고, parse/전달 miss는 재현되면 별 이슈로 두는 편이 맞습니다.
메인테이너의 판단이 필요한 지점
너의 추천
이슈는 열린 채로 #4170에 묶어두세요. 중복 수정 PR을 새로 받지 말고, #4170을 privacy/CI·체크리스트 정리 후 merge하는 쪽이 맞습니다. merge되면 이 이슈는 자동/수동으로 닫으면 됩니다. 운영자 루프를 끊는 일이라 우선순위는 중상입니다. types/config 분할 close-don't-rebase 대상이 아닙니다.
이 댓글은 grok-bot이 작성했습니다
test(stop): make the overlap case actually discriminate the global ha…
fix(stop): name the real refusal cause instead of asserting ownership
lidge-jun commentedon Sep 11, 2026
Fixed on
devby #4237, which carried @yeongjunyoo's #4170.stopProxyno longer hardcodes the ownership sentence for every 409. It now selects the refusalmessage from the server's
code—respawnable_service,self_unload_service, andservice_state_unknowneach get their own explanation, and anything else gets a cause-neutral oneinstead of being reported as a
CODEX_HOMEownership mismatch.handleStopprints the matchingrefusalNextStep, so the operator is no longer told to run the command that just refused, which iswhat produced the loop in the report.
Two things were deliberately left out of that change and are not part of this issue: renaming
ProxyOwnershipRefusedError(src/lib/process-control.ts) and attaching acodeto the ownershipand generic-failure 409s in
src/server/management-api.ts. Both are internal naming, both wouldtouch an existing oracle that asserts the current class name, and neither changes what the operator
sees. Observation (2) in the report — the server message present but the CLI still falling back —
could not be reproduced against the current code.
Closing as fixed. If you still see a stop refusal that names
CODEX_HOMEwhen the real cause issomething else, please reopen with the
ocx stopoutput and the server response code.yeongjunyoo commentedon Sep 11, 2026
Follow-up: this refusal is not only a misleading message. Today it blocked a routine
ocx service installand left the machine without a service.I was reinstalling the service to move it onto a clean
CODEX_HOME(elevated,CODEX_HOME=%USERPROFILE%\.codex,ORCA_CODEX_HOMEcleared). Sequence, from my log:Resulting state:
So the two halves behave asymmetrically.
uninstallremoves the registration despite the stop refusal, whileinstalltreats the same refusal as fatal and rolls back. Run back to back, that lands onservice: absentwith a live proxy androuting=native— a reboot would not have brought the proxy back, and Codex was no longer pointed at it.The escape did exist: by then the scheduled task was already gone, so nothing could respawn the proxy. I terminated the listener on port 10100 directly and re-ran
ocx service install, which succeeded immediately, thenocx syncrestoredrouting=opencodex-local. For honesty: I did not tryocx stopat that point, so I cannot say whether it would have worked once the task was absent.Two things this adds to the report:
CODEX_HOME. This shows the same refusal path can interrupt a supported install and leave the service uninstalled, which is a worse resting state than either "installed" or "cleanly removed".install, the message says to runocx stop— but the caller is the tool that is supposed to own the stop, and it has just refused itself.PR #4170 does not change this behaviour. It only stops the message from naming a cause the server never reported. Whether
installshould tolerate a stop refusal the wayuninstalldoes, or the two should be made symmetric, looks like a maintainer call and I did not touch it.Environment: opencodex 2.49.0, Windows 11, Task Scheduler backend, elevated shell.
Ingwannu commentedon Sep 12, 2026
Thanks for the new field evidence linked from #4170. I checked current dev 7a0513c, rather than treating the wording PR as a service fix.
The asymmetry is still present in source: src/service.ts::stopTrackedProxyForServiceCommand catches a stop exception and returns "none", whereas install preparation propagates cleanup failure. installFreshWindowsSchedulerSafely then rolls back its newly registered task if preparation fails before start. This is consistent with the reported service-absent/live-listener resting state, but I have not reproduced the Windows Task Scheduler sequence here.
I would not fix this by swallowing the install refusal: the preparation contract deliberately prevents reporting a healthy installation over a surviving old listener. A focused fix needs manager-aware, identity-verified stop/respawn settlement, with a failure result that preserves or explicitly reports registration and routing state. A port number alone is not permission to terminate a process.
@lidge-jun this needs a separate service-lifecycle fix/decision; #4170 must not close this report. Suggested regression sequence: uninstall refusal, surviving old listener, fresh install preparation refusal, and the resulting registration/routing state. Keep ownership-mismatch and unknown-manager states fail-closed, plus a successful verified-stop/reinstall control. No runtime restarts or service changes were made during this review.
fix(cli): bind hub status credentials to connection snapshots [skip ci]
fix(stop): name the real refusal cause instead of asserting ownership
fix(cli): bind hub status credentials to connection snapshots [skip ci]