Skip to content
Open
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
9046e47
test: add queue-driven fake-codex harness for multi-turn tests
kingdoooo May 17, 2026
0e9b8e5
test: harden fake-codex harness close() and dedupe saves
kingdoooo May 17, 2026
3c47c59
feat(adversarial-review): add investigate + finalize prompt templates
kingdoooo May 17, 2026
53f9e75
feat(codex): add runAppServerInvestigation for two-phase reviews
kingdoooo May 17, 2026
2708672
test: strengthen soft-error assertion to check error message
kingdoooo May 17, 2026
fbb2bc7
feat(adversarial-review): add investigate/finalize prompt builders
kingdoooo May 17, 2026
cfa0423
fix(adversarial-review): route self-collect through two-phase investi…
kingdoooo May 17, 2026
8b25e7b
feat(adversarial-review): show banner when investigation is truncated
kingdoooo May 17, 2026
93609a5
feat(adversarial-review): plumb --max-investigation-turns CLI flag
kingdoooo May 17, 2026
a32d1d1
docs(adversarial-review): document --max-investigation-turns
kingdoooo May 17, 2026
5b767ff
test(commands): allow --max-investigation-turns in argument-hint asse…
kingdoooo May 17, 2026
950329c
fix(codex): normalize runAppServerInvestigation status to numeric
kingdoooo May 17, 2026
0981f6d
test(investigation): cover phase-2 finalize transport error path
kingdoooo May 17, 2026
6992596
feat(adversarial-review): point to --max-investigation-turns in banner
kingdoooo May 17, 2026
3de1895
fix(adversarial-review): tighten --max-investigation-turns validation
kingdoooo May 18, 2026
daac52f
fix(adversarial-review): converge on any 0-command turn with an agent…
kingdoooo May 18, 2026
a7a90c7
fix(adversarial-review): retry finalize turn if the model runs commands
kingdoooo May 18, 2026
324836e
fix(adversarial-review): narrow inline-diff path to single-file reviews
kingdoooo May 18, 2026
d222542
fix(adversarial-review): force status=1 when investigation turn errors
kingdoooo May 18, 2026
510e750
fix(adversarial-review): harden self-collect path against upstream st…
kingdoooo Jun 6, 2026
7c68363
fix(adversarial-review): exit success when a recovered run has a vali…
kingdoooo Jun 6, 2026
d2157c2
fix(adversarial-review): don't double-count the final finalize attempt
kingdoooo Jun 6, 2026
3f7b7e3
fix(adversarial-review): honor turn idle timeout on the inline review…
kingdoooo Jun 7, 2026
980cd8b
fix(adversarial-review): route skipped untracked single-file reviews …
kingdoooo Jun 7, 2026
476b63b
fix(adversarial-review): handle empty diffs and empty completed runs
kingdoooo Jun 8, 2026
e5f0c56
fix(adversarial-review): keep the idle watchdog review-only
kingdoooo Jun 8, 2026
e03613b
docs: design for recovered-turn status fix (task + native review + st…
kingdoooo Jun 8, 2026
db50a08
docs: implementation plan for recovered-turn status fix
kingdoooo Jun 8, 2026
4952b9e
docs: promote investigation-loop turn-lifecycle race to a separate wo…
kingdoooo Jun 9, 2026
0c79421
feat(codex): add resolveRunExitStatus recovery helper
kingdoooo Jun 9, 2026
66becdc
fix(codex): exit success on recovered task and native review turns
kingdoooo Jun 9, 2026
b59f973
test(codex): let queue-driven fixture script native review text and t…
kingdoooo Jun 9, 2026
370ac7c
test(codex): stop-gate parses ALLOW when the review task recovered fr…
kingdoooo Jun 9, 2026
bedf5c9
docs: design for investigation turn-lifecycle race (Defect A+B+C)
kingdoooo Jun 9, 2026
764e07d
docs: implementation plan for investigation turn-lifecycle race (A+B+C)
kingdoooo Jun 9, 2026
b4a87ca
docs: revise spec & plan per adversarial review (test-validity fixes)
kingdoooo Jun 9, 2026
f4f0b48
fix(codex): interrupt with buffered turn id when turn/start RPC reply…
kingdoooo Jun 9, 2026
24e2d92
fix(codex): re-arm idle watchdog only for belonging turn traffic (Def…
kingdoooo Jun 9, 2026
b964b96
fix(codex): demote inferred completion to a subagent-gated quiet-wind…
kingdoooo Jun 9, 2026
4975393
test(codex): reproduce the finalize-queue hang end-to-end via busy-th…
kingdoooo Jun 9, 2026
219ab61
test(codex): de-flake Defect B timing assertion; document serialize b…
kingdoooo Jun 9, 2026
d72f726
test(codex): keep subagent fallback fast via CODEX_INFERRED_COMPLETIO…
kingdoooo Jun 9, 2026
8fec631
docs: note CODEX_INFERRED_COMPLETION_QUIET_MS is a deliberately-inter…
kingdoooo Jun 9, 2026
0a2c114
Merge origin/main into feat/codex-self-collect-multiturn
kingdoooo Jun 26, 2026
c160b15
Merge remote-tracking branch 'origin/main' into feat/codex-self-colle…
kingdoooo Jul 27, 2026
e94cbc7
feat(review): raise idle-watchdog default to 1200s for slow reasoning…
kingdoooo Jul 28, 2026
6ae002a
feat(review): embed the full diff in multi-turn investigation up to a…
kingdoooo Jul 28, 2026
97e63ee
fix(review): own the untracked gap in fed investigation guidance
kingdoooo Jul 28, 2026
9e12d85
feat(review): run the finalize turn at medium effort (CODEX_COMPANION…
kingdoooo Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
636 changes: 636 additions & 0 deletions docs/superpowers/plans/2026-06-08-fix-status-on-soft-error.md

Large diffs are not rendered by default.

190 changes: 190 additions & 0 deletions docs/superpowers/specs/2026-06-08-fix-status-on-soft-error-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# Design: fix recovered-turn status being mis-marked as failed

Date: 2026-06-08
Repo: /Users/kentpeng/projects/codex-plugin-cc
Branch: feat/codex-self-collect-multiturn (PR #328 → openai:main)
Source brainstorm: docs/fix-status-on-soft-error.md

## Problem

Commit `d222542` on this PR changed the shared `buildResultStatus`
(`plugins/codex/scripts/lib/codex.mjs:740`) to return non-zero whenever a turn
recorded *any* `error` — including a transient one the turn recovered from:

```js
function buildResultStatus(turnState) {
if (turnState.error) return 1; // added by d222542
return turnState.finalTurn?.status === "completed" ? 0 : 1;
}
```

The app-server multiplexes transient retry notices (e.g. `Reconnecting... 1/5`)
onto the same `error` notification channel as fatal turn failures, and the
capture state records the last one seen **without clearing it**. So a turn can
simultaneously have `turnState.error` set (stale transient notice) AND
`finalTurn.status === "completed"` with a valid `lastAgentMessage`. After
`d222542`, `buildResultStatus` returns 1 for that recovered turn.

### Who compensates, who doesn't (verified against current code)

| Path | State | Location |
|------|-------|----------|
| Adversarial review | COMPENSATED | `executeReviewRun` codex-companion.mjs:575 — exits 0 when a valid parsed verdict exists |
| Investigation runner | COMPENSATED internally | codex.mjs:1218 — aborts only when `error && !turnRecovered` |
| **/codex:task** | **NOT compensated** | `executeTaskRun` codex-companion.mjs:655 returns `exitStatus: result.status` raw |
| **Native /codex:review** | **NOT compensated** | `executeReviewRun` Review branch codex-companion.mjs:414 returns `exitStatus: result.status` raw |

### Blast radius

1. `runTrackedJob` (lib/tracked-jobs.mjs:156): `exitStatus !== 0` ⇒ job recorded
as **failed**, foreground command exits non-zero.
2. Stop-review-gate hook (scripts/stop-review-gate-hook.mjs:120): keys off
`result.status !== 0` and returns a "task failed" block — without ever
parsing the `ALLOW:`/`BLOCK:` answer the model produced. A recovered gate
review = **false-positive session block**.

### Key nuance found during brainstorm

The three callers do not share one definition of "usable output":

- task → `result.finalMessage`
- native review → `result.reviewText`
- adversarial review → a *parsed structured verdict* (`parsed.parsed`), stricter
than "any message present", and it reuses `result.status` as its failure
fallback.

Therefore pushing the fix down into `buildResultStatus`/the runners is **not**
free: a recovered-but-unparseable adversarial run would flip exit 1 → 0 (a new
regression) unless the adversarial fallback were also tightened. We avoid that
by fixing at the caller layer with a shared helper, leaving `result.status`
semantics and the already-correct adversarial path untouched.

## Required behavior

A turn that **completed with a usable result** (`finalTurn.status ===
"completed"` and usable output present) but recorded a transient `error` must be
treated as SUCCESS: exit 0, job recorded "completed", gate proceeds to parse the
answer. A turn that genuinely failed (no usable output, or `finalTurn.status !==
"completed"`) keeps non-zero status.

## Chosen approach: shared caller-level helper (A2)

Add the recovery rule in ONE named place and apply it at the two un-compensated
caller sites. Do not touch `buildResultStatus`, the runners, or the adversarial
path.

### `plugins/codex/scripts/lib/codex.mjs` — new function

```js
function resolveRunExitStatus(result, usableText) {
const recovered = result.turn?.status === "completed"
&& Boolean(String(usableText ?? "").trim());
return recovered ? 0 : result.status;
}
```

Both runners already return `turn: turnState.finalTurn` (codex.mjs:1040 and
:1109), so `result.turn?.status` is available on both paths.

### `plugins/codex/scripts/codex-companion.mjs` — three sites

- `executeTaskRun` (:655):
`exitStatus: resolveRunExitStatus(result, result.finalMessage)`, and set
`payload.status` to the same resolved value (removes the JSON inconsistency
where a recovered/success task still reports `status: 1`).
- Native-review branch (:414):
`exitStatus: resolveRunExitStatus(result, result.reviewText)`, and set
`payload.codex.status` to the same resolved value.
- Adversarial branch (:575): **unchanged** — already correct.

### Rendering — no change needed (verified)

- `renderTaskResult` (render.mjs:350) prefers `rawOutput` (= `finalMessage`) and
only falls back to `failureMessage` when it is empty. A recovered task renders
its real answer, not the stale `Reconnecting...` notice.
- `renderNativeReviewResult` (render.mjs:323) prefers `stdout` (= `reviewText`)
regardless of `status`.

### Stop-gate hook — no change needed (verify by test)

The hook keys off the child process exit status (`result.status` in
stop-review-gate-hook.mjs:120). Once the task exits 0 on recovery, the hook
proceeds to parse the `ALLOW:`/`BLOCK:` answer. This propagation must be
confirmed by test C, not assumed.

## Test plan (`node --test`, TDD: write failing tests first)

### A. Task path — queue-driven fixture
Mirror `"finalize turn that recovered from a transient reconnect keeps its valid
verdict (e2e)"` in tests/investigation.test.mjs, but drive `task --json`.

1. Recovered = success: queue
`{ finalAnswer: { text: "ALLOW: looks fine" }, turnError: { message: "Reconnecting... 1/5" } }`,
run companion `task --json`. Assert `result.status === 0` (process exit),
`payload.rawOutput` contains the answer, `payload.status === 0` (the
normalized JSON field).
2. Genuine-failure guard: a turn with `turnError` AND no `finalMessage` (or
`finalTurn` not completed) must still exit non-zero.

### B. Native review path — same fixture, `review` branch
Recovered turn with `reviewText` present + `turnError` ⇒ `exitStatus 0`; a
failure turn with no `reviewText` ⇒ non-zero. Confirms `resolveRunExitStatus`
uses `reviewText`, not `finalMessage`.

### C. Stop-gate hook — `installFakeCodex` named-scenario harness (runtime.test.mjs)
The existing gate tests use `installFakeCodex(binDir, behavior)` named scenarios,
not the queue-driven fixture. The queue-driven path already emits `entry.turnError`
as an `error` notification (tests/fake-codex-fixture.mjs ~413); extend the
named-scenario path with an equivalent switch (or add a `stop-gate-recovered`
scenario) that emits `error` notice + valid agent message + `turn/completed`.

- Assert: a recovered gate task yields `ok:true` and the ALLOW/BLOCK answer is
parsed — NOT a "task failed" block.
- Keep the existing `"... blocks on findings"` and `"... allows ... when clean"`
gate tests green.

### D. Regression guards (must stay green)
- Existing adversarial `"recovered finalize keeps its valid verdict"` (confirms
A2 did not touch the adversarial path).
- Idle-watchdog tests (a genuine idle timeout must still be a failure).

## Verification before done

- `node --test tests/*.test.mjs` — full suite. Known PRE-EXISTING failures
unrelated to this work (NOT regressions): `status shows phases, hints, and the
latest finished job`, `status preserves adversarial review kind labels`,
`result returns the stored output for the latest finished job by default`,
`resolveStateDir uses a temp-backed per-workspace directory`. Net new failures
must be zero.
- Confirm the suite EXITS CLEANLY (no hang). Do not abandon turns in tests —
always let them settle with the fixture's normal queued responses.

## Deploy to the live local install (after merge-ready)

The running plugin is the CACHE build, not this repo. Copy the changed files to
`~/.claude/plugins/cache/openai-codex/codex/1.0.4/scripts/...` (back up first;
mapping in memory `codex-plugin-runtime-source`).

## Out of scope (track separately; do NOT bundle)

`?? ""`-empty-string family; native review missing idle watchdog / empty-diff
short-circuit; `--turn-idle-timeout` upper bound; `runAppServerInvestigation`
`truncated` mislabel at 0 commands; uncommitted dead `runAppServerTurn` import in
tests; stale `DEFAULT_INLINE_DIFF_MAX_FILES` comment.

**Promoted to a separate work item (own brainstorm → spec → plan):** the
investigation-loop turn-lifecycle race. The recon loop advances to the finalize
turn on an *inferred* completion (`scheduleInferredCompletion`, codex.mjs:393)
that fires on the first `final_answer`-phase message — which can be a "ready to
finalize" readiness cue, not the real end of the turn. The finalize `turn/start`
is then dispatched while the app-server's recon turn is still active; the server
queues it and never opens it, the real verdict streams under the recon turn and
is discarded, and the turn hangs until the 180s idle watchdog aborts. This
bundles **Defect A** (premature finalize dispatch; fix = wait for a real
`turn/completed`, demote inference to a guarded fallback) with **Defect B**
(`captureTurn` `armIdle()` runs before the `belongsToTurn` filter, codex.mjs:631,
so orphaned cross-turn traffic re-arms the captured turn's watchdog and masks the
stuck turn). Both live in `captureTurn`/the idle-watchdog subsystem; fix them
together, NOT in this status-fix change. Evidence: live run thread
`019ea5d4-0c53-75f1-9032-5573d18cd878` in `~/.codex/logs_2.sqlite` (2026-06-08
~06:21).
4 changes: 3 additions & 1 deletion plugins/codex/commands/adversarial-review.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
description: Run a Codex review that challenges the implementation approach and design choices
argument-hint: '[--wait|--background] [--base <ref>] [--scope auto|working-tree|branch] [focus ...]'
argument-hint: '[--wait|--background] [--base <ref>] [--scope auto|working-tree|branch] [--max-investigation-turns N] [--turn-idle-timeout SECONDS] [focus ...]'
disable-model-invocation: true
allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), AskUserQuestion
---
Expand Down Expand Up @@ -43,6 +43,8 @@ Argument handling:
- It supports working-tree review, branch review, and `--base <ref>`.
- It does not support `--scope staged` or `--scope unstaged`.
- Unlike `/codex:review`, it can still take extra focus text after the flags.
- For very large diffs that exceed the inline threshold, Codex investigates the diff with read-only commands across multiple turns. Use `--max-investigation-turns N` (default 10) to raise or lower the cap.
- If a turn stalls with no output for `--turn-idle-timeout SECONDS` (default 180), the run aborts gracefully with a clear failure instead of hanging. Lower it to fail faster on a flaky connection; raise it for very slow turns.

Foreground flow:
- Run:
Expand Down
54 changes: 54 additions & 0 deletions plugins/codex/prompts/adversarial-review-finalize.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<role>
You have just completed an investigation of a code change. Now produce the structured adversarial review.
</role>

<task>
Based on your investigation in the prior turns of this thread, write up your findings as a structured review.
Target: {{TARGET_LABEL}}
User focus: {{USER_FOCUS}}
</task>

<finding_bar>
Report only material findings.
Do not include style feedback, naming feedback, low-value cleanup, or speculative concerns without evidence.
A finding should answer:
1. What can go wrong?
2. Why is this code path vulnerable?
3. What is the likely impact?
4. What concrete change would reduce the risk?
</finding_bar>

<structured_output_contract>
This is the finalization turn. Do NOT run any shell commands or tool calls in this turn — your investigation is already complete and you have all the context you need from the prior turns of this thread.
Return only valid JSON matching the provided schema. Your entire output must be that JSON — no prose before or after, no shell commands, no tool-call payloads.
Keep the output compact and specific.
Use `needs-attention` if there is any material risk worth blocking on.
Use `approve` only if you cannot support any substantive adversarial finding from your investigation.
Every finding must include:
- the affected file
- `line_start` and `line_end`
- a confidence score from 0 to 1
- a concrete recommendation
Write the summary like a terse ship/no-ship assessment, not a neutral recap.
</structured_output_contract>

<grounding_rules>
Be aggressive, but stay grounded.
Every finding must be defensible from what you read during the investigation.
Do not invent files, lines, code paths, incidents, attack chains, or runtime behavior you cannot support.
If a conclusion depends on an inference, state that explicitly in the finding body and keep the confidence honest.
</grounding_rules>

<calibration_rules>
Prefer one strong finding over several weak ones.
Do not dilute serious issues with filler.
If the change looks safe, say so directly and return no findings.
</calibration_rules>

<final_check>
Before finalizing, check that each finding is:
- adversarial rather than stylistic
- tied to a concrete code location
- plausible under a real failure scenario
- actionable for an engineer fixing the issue
</final_check>
48 changes: 48 additions & 0 deletions plugins/codex/prompts/adversarial-review-investigate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<role>
You are Codex performing an adversarial software review.
Your job is to break confidence in the change, not to validate it.
This is the investigation phase: gather evidence with read-only commands before producing any structured output.
</role>

<task>
Investigate the change so you can later produce a confident adversarial assessment.
Target: {{TARGET_LABEL}}
User focus: {{USER_FOCUS}}
</task>

<operating_stance>
Default to skepticism.
Assume the change can fail in subtle, high-cost, or user-visible ways until the evidence says otherwise.
Do not give credit for good intent, partial fixes, or likely follow-up work.
If something only works on the happy path, treat that as a real weakness.
</operating_stance>

<attack_surface>
Prioritize the kinds of failures that are expensive, dangerous, or hard to detect:
- auth, permissions, tenant isolation, and trust boundaries
- data loss, corruption, duplication, and irreversible state changes
- rollback safety, retries, partial failure, and idempotency gaps
- race conditions, ordering assumptions, stale state, and re-entrancy
- empty-state, null, timeout, and degraded dependency behavior
- version skew, schema drift, migration hazards, and compatibility regressions
- observability gaps that would hide failure or make recovery harder
</attack_surface>

<investigation_method>
Use read-only shell commands to inspect the diff and the surrounding code.
Useful starting points: `git diff`, `git log`, `git show`, `git blame`, `cat`, `rg`/`grep`.
Read the changed files, follow references, and confirm or refute hypotheses with evidence from the code.
Do not modify any files. Your sandbox is read-only.
{{REVIEW_COLLECTION_GUIDANCE}}
</investigation_method>

<convergence>
Continue investigating until you can defend a confident adversarial assessment.
When you have seen enough, emit a brief summary message describing what you found and stop running commands.
A summary message with no further command calls signals that you are ready for the finalization phase.
Do not produce a structured review yet — that comes in the next phase.
</convergence>

<repository_context>
{{REVIEW_INPUT}}
</repository_context>
3 changes: 2 additions & 1 deletion plugins/codex/prompts/adversarial-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ A finding should answer:
</finding_bar>

<structured_output_contract>
Return only valid JSON matching the provided schema.
This is a single-turn review. Do NOT run any shell commands or tool calls — the file contents you need are already embedded in the repository_context block below. Do not emit a tool-use stub like `{"cmd": "..."}` instead of the review; that is not the schema.
Return only valid JSON matching the provided schema. Your entire output must be that JSON — no prose before or after, no shell commands, no tool-call payloads.
Comment on lines +49 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route skipped single-file reviews to self-collect

When the working tree contains exactly one untracked file whose contents are skipped (for example >24 KiB, binary, or unreadable), collectReviewContext can still choose the inline path because its byte check only measures git diffs and the file count is now within the one-file cap; the prompt then tells Codex that the needed contents are embedded and forbids any shell commands. In that scenario the review can only see a (skipped: ...) marker and must approve or guess without inspecting the actual change, so skipped untracked single-file reviews should fall through to self-collect or otherwise allow read-only inspection.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 980cd8b. Skipped untracked single-file reviews now fall through to self-collect.

Root cause confirmed: an untracked file never appears in git diff, so its bytes don't count toward the inline diff-size measurement. A single untracked file whose contents are skipped (>24 KiB, binary, a directory, or unreadable) looked like a 1-file, 0-byte diff and slipped onto the inline path, where the prompt embeds only a (skipped: ...) marker and forbids shell — leaving the reviewer nothing to inspect.

The untracked skip decision is now a single source of truth (classifyUntrackedFile), and collectReviewContext forces self-collect for working-tree reviews whenever hasSkippedUntrackedContent is true, so Codex can inspect the change with read-only commands.

Tests (tests/git.test.mjs): a single oversized untracked file and a single binary untracked file both route to self-collect; a guard test confirms a small text untracked file still inlines (no over-correction). Both hazard cases were red before the fix.

Keep the output compact and specific.
Use `needs-attention` if there is any material risk worth blocking on.
Use `approve` only if you cannot support any substantive adversarial finding from the provided context.
Expand Down
Loading