Skip to content

[Bug] Duplicate Langfuse traces for a single Codex turn when Stop hook runs before task_complete #46

Description

@flc1125

Note

This issue was organized and drafted with the assistance of AI. All descriptions, reproduction details, and supporting evidence have been manually reviewed and confirmed.

Summary

When using tracing@codex-observability-plugin v0.1.0 with Codex Desktop, a single Codex turn can be exported as two distinct Langfuse traces.

The two traces have:

  • the same metadata["codex.turn_id"]
  • the same start time
  • identical LLM steps, tool calls, token usage, and cost
  • different trace IDs and observation IDs

This is not a duplicated UI row or two actual model executions. The same turn is ingested twice, which doubles token and cost analytics.

Environment

  • Codex Desktop / App
  • Codex CLI/runtime: 0.147.0
  • Plugin: tracing@codex-observability-plugin 0.1.0
  • Langfuse Cloud US
  • Node.js: 25.8.1
  • OS: macOS
  • Model: gpt-5.6-sol
  • trace_seed: not configured
  • Only one Langfuse tracing plugin is installed and enabled
  • Only one corresponding Stop hook configuration was found

Observed behavior

One real Codex turn:

codex.turn_id:
019ffdfa-241e-7a41-b5ae-db30995303d8

was ingested as two traces:

Trace A:
161cd7861c817fd51f75622b9c1e2b3f

Trace B:
96cbbb06c5f3e5d02fbbd4f9d4a1607d

Both traces contain:

1 Codex Turn observation
11 LLM generations, step_index 0-10
10 tool observations
22 observations in total

The final LLM generation is identical in both traces:

start_time: 2026-08-14T01:57:47.671Z
end_time:   2026-08-14T01:57:47.672Z
model:      gpt-5.6-sol
step_index: 10

input:                   919
input_cached_tokens:  189184
output:                  172
output_reasoning_tokens: 124
total:                 190399

cost: $0.108067

However, the two generations have different observation IDs:

6477ecd8b4355b92
57f9ed6f0694c569

The full turn cost was also recorded twice:

Trace A: $1.468718
Trace B: $1.468718

Langfuse aggregated total: $2.937436
Expected total:            $1.468718

Local rollout evidence

The local rollout contains only one turn execution:

task_started:
2026-08-14T01:54:16.814Z

final assistant message:
2026-08-14T01:57:47.671Z

task_complete:
2026-08-14T01:57:48.791Z

The two Langfuse root observations have these timestamps:

Trace A:
start_time: 2026-08-14T01:54:16.814Z
end_time:   2026-08-14T01:57:47.672Z

Trace B:
start_time: 2026-08-14T01:54:16.814Z
end_time:   2026-08-14T01:57:48.791Z

This shows that:

  • Trace A ends immediately after the final assistant message.
  • task_complete had not yet been written when Trace A was exported.
  • Trace B ends exactly at the task_complete timestamp.
  • The rollout contains only one matching task_started and one task_complete.
  • The sidecar eventually contains this turn_id exactly once.

Likely root cause

The current code only skips a turn when it is both completed and already present in the sidecar:

if (turn.completed && turn.turnId && uploaded.has(turn.turnId)) {
  continue;
}

An in-progress trailing turn is still exported, but is intentionally not recorded in the sidecar:

if (turn.completed && turn.turnId) {
  await markTurnUploaded(rolloutFile, turn.turnId);
} else if (turn.turnId) {
  // uploaded in-progress turn, but not marked
}

This can result in the following sequence:

  1. The first Stop hook invocation reads the rollout.
  2. The final assistant message is present, but task_complete is not.
  3. The plugin exports the turn as in-progress.
  4. The turn is not written to the sidecar because it is not completed.
  5. Codex appends task_complete.
  6. A later hook invocation reads the same completed turn.
  7. The plugin exports the entire turn again with new trace and observation IDs.
  8. Only the second export is recorded in the sidecar.

Expected behavior

For the same:

session_id + codex.turn_id

Langfuse should contain only one finalized trace.

A generation identified by codex.turn_id + codex.step_index should not be ingested more than once, and its token usage and cost should only be counted once.

Suggested fix

One straightforward approach is:

  • do not export an incomplete trailing turn
  • wait until the rollout contains task_complete or turn_aborted
  • export and sidecar-mark the completed turn
  • skip it during later hook invocations

Another option is to derive stable idempotent IDs for turns, generations, and tool observations from fields such as:

session_id
turn_id
step_index / tool call id

That would prevent a repeated hook invocation from creating new traces and observations for the same turn.

Suggested regression test:

  1. Process a rollout containing task_started but no task_complete.
  2. Assert that no remote spans are emitted and no sidecar entry is written.
  3. Append task_complete to the same rollout.
  4. Process it again and assert that exactly one completed turn is exported.
  5. Process it a third time and assert that the sidecar prevents another export.
  6. Assert that only one trace exists and cost is counted once.

Related

PR #17 describes the same lifecycle issue, but it is still open and unmerged. The issue remains reproducible with Codex Desktop 0.147.0 and plugin v0.1.0.

For privacy, this report does not include prompt text, assistant output, tool inputs or outputs, project identifiers, or Langfuse credentials.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions