Skip to content

[Bug]: every stop refusal is reported as a CODEX_HOME ownership mismatch, hiding respawnable_service and looping the operator #4169

Closed

Description

@yeongjunyoo

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 → 409 respawnable_service: "the stop must be run by ocx 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

  1. On Windows, install the service backend (Task Scheduler) so the proxy is respawnable.
  2. Run ocx update (or ocx stop) from the same CODEX_HOME the service was installed with.
  3. 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_service
  • self_unload_service
  • service_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:

  1. The fallback asserts a specific wrong cause. Whenever lastRefusalMessage is absent, the operator is told about CODEX_HOME regardless of which of the three refusals occurred.
  2. In my run the server message was present but not shown. The printed text was the hardcoded fallback even though POST /api/stop returned a non-empty message moments later under the same conditions. I have not isolated why lastRefusalMessage was 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

  1. added
    bugSomething isn't working
    proxyHTTP proxy, routing, reverse-proxy / management auth
    serviceService lifecycle (WinSW/launchd/scheduler)
    on Sep 10, 2026
  2. github-actions commented on Sep 10, 2026

    @github-actions
    Contributor

    Issue reopened

    The report now contains the information required by the automated check. Thanks for updating it.

  3. yeongjunyoo commented on Sep 10, 2026

    @yeongjunyoo
    ContributorAuthor

    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.

  4. yeongjunyoo commented on Sep 10, 2026

    @yeongjunyoo
    ContributorAuthor

    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.

  5. lidge-jun commented on Sep 10, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 63 / 80

    이 이슈는 Windows Task Scheduler 서비스 백엔드에서 ocx stop / 업데이트 경로가 프록시 정지 거절을 전부 CODEX_HOME/OPENCODEX_HOME 소유권 불일치로 인쇄하는 버그입니다. 지금 dev HEAD(c15a98caa)의 src/server/management-api.ts POST /api/stop은 409를 최소 세 코드로 나눕니다. respawnable_service(리스폰 가능한 스케줄러 래퍼), self_unload_service(프록시가 곧 설치된 서비스, #4023), service_state_unknown(스케줄러 상태를 못 읽음). 별도로 진짜 다른 홈 소유 서비스는 isServiceOwnershipError 경로의 409입니다. 그런데 src/lib/process-control.ts stopProxy는 거절 시 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 - ProxyOwnershipRefusedError fallback이 세 거절을 모두 소유권 문장으로 덮습니다. 본문이 비는 순간 오안내가 확정됩니다.

    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을 이 이슈의 정식 수정으로 받을지(이미 Fixes 링크됨)
    • ownership 409에 code를 #4170에 같이 넣을지 후속으로 뺄지
    • ProxyOwnershipRefusedError 이름 변경을 받을지
    • 리포터 관측 (2)를 별도 조사 이슈로 남길지

    너의 추천
    이슈는 열린 채로 #4170에 묶어두세요. 중복 수정 PR을 새로 받지 말고, #4170을 privacy/CI·체크리스트 정리 후 merge하는 쪽이 맞습니다. merge되면 이 이슈는 자동/수동으로 닫으면 됩니다. 운영자 루프를 끊는 일이라 우선순위는 중상입니다. types/config 분할 close-don't-rebase 대상이 아닙니다.

    이 댓글은 grok-bot이 작성했습니다

  6. lidge-jun commented on Sep 11, 2026

    @lidge-jun
    Owner

    Fixed on dev by #4237, which carried @yeongjunyoo's #4170.

    stopProxy no longer hardcodes the ownership sentence for every 409. It now selects the refusal
    message from the server's code — respawnable_service, self_unload_service, and
    service_state_unknown each get their own explanation, and anything else gets a cause-neutral one
    instead of being reported as a CODEX_HOME ownership mismatch. handleStop prints the matching
    refusalNextStep, so the operator is no longer told to run the command that just refused, which is
    what 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 a code to the ownership
    and generic-failure 409s in src/server/management-api.ts. Both are internal naming, both would
    touch 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_HOME when the real cause is
    something else, please reopen with the ocx stop output and the server response code.

  7. yeongjunyoo commented on Sep 11, 2026

    @yeongjunyoo
    ContributorAuthor

    Follow-up: this refusal is not only a misleading message. Today it blocked a routine ocx service install and 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_HOME cleared). Sequence, from my log:

    --- service uninstall ---
    Failed to stop proxy: 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.
    Removed the opencodex managed block from Grok config.
    service uninstalled.                      <-- proceeded anyway
    
    --- service install ---
    Service install cleanup failed: 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.
    The new Task Scheduler registration was rolled back.
    

    Resulting state:

    Proxy: running (PID 21096)
    routing=native, service=absent
    Service: not installed
    

    So the two halves behave asymmetrically. uninstall removes the registration despite the stop refusal, while install treats the same refusal as fatal and rolls back. Run back to back, that lands on service: absent with a live proxy and routing=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, then ocx sync restored routing=opencodex-local. For honesty: I did not try ocx stop at that point, so I cannot say whether it would have worked once the task was absent.

    Two things this adds to the report:

    1. Severity. The original report was about an operator being sent to the wrong 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".
    2. The advice is still circular at the moment it is printed. During install, the message says to run ocx 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 install should tolerate a stop refusal the way uninstall does, 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.

  8. Ingwannu commented on Sep 12, 2026

    @Ingwannu
    Owner

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingproxyHTTP proxy, routing, reverse-proxy / management authserviceService lifecycle (WinSW/launchd/scheduler)

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions