Hook timeouts: a flat 30s is wrong in both directions, and the cap is doing telemetry's job
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 5.1k
- Forks
- 475
- Avg merge
- 1d 11h
- Merged PRs (30d)
- 178
Description
Two questions came up from a real timeout report: how did we land on a flat 30s for Codex hooks, and should it differ per hook? The history turns out to matter, and it reframes the report.
Where 30s came from
Introduced in 433103a7c (2026-08-10, feat(codex): finalize sessions via Codex's SessionEnd hook). The constant documents its own scope:
defaultHookTimeoutSecis the timeout Entire configures for Codex hooks that run between turns, where Codex allows up to its standard 600s.
So 30s is our own conservative default with 20× headroom available, not a platform limit. That matters for the report that prompted this: a 65MB Codex rollout took 30.203s in the Stop hook, got its process tree killed, and left the checkpoint's final full-transcript rewrite incomplete. That user wasn't hitting a Codex constraint — he was hitting our round number.
There is no design record beyond that comment, and it reads as a sensible default chosen while wiring SessionEnd rather than a measured decision. Nothing was known to run long at the time.
We already differentiate once, and it's the well-reasoned one. SessionEndTimeoutSec = 3, because Codex clamps SessionEnd to SESSION_END_MAX_TIMEOUT_SEC = 3 and warns on every startup if a config asks for more, so we request exactly the ceiling. It even carries a 1s internal gap to cover sh startup, the command -v PATH walk, and loading a ~66MB binary before our own clock starts. That's the standard to aim for.
The managedHook table already has a per-entry timeout field, so differentiation is structurally supported and simply unused — everything but SessionEnd takes the default.
The tension
A hard kill is itself a failure: if a few more seconds would have completed the write, the timeout caused the damage it was meant to prevent. But raising it means we hear about degradation less often — the timeout is currently doing double duty as an accidental telemetry channel.
The second half does not survive the data. Across 287 real hook spans:
| threshold | spans over | share |
|---|---|---|
| 1.5s (the WARN threshold from #1984) | 274 | 95.5% |
| 5s | 39 | 13.6% |
| 10s | 3 | 1.0% |
| 15s | 1 | 0.3% |
| 30s (the cap) | 0 | 0% |
The cap fired zero times. The WARN traces fired 274 times, each with a full step breakdown. We are not trading signal for silence — we already have ~274× more signal than the timeout provides.
And the report that started this did not come from the timeout. It came from a user noticing his agent break and taking the trouble to say so. That is the worst channel available: it needs user pain plus user effort, and it is silent for everyone who shrugs. Nobody reports the 12–16s pre-push runs, and those are where a rising trend shows up first.
The real gap is that the signal never leaves the machine. The traces already sit in every user's .entire/logs/entire.log at WARN and nothing brings them to us. Worth fixing independently of the timeouts.
Measured cost per hook
| hook | n | p50 | p90 | max | headroom vs 30s |
|---|---|---|---|---|---|
pre-push |
94 | 4622ms | 7644ms | 16394ms | 1.8× |
stop |
89 | 2331ms | 3074ms | 9838ms | 3.0× |
post-commit |
93 | 2313ms | 3908ms | 5906ms | 5.1× |
user-prompt-submit |
3 | 24ms | 2123ms | 2123ms | 14× |
prepare-commit-msg |
3 | 11ms | 13ms | 13ms | 2300× |
commit-msg, session-start, session-end |
3, 1, 1 | ~0–12ms | — |
One flat number is wrong in both directions. pre-push has been observed at 16.4s — 1.8× from the cliff, and it is network-bound, so someone else's slow link closes that gap with no code change on our side. Meanwhile prepare-commit-msg gets 30s for 10ms of work.
Proposal
1. Per-hook caps, using the table field that already exists.
pre-push→ 120s. It waits on a remote; its worst case is set by someone else's network, and a kill mid-push is the worst failure mode we have.stop,post-commit→ 60s. Both scale with transcript size and accumulated session count. #2107 helped; the ceiling still should not be 3× a measured max.prepare-commit-msg,commit-msg,session-start→ 10s. Still 500×+ observed cost, and a tight bound here is a genuine safety property: these run inside the user's git operations, where a hang is far more visible than a slow Stop.
2. A soft budget under each hard cap, generalizing what SessionEnd already does. sessionEndBudget is deliberately held below SessionEndTimeoutSec so "Entire stops itself cleanly instead of being terminated part-way through a condense." On breach: warn, complete or degrade gracefully, and never get killed mid-write.
3. Persist and surface the breach. CaptureDegradedAt is the precedent — set in lifecycle.go:1905, cleared by the next healthy turn, and printed by status.go:608 as "capture degraded …: status scan over budget … (see 'entire doctor logs')". Same shape for a hook that crossed its soft budget.
Together that is strictly more signal than today: it fires on the approach rather than only at the cliff, and for every user rather than only the ones who complain.
Open questions
- Are these the right numbers? They come from one developer's corpus across 95 log files. A 65MB Codex session is already outside it, so the tail is under-sampled.
- Where should the soft threshold sit relative to the hard cap — a fixed gap like SessionEnd's 1s, or a fraction?
- Do we want aggregate visibility (telemetry, or an
entire statusnudge to send a bundle), or is local-only the deliberate privacy stance? This is the decision that actually determines whether raising caps costs us anything. - Per-hook values become hook-config drift, so
dropStaleEntireHookswill rewrite existing users'hooks.jsonon their nextenable. Correct behaviour, but it lands in everyone's config. - Do other agents need the same treatment? This is Codex-specific today; Claude Code, Cursor, Pi and OpenCode have their own hook-timeout stories that nobody has surveyed.
Not the point
Raising a ceiling is not a latency fix. User-visible slowness should come from making the work fast — #2091 tracks the remaining items (process_sessions, the doubled compact-transcript generation, the un-instrumented sentinel). Timeouts are the backstop, and the argument here is only about the backstop failing badly rather than gracefully.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reading the managedHook table and the existing SessionEnd timeout and budget handling. Then inspect lifecycle.go:1905 and status.go:608 for the CaptureDegradedAt precedent, along with dropStaleEntireHooks and the hook configuration flow. Done requires an agreed per-hook timeout and soft-budget design, persisted breach visibility, and resolved telemetry and cross-agent scope questions.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- cli, developer-experience
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Needs clarification
- Newbie friendliness
- 28/100