Skip to content

fix: let /codex:cancel reach turns orphaned by a SIGKILLed companion - #49

Merged
axisrow merged 3 commits into
mainfrom
fix/issue-42-cancel-orphaned-turn
Aug 3, 2026
Merged

fix: let /codex:cancel reach turns orphaned by a SIGKILLed companion#49
axisrow merged 3 commits into
mainfrom
fix/issue-42-cancel-orphaned-turn

Conversation

@axisrow

@axisrow axisrow commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Summary

  • A foreground run killed by Claude Code's Bash tool (~120s ceiling) leaves the underlying Codex turn running inside the detached broker; reconcileRunningJobs lazily flips the job from running to failed, and /codex:cancel's eligibility filter only accepted queued/running jobs — so the orphaned turn became permanently unreachable through the plugin.
  • resolveCancelableJob (plugins/codex/scripts/lib/job-control.mjs) now also treats a job as cancelable when it was reconciled to failed with the UNREPORTED_PROCESS_EXIT_MESSAGE and still carries a threadId/turnId — those ids mean the underlying app-server turn may still be alive, and interruptAppServerTurn already supports attaching to a busy broker to interrupt it.
  • Exported UNREPORTED_PROCESS_EXIT_MESSAGE from state.mjs so job-control.mjs can recognize the reconciliation marker without re-deriving it.
  • This is the "cancel-side" fix proposed in SIGKILLed foreground companion orphans a live app-server turn; /cancel can't reach it #42 — minimal, addresses the user-facing symptom (/cancel can't reach the orphan). The broker-side (turn/interrupt on socket close) and companion-side (SIGTERM/SIGINT handlers) mitigations from the issue are independent and not included here.

Test plan

  • Added cancel reaches a job orphaned by a SIGKILLed companion process (closes #42) in tests/runtime.test.mjs, which reproduces the real sequence: a job record with a dead pid, reconciled to failed via /codex:status, then successfully cancelled via /codex:cancel <id>.
  • Verified the new test fails without the fix (No job found for "task-orphaned") and passes with it.
  • npm test — 179/182 passing; the 3 failures are pre-existing on main (unrelated status/result rendering tests), confirmed identical failure set before and after this change.

Closes #42.

@axisrow

axisrow commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

🔍 Local review (cycle 1) — round af8f360a-c4ba-4e0f-8f96-2f99cd9240d1

Reviewed locally (/review + Codex companion), no bots pinged.

Verdict Reviewer Finding Location
FIX codex Cancel finalized as successful even when the remote turn interrupt failed for an already-pidless orphaned job. plugins/codex/scripts/lib/job-control.mjs:334

/review (Claude) reported no blocking issues on this diff.

axisrow added a commit that referenced this pull request Aug 2, 2026
…ncellation

/codex:cancel was finalizing an orphaned job (pid already nulled by
reconciliation) as "cancelled" even when interruptAppServerTurn failed
to reach the remote broker/turn. settleCancellationAfterTermination
treats a null pid as "stopped" unconditionally, so a failed or
never-attempted interrupt still produced exit 0 and rewrote the job
to cancelled — masking the fact that the detached Codex turn may
still be running with no way to reach it again, since a "cancelled"
job no longer matches isOrphanedTurn's retry gate.

For the orphan path specifically, require interrupt.interrupted to be
true before persisting "cancelled". On failure, restore the exact
orphan marker (status: "failed" + the reconciliation error message)
so a retried /codex:cancel can select and re-attempt the same job via
isOrphanedTurn, and surface the interrupt failure via a thrown error
instead of a false success. The existing pid-based termination path
for live foreground jobs is unaffected.

Found by Codex adversarial review during cycle-review triage of #49.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ph4oGsw1WyeGmrckaDoGkb
@axisrow

axisrow commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

🔍 Local review (cycle 2) — round 0619fdf0-341a-4a0a-85cd-00566f3060fb

Reviewed locally (/review + Codex companion), no bots pinged.

Verdict Reviewer Finding Location
FIX codex The optimistic cancelled-write happened before the (previously unbounded) remote interrupt awaited, so a hang/crash during that wait would persist a false-cancelled state with no rollback ever running. plugins/codex/scripts/codex-companion.mjs:1120

/review (Claude) re-read the cycle-1 fix and found no additional issues in the gating logic itself.

axisrow added a commit that referenced this pull request Aug 2, 2026
…ter it succeeds

Round-2 review found that the orphan-cancellation gate added in the
previous commit still had a crash window: persistCancellation() wrote
"cancelled" to disk BEFORE the (unbounded) await on
interruptAppServerTurn(), and the rollback only ran once that await
resolved. A hung broker/transport, or the companion process being
killed while still awaiting the interrupt, left the job permanently
marked "cancelled" with the rollback never executing — the exact
false-success class of bug this PR exists to fix, now surfaced in
/codex:cancel itself.

Two changes:
- interruptAppServerTurn() now bounds its turn/interrupt RPC with a
  15s default timeout (CODEX_INTERRUPT_TIMEOUT_MS override), since
  it's a control-plane request, not agent work, and must not hang the
  way a turn can.
- handleCancel()'s orphan path is reordered: attempt the interrupt
  first, and only call persistCancellation() if it actually succeeds.
  No job file is touched at all while the interrupt is in flight, so
  there is nothing to roll back — a failed or timed-out interrupt
  simply leaves the existing orphan-failed state untouched. The
  non-orphan (live pid) path is unchanged.

Added a stalled-interrupt fake-codex behavior (mirrors the existing
stalled-initialize pattern) and a regression test proving cancel does
not hang and does not persist "cancelled" when the remote interrupt
never responds.

Found by Codex adversarial review during cycle-review round 2 of #49.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ph4oGsw1WyeGmrckaDoGkb
@axisrow

axisrow commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

🔍 Local review (cycle 3, final) — round 681d9dc6-b596-4472-a6c0-d8938d1a33cf

Reviewed locally (/review + Codex companion), no bots pinged.

Verdict Reviewer Finding Location
HALLUCINATION codex Claimed the broker close-teardown path can hang indefinitely against an unresponsive peer, defeating the interrupt timeout. plugins/codex/scripts/lib/codex.mjs:1193

Why HALLUCINATION: Empirically disproven: net.Socket defaults to allowHalfOpen:false (unchanged by this codebase), so a local end() call unilaterally tears the connection down regardless of peer behavior — verified both by a standalone socket test (closes in ~1ms against a peer that ignores writes and never responds) and by the existing stalled-interrupt regression test, which drives this exact path end-to-end against a real never-responding fake broker and completes in a few seconds, not indefinitely.

/review (Claude) independently re-read the reordered cancel flow and found no additional issues.

No FIX or UNVERIFIED verdicts this round — this is the final review cycle for this PR.

@axisrow

axisrow commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

📋 Review summary — all cycles

Cycle Reviewer Finding Verdict Resolution
1 codex Cancel finalized as successful even when the remote turn interrupt failed for a pid-less orphaned job, making the orphan unretryable. FIX Fixed in 2214ed6
1 claude (no blocking issues found)
2 codex The optimistic cancelled-write happened before the unbounded remote interrupt await, risking a false-cancelled state on a crash or hang mid-wait. FIX Fixed in debc110
2 claude (no additional issues found)
3 codex Claimed broker close() teardown can hang indefinitely against an unresponsive peer, defeating the new interrupt timeout. HALLUCINATION Refuted with socket-semantics test + existing regression test evidence; no change needed
3 claude (no additional issues found)

Totals: 2 FIX (both resolved), 1 HALLUCINATION (refuted), 0 SKIP, 0 UNVERIFIED.

Local review (/review + Codex companion, model sol/effort xhigh), no bots pinged. All three cycles review-bound to base fa3c630 / current head debc110.

axisrow and others added 3 commits August 3, 2026 09:20
A foreground run killed by Claude Code's Bash tool leaves the
underlying Codex turn running inside the detached broker. The job is
lazily reconciled from "running" to "failed" on the next listJobs
call, and resolveCancelableJob only considered "queued"/"running"
jobs cancelable — so the orphaned turn became permanently unreachable
through /codex:cancel even though interruptAppServerTurn already
supports attaching to a busy broker via threadId/turnId.

Widen the cancel eligibility filter to also accept jobs reconciled to
"failed" with the unreported-process-exit message when they still
carry a threadId and turnId, since those ids mean the underlying
app-server turn may still be live and reachable for interrupt. No
change to reconciliation or status display: the job record is still
accurately "failed", cancel just gets a second path to reach it.

Closes #42.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ph4oGsw1WyeGmrckaDoGkb
…ncellation

/codex:cancel was finalizing an orphaned job (pid already nulled by
reconciliation) as "cancelled" even when interruptAppServerTurn failed
to reach the remote broker/turn. settleCancellationAfterTermination
treats a null pid as "stopped" unconditionally, so a failed or
never-attempted interrupt still produced exit 0 and rewrote the job
to cancelled — masking the fact that the detached Codex turn may
still be running with no way to reach it again, since a "cancelled"
job no longer matches isOrphanedTurn's retry gate.

For the orphan path specifically, require interrupt.interrupted to be
true before persisting "cancelled". On failure, restore the exact
orphan marker (status: "failed" + the reconciliation error message)
so a retried /codex:cancel can select and re-attempt the same job via
isOrphanedTurn, and surface the interrupt failure via a thrown error
instead of a false success. The existing pid-based termination path
for live foreground jobs is unaffected.

Found by Codex adversarial review during cycle-review triage of #49.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ph4oGsw1WyeGmrckaDoGkb
…ter it succeeds

Round-2 review found that the orphan-cancellation gate added in the
previous commit still had a crash window: persistCancellation() wrote
"cancelled" to disk BEFORE the (unbounded) await on
interruptAppServerTurn(), and the rollback only ran once that await
resolved. A hung broker/transport, or the companion process being
killed while still awaiting the interrupt, left the job permanently
marked "cancelled" with the rollback never executing — the exact
false-success class of bug this PR exists to fix, now surfaced in
/codex:cancel itself.

Two changes:
- interruptAppServerTurn() now bounds its turn/interrupt RPC with a
  15s default timeout (CODEX_INTERRUPT_TIMEOUT_MS override), since
  it's a control-plane request, not agent work, and must not hang the
  way a turn can.
- handleCancel()'s orphan path is reordered: attempt the interrupt
  first, and only call persistCancellation() if it actually succeeds.
  No job file is touched at all while the interrupt is in flight, so
  there is nothing to roll back — a failed or timed-out interrupt
  simply leaves the existing orphan-failed state untouched. The
  non-orphan (live pid) path is unchanged.

Added a stalled-interrupt fake-codex behavior (mirrors the existing
stalled-initialize pattern) and a regression test proving cancel does
not hang and does not persist "cancelled" when the remote interrupt
never responds.

Found by Codex adversarial review during cycle-review round 2 of #49.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ph4oGsw1WyeGmrckaDoGkb
@axisrow
axisrow force-pushed the fix/issue-42-cancel-orphaned-turn branch from debc110 to f67a09f Compare August 3, 2026 01:27
@axisrow
axisrow merged commit 171c5e1 into main Aug 3, 2026
@axisrow
axisrow deleted the fix/issue-42-cancel-orphaned-turn branch August 3, 2026 01:43
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.

SIGKILLed foreground companion orphans a live app-server turn; /cancel can't reach it

1 participant