Skip to content

fix: fail loud when a delegated Codex task run performs no work - #617

Open
petersimmons1972 wants to merge 1 commit into
openai:mainfrom
petersimmons1972:fix/work-evidence-gate-264
Open

fix: fail loud when a delegated Codex task run performs no work#617
petersimmons1972 wants to merge 1 commit into
openai:mainfrom
petersimmons1972:fix/work-evidence-gate-264

Conversation

@petersimmons1972

Copy link
Copy Markdown

Problem

scripts/lib/tracked-jobs.mjs treats execution.exitStatus === 0 as
completed with no check on whether the delegated turn actually did
anything (tracked-jobs.mjs:156 in the shipped 1.0.6 build). A Codex
app-server turn that reports "completed" status commits to nothing about
work performed — a turn where the model described a plan and stopped, with
no file touched and no command run, exits/completes exactly like a turn
that made the change. grep-ing the shipped plugin for
workEvidence|emptyRun returns zero matches: there is no gate at all
(fleet#264).

Live specimen

fleet PR #292 (closed) is this failure mode end to end: the delegate's
REPORT claimed the work was done and run_state recorded it as delivered,
but the actual PR diff was empty. Nothing in the codex bridge would have
caught this — exitStatus was 0, so the job was marked completed, and
every caller downstream treated completed as evidence of delivered work.

Precedent

This is the same defect class already fixed in the sibling Grok bridge:
xai-org/grok-build-plugin-cc#16, "fail loud when a delegated run performs
no work" (fleet#254). That fix added a work-evidence.mjs module and a
gate in tracked-jobs.mjs that demotes an empty run from completed to
failed regardless of process exit code, using num_turns from the Grok
CLI's structured JSON output as the discriminating signal.

This fix

The codex app-server protocol already gives a better signal than the
Grok CLI's JSON envelope hack: scripts/lib/codex.mjs's captureTurn
collects fileChanges and commandExecutions thread items for every turn,
and runAppServerTurn already returns touchedFiles /
commandExecutions on the result. No telemetry-extraction step needs
porting — only the gating decision.

Adds scripts/lib/work-evidence.mjs with assessWorkEvidence():

  • For a --write task run: noWork if the turn touched zero files AND
    ran zero commands (the fleet#292 shape).
  • For any run: noWork if there is zero output text AND zero tool
    activity.
  • Read-only (write: false) runs are not gated on touched-file count —
    answering a question legitimately touches nothing.
  • requireWork: false (new --allow-no-work CLI flag) disables the gate
    for callers that intentionally want a plan-only run.

Wires the verdict through codex-companion.mjs::executeTaskRun,
buildTaskRequest (so background task-worker runs enforce the same
gate as foreground), and runForegroundCommand (foreground exit code 3
on an empty run, mirroring the grok bridge's exit-3 convention). Wires
tracked-jobs.mjs::runTrackedJob to demote completionStatus to
failed whenever execution.workVerdict.noWork is true, independent of
exitStatus, and records workEvidence / emptyRun / errorMessage on
the stored job record so /codex:status surfaces the reason.

Verification

Unit + integration checks run against a scratch copy of the plugin tree
(not committed to this repo; see fleet#264 lane E1 receipt for paths):

  • assessWorkEvidence positive/negative/read-only/disabled-gate cases.
  • runTrackedJob end-to-end: a stubbed execution with exitStatus: 0 and
    workVerdict.noWork: true lands as job status failed
    (emptyRun: true); the positive-work control with the same
    exitStatus: 0 lands as completed.
  • git apply --check and node --check against a freshly copied,
    unmodified 1.0.6 tree (not the tree the patch was authored against).

scripts/lib/tracked-jobs.mjs treats execution.exitStatus === 0 as
"completed" with no check on whether the delegated turn actually did
anything. A Codex app-server turn that reports "completed" status
commits to nothing about work performed - a turn where the model
described a plan and stopped, with no file touched and no command run,
exits/completes exactly like a turn that made the change.

This is the same defect class already fixed in the sibling Grok bridge
(xai-org/grok-build-plugin-cc#16, "fail loud when a delegated run
performs no work"). The codex app-server protocol already gives a
better signal than the Grok CLI's JSON envelope hack:
scripts/lib/codex.mjs's captureTurn collects fileChanges and
commandExecutions thread items for every turn, and runAppServerTurn
already returns touchedFiles / commandExecutions on the result. No
telemetry-extraction step needs porting - only the gating decision.

Adds scripts/lib/work-evidence.mjs with assessWorkEvidence():

- For a --write task run: noWork if the turn touched zero files AND
  ran zero commands.
- For any run: noWork if there is zero output text AND zero tool
  activity.
- Read-only (write: false) runs are not gated on touched-file count -
  answering a question legitimately touches nothing.
- requireWork: false (new --allow-no-work CLI flag) disables the gate
  for callers that intentionally want a plan-only run.

Wires the verdict through codex-companion.mjs::executeTaskRun,
buildTaskRequest (so background task-worker runs enforce the same gate
as foreground), and runForegroundCommand (foreground exit code 3 on an
empty run, mirroring the grok bridge's exit-3 convention). Wires
tracked-jobs.mjs::runTrackedJob to demote completionStatus to failed
whenever execution.workVerdict.noWork is true, independent of
exitStatus, and records workEvidence / emptyRun / errorMessage on the
stored job record so /codex:status surfaces the reason.
@petersimmons1972
petersimmons1972 requested a review from a team August 8, 2026 10:58

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bca81c6c0b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +702 to +705
} else if (execution.workVerdict?.noWork) {
// fleet#264: an empty run must be visible to the shell that invoked codex-companion, not
// just in the job record.
process.exitCode = 3;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Forward the empty-run diagnostic from rescue

For a foreground empty run, setting exit code 3 makes the companion's Bash invocation fail even though the diagnostic banner was written to stdout. The primary delegated path in plugins/codex/agents/codex-rescue.md:41-42 instructs the rescue subagent to return nothing whenever that Bash call fails, so users receive no explanation in exactly the scenario this change is intended to expose. The wrapper must explicitly forward stdout for this expected exit code, or the companion must signal the verdict without triggering the wrapper's failure-suppression rule.

Useful? React with 👍 / 👎.

Comment on lines +60 to +63
if (evidence.write && evidence.touchedFileCount === 0 && evidence.commandCount === 0) {
reasons.push(
"This task was run with --write but the turn touched no files and executed no commands: it described the change instead of making it (fleet#292 shape)."
);

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 Count non-shell tool executions as work

When a write-capable turn completes work through an mcpToolCall, dynamicToolCall, web search, or collaboration tool without a shell command or app-server fileChange, this condition marks the successful run as empty. These are supported tool types in codex.mjs:241-299, but recordItem only adds commandExecution and fileChange items to the evidence passed here; an MCP tool can even perform an external side effect while both counts remain zero. Track completed activity for all applicable tool types rather than treating these turns as having made no tool calls.

Useful? React with 👍 / 👎.

Comment on lines 526 to 529
const payload = {
status: result.status,
threadId: result.threadId,
rawOutput,

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 Preserve the empty-run banner in stored results

For a background empty run whose final message claims success, the new banner exists only in execution.rendered, while this payload also stores that claim as result.rawOutput. /codex:result permits failed jobs but renderStoredJobResult at render.mjs:401-410 returns storedJob.result.rawOutput before consulting storedJob.rendered, so fetching the result drops the failure banner and shows only the misleading Codex claim. Store or render the verdict in the result path before returning raw output.

Useful? React with 👍 / 👎.

Comment on lines +505 to +510
const workVerdict = assessWorkEvidence({
write: Boolean(request.write),
touchedFiles: result.touchedFiles,
commandExecutions: result.commandExecutions,
text: rawOutput,
requireWork: request.requireWork !== false

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep the existing write-task integration tests passing

The repository's normal fake app-server task returns a final message without emitting command or file-change items, so enabling this gate by default changes every existing task --write integration run to exit 3. Running node --test --test-name-pattern='write task output focuses' tests/runtime.test.mjs on this commit fails at the expected-zero exit assertion (3 !== 0), which means the checked-in test suite no longer passes. Update the fixture to emit positive work evidence for successful write scenarios, or explicitly disable the gate in tests that are not exercising it.

Useful? React with 👍 / 👎.

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.

1 participant