Skip to content

Deferring the trailing turn (#31/#50) never exports the last turn of a session #65

Description

@caiohasouza

Summary

Deferring the trailing turn (proposed in #31 and #50) removes the duplicate traces from #46, but it introduces the mirror bug: the last turn of every session is never exported, and a single-turn session produces no trace at all.

This is not an argument against deferral — it is the missing half of it. Filing it separately because both open PRs stop at Stop, and neither would be correct to merge on its own.

Why deferral loses the last turn

convertRollout skips a turn whose task_complete has not been appended yet:

// A Stop hook can observe the trailing source turn before Codex has
// appended its completion event. ... a later hook invocation will
// export the finalized source turn exactly once through the ledger.
if (!turn.completed) continue;

The Stop hook fires after every turn, so for every turn but the last that later invocation does happen. After the last turn, nothing re-runs. The turn stays deferred forever.

Evidence

25 real Codex sessions on a single day, running a build with #31's deferral applied. The sidecar ledger records exactly turns - 1 entries:

turns sidecar written turn ids recorded
27 (+2 aborted) yes 28
13 yes 13
3 yes 2
2 yes 1
1 no 0

Thirteen single-turn sessions that day produced no sidecar and zero observations in Langfuse. The failure is silent: the hook exits 0 and fails open by design, so nothing surfaces until someone looks for a session that is not there.

This may also explain part of #12 ("some completed Codex sessions produce no sidecar and no remote session").

Suggested fix

Codex dispatches a SessionEnd hook, and it flushes the transcript before doing so — the binary carries the string:

failed to flush transcript before SessionEnd hook

So at SessionEnd the trailing turn is complete on disk. Declaring the same bundle for SessionEnd in hooks.json closes the gap, and the existing sidecar ledger keeps it exactly-once — turns already exported by a previous Stop are skipped.

"SessionEnd": [
  {
    "hooks": [
      {
        "type": "command",
        "command": "node \"${PLUGIN_ROOT}/dist/index.mjs\"",
        "timeout": 30,
        "statusMessage": "Uploading final Codex turn to Langfuse"
      }
    ]
  }
]

No source change is needed: the entrypoint ignores hook_event_name and just converts the rollout.

Important: on main as it stands today (no deferral), adding SessionEnd alone would not help — the trailing turn is already exported provisionally, so the extra run would just make the #46 duplicate deterministic instead of racy. SessionEnd only makes sense together with the deferral from #31/#50. Deferral gives "never early", SessionEnd gives "still exported" — exactly-once needs both.

Environment

I have this patch running on a fork with tests (hook declaration coverage; 40 tests, bundle reproduces). Happy to open a PR against whichever deferral PR you decide to take, or standalone once one of them lands — just say which base you prefer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions