You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 433103a (2026-08-10, feat(codex): finalize sessions via Codex's SessionEnd hook). The constant documents its own scope:
defaultHookTimeoutSec is 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:
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.
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 status nudge 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 dropStaleEntireHooks will rewrite existing users' hooks.json on their next enable. 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.
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 433103a (2026-08-10, feat(codex): finalize sessions via Codex's SessionEnd hook). The constant documents its own scope:
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 toSESSION_END_MAX_TIMEOUT_SEC = 3and warns on every startup if a config asks for more, so we request exactly the ceiling. It even carries a 1s internal gap to covershstartup, thecommand -vPATH walk, and loading a ~66MB binary before our own clock starts. That's the standard to aim for.The
managedHooktable already has a per-entrytimeoutfield, 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:
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.logat WARN and nothing brings them to us. Worth fixing independently of the timeouts.Measured cost per hook
pre-pushstoppost-commituser-prompt-submitprepare-commit-msgcommit-msg,session-start,session-endOne flat number is wrong in both directions.
pre-pushhas 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. Meanwhileprepare-commit-msggets 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. perf: reuse redaction prefixes at condensation and Stop finalize #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.
sessionEndBudgetis deliberately held belowSessionEndTimeoutSecso "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.
CaptureDegradedAtis the precedent — set inlifecycle.go:1905, cleared by the next healthy turn, and printed bystatus.go:608as "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
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.dropStaleEntireHookswill rewrite existing users'hooks.jsonon their nextenable. Correct behaviour, but it lands in everyone's config.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.