Add SessionEnd hooks for thread teardown (#33895)

## What changed

- Add the `SessionEnd` hook event to hook configuration, protocol schemas, analytics, and the hooks browser.
- Run matching hooks for root threads during shutdown, including app-server archive, delete, idle unload, and graceful shutdown. Flush the transcript first and provide the session ID, transcript path, working directory, and `reason: "other"`.
- Keep teardown bounded: hook output is advisory, the default timeout is one second, configured timeouts are capped at three seconds, and async hooks run synchronously with a warning.

## Testing

- Cover transcript availability, matcher selection, timeout normalization, ignored control output, subagent exclusion, archive/delete ordering, and graceful shutdown of multiple loaded threads.

GitOrigin-RevId: 5d19a658677a137caf836ed5042dcb43f5eb6d1a
This commit is contained in:
Abhinav authored and copyberry committed 2026-07-17 19:58:56 +00:00
1 parent b9680065b0
commit 7bd44085e1
46 files changed
+930 -14

No files matched your search

+33
View File
@@ -365,6 +365,38 @@ pub(crate) async fn run_turn_stop_hooks(
outcome
}
#[instrument(level = "trace", skip_all)]
pub(crate) async fn run_session_end_hooks(sess: &Arc<Session>) {
let hooks = sess.hooks();
let preview_runs = hooks.preview_session_end();
if preview_runs.is_empty() {
return;
}
let turn_context = sess.new_default_turn().await;
// SessionEnd is root-only; ThreadSpawn uses SubagentStart/SubagentStop and other subagents
// are internal implementation details.
if matches!(&turn_context.session_source, SessionSource::SubAgent(_)) {
return;
}
let request = codex_hooks::SessionEndRequest {
session_id: sess.session_id().into(),
turn_id: turn_context.sub_id.clone(),
#[allow(deprecated)]
cwd: turn_context.cwd.clone(),
transcript_path: sess.hook_transcript_path().await,
};
if let Err(err) = sess.flush_rollout().await {
tracing::warn!("failed to flush transcript before SessionEnd hook: {err}");
}
emit_hook_started_events(sess, &turn_context, preview_runs).await;
let outcome = hooks.run_session_end(request).await;
emit_hook_completed_events(sess, &turn_context, outcome.hook_events).await;
}
pub(crate) async fn run_pre_compact_hooks(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
@@ -703,6 +735,7 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str);
HookEventName::PreCompact => "PreCompact",
HookEventName::PostCompact => "PostCompact",
HookEventName::SessionStart => "SessionStart",
HookEventName::SessionEnd => "SessionEnd",
HookEventName::UserPromptSubmit => "UserPromptSubmit",
HookEventName::SubagentStart => "SubagentStart",
HookEventName::SubagentStop => "SubagentStop",
+2
View File
@@ -609,6 +609,8 @@ async fn shutdown_session_runtime(sess: &Arc<Session>) {
}
sess.services.mcp_runtime.shutdown().await;
sess.guardian_review_session.shutdown().await;
crate::hook_runtime::run_session_end_hooks(sess).await;
}
async fn emit_thread_stop_lifecycle(sess: &Session) {