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.
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
convertRolloutskips a turn whosetask_completehas not been appended yet:The
Stophook 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 - 1entries: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
SessionEndhook, and it flushes the transcript before doing so — the binary carries the string:So at
SessionEndthe trailing turn is complete on disk. Declaring the same bundle forSessionEndinhooks.jsoncloses the gap, and the existing sidecar ledger keeps it exactly-once — turns already exported by a previousStopare skipped.No source change is needed: the entrypoint ignores
hook_event_nameand just converts the rollout.Important: on
mainas it stands today (no deferral), addingSessionEndalone would not help — the trailing turn is already exported provisionally, so the extra run would just make the #46 duplicate deterministic instead of racy.SessionEndonly makes sense together with the deferral from #31/#50. Deferral gives "never early",SessionEndgives "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.