Make extension lifecycle hooks async (#23291)

## Why

Extension lifecycle hooks sit on the host/extension boundary, but the
current trait surface only allows synchronous callbacks. That forces
extensions that need to seed, rehydrate, observe, or flush
extension-owned state during thread and turn transitions to either block
inside the callback or move async work into separate host plumbing.

This PR makes those lifecycle callbacks awaitable so extension
implementations can perform async work directly at the lifecycle point
where the host already has the relevant session, thread, or turn stores
available.

## What changed

- Makes `ThreadLifecycleContributor` and `TurnLifecycleContributor`
async in `codex-extension-api`.
- Awaits thread start/resume/stop and turn start/stop/abort lifecycle
callbacks from `codex-core`.
- Updates the guardian and memories extensions to implement the async
lifecycle trait surface.
- Updates the existing lifecycle tests to use async contributor
implementations.
- Adds `async-trait` to the crates that now expose or implement these
async object-safe lifecycle traits.

## Testing

- Existing `codex-core` lifecycle tests were updated to cover async
implementations for thread stop and turn abort ordering.
This commit is contained in:
jif-oai authored and GitHub committed 2026-05-18 13:53:58 +02:00
1 parent a80f07ec4a
commit 9531e932ef
15 files changed
+86 -59

No files matched your search

+2
View File
@@ -2829,6 +2829,7 @@ dependencies = [
name = "codex-extension-api" name = "codex-extension-api"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"async-trait",
"codex-protocol", "codex-protocol",
"codex-tools", "codex-tools",
] ]
@@ -2962,6 +2963,7 @@ dependencies = [
name = "codex-guardian" name = "codex-guardian"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"async-trait",
"codex-core", "codex-core",
"codex-extension-api", "codex-extension-api",
"codex-protocol", "codex-protocol",
+7 -5
View File
@@ -144,7 +144,7 @@ impl CodexThread {
self.codex.session_loop_termination.clone().await; self.codex.session_loop_termination.clone().await;
} }
pub(crate) fn emit_thread_resume_lifecycle(&self) { pub(crate) async fn emit_thread_resume_lifecycle(&self) {
for contributor in self for contributor in self
.codex .codex
.session .session
@@ -152,10 +152,12 @@ impl CodexThread {
.extensions .extensions
.thread_lifecycle_contributors() .thread_lifecycle_contributors()
{ {
contributor.on_thread_resume(codex_extension_api::ThreadResumeInput { contributor
session_store: &self.codex.session.services.session_extension_data, .on_thread_resume(codex_extension_api::ThreadResumeInput {
thread_store: &self.codex.session.services.thread_extension_data, session_store: &self.codex.session.services.session_extension_data,
}); thread_store: &self.codex.session.services.thread_extension_data,
})
.await;
} }
} }
+9 -7
View File
@@ -638,12 +638,14 @@ async fn shutdown_session_runtime(sess: &Arc<Session>) {
sess.guardian_review_session.shutdown().await; sess.guardian_review_session.shutdown().await;
} }
fn emit_thread_stop_lifecycle(sess: &Session) { async fn emit_thread_stop_lifecycle(sess: &Session) {
for contributor in sess.services.extensions.thread_lifecycle_contributors() { for contributor in sess.services.extensions.thread_lifecycle_contributors() {
contributor.on_thread_stop(codex_extension_api::ThreadStopInput { contributor
session_store: &sess.services.session_extension_data, .on_thread_stop(codex_extension_api::ThreadStopInput {
thread_store: &sess.services.thread_extension_data, session_store: &sess.services.session_extension_data,
}); thread_store: &sess.services.thread_extension_data,
})
.await;
} }
} }
@@ -662,7 +664,7 @@ pub async fn shutdown(sess: &Arc<Session>, sub_id: String) -> bool {
&[], &[],
); );
emit_thread_stop_lifecycle(sess.as_ref()); emit_thread_stop_lifecycle(sess.as_ref()).await;
// Gracefully flush and shutdown thread persistence on session end so tests // Gracefully flush and shutdown thread persistence on session end so tests
// that inspect durable state do not race with the background writer. // that inspect durable state do not race with the background writer.
@@ -917,7 +919,7 @@ pub(super) async fn submission_loop(
// explicit shutdown op, still run session teardown. // explicit shutdown op, still run session teardown.
if !shutdown_received { if !shutdown_received {
shutdown_session_runtime(&sess).await; shutdown_session_runtime(&sess).await;
emit_thread_stop_lifecycle(sess.as_ref()); emit_thread_stop_lifecycle(sess.as_ref()).await;
} }
debug!("Agent loop exited"); debug!("Agent loop exited");
} }
+1 -1
View File
@@ -877,7 +877,7 @@ impl Session {
config: config.as_ref(), config: config.as_ref(),
session_store: &session_extension_data, session_store: &session_extension_data,
thread_store: &thread_extension_data, thread_store: &thread_extension_data,
}); }).await;
} }
let services = SessionServices { let services = SessionServices {
+6 -3
View File
@@ -5683,8 +5683,9 @@ async fn submission_loop_channel_close_emits_thread_stop_lifecycle() {
expected_thread_id: ThreadId, expected_thread_id: ThreadId,
} }
#[async_trait::async_trait]
impl codex_extension_api::ThreadLifecycleContributor<crate::config::Config> for ThreadStopRecorder { impl codex_extension_api::ThreadLifecycleContributor<crate::config::Config> for ThreadStopRecorder {
fn on_thread_stop(&self, input: codex_extension_api::ThreadStopInput<'_>) { async fn on_thread_stop(&self, input: codex_extension_api::ThreadStopInput<'_>) {
assert_eq!( assert_eq!(
self.expected_thread_id.to_string(), self.expected_thread_id.to_string(),
input.thread_store.level_id() input.thread_store.level_id()
@@ -5728,8 +5729,9 @@ async fn submission_loop_channel_close_aborts_active_turn_before_thread_stop_lif
expected_turn_id: String, expected_turn_id: String,
} }
#[async_trait::async_trait]
impl codex_extension_api::ThreadLifecycleContributor<crate::config::Config> for LifecycleRecorder { impl codex_extension_api::ThreadLifecycleContributor<crate::config::Config> for LifecycleRecorder {
fn on_thread_stop(&self, input: codex_extension_api::ThreadStopInput<'_>) { async fn on_thread_stop(&self, input: codex_extension_api::ThreadStopInput<'_>) {
assert_eq!( assert_eq!(
self.expected_thread_id.to_string(), self.expected_thread_id.to_string(),
input.thread_store.level_id() input.thread_store.level_id()
@@ -5741,8 +5743,9 @@ async fn submission_loop_channel_close_aborts_active_turn_before_thread_stop_lif
} }
} }
#[async_trait::async_trait]
impl codex_extension_api::TurnLifecycleContributor for LifecycleRecorder { impl codex_extension_api::TurnLifecycleContributor for LifecycleRecorder {
fn on_turn_abort(&self, input: codex_extension_api::TurnAbortInput<'_>) { async fn on_turn_abort(&self, input: codex_extension_api::TurnAbortInput<'_>) {
assert_eq!( assert_eq!(
self.expected_thread_id.to_string(), self.expected_thread_id.to_string(),
input.thread_store.level_id() input.thread_store.level_id()
+25 -19
View File
@@ -4,38 +4,44 @@ use codex_protocol::protocol::TurnAbortReason;
use crate::session::session::Session; use crate::session::session::Session;
impl Session { impl Session {
pub(super) fn emit_turn_start_lifecycle(&self, turn_store: &ExtensionData) { pub(super) async fn emit_turn_start_lifecycle(&self, turn_store: &ExtensionData) {
for contributor in self.services.extensions.turn_lifecycle_contributors() { for contributor in self.services.extensions.turn_lifecycle_contributors() {
contributor.on_turn_start(codex_extension_api::TurnStartInput { contributor
session_store: &self.services.session_extension_data, .on_turn_start(codex_extension_api::TurnStartInput {
thread_store: &self.services.thread_extension_data, session_store: &self.services.session_extension_data,
turn_store, thread_store: &self.services.thread_extension_data,
}); turn_store,
})
.await;
} }
} }
pub(super) fn emit_turn_stop_lifecycle(&self, turn_store: &ExtensionData) { pub(super) async fn emit_turn_stop_lifecycle(&self, turn_store: &ExtensionData) {
for contributor in self.services.extensions.turn_lifecycle_contributors() { for contributor in self.services.extensions.turn_lifecycle_contributors() {
contributor.on_turn_stop(codex_extension_api::TurnStopInput { contributor
session_store: &self.services.session_extension_data, .on_turn_stop(codex_extension_api::TurnStopInput {
thread_store: &self.services.thread_extension_data, session_store: &self.services.session_extension_data,
turn_store, thread_store: &self.services.thread_extension_data,
}); turn_store,
})
.await;
} }
} }
pub(super) fn emit_turn_abort_lifecycle( pub(super) async fn emit_turn_abort_lifecycle(
&self, &self,
reason: TurnAbortReason, reason: TurnAbortReason,
turn_store: &ExtensionData, turn_store: &ExtensionData,
) { ) {
for contributor in self.services.extensions.turn_lifecycle_contributors() { for contributor in self.services.extensions.turn_lifecycle_contributors() {
contributor.on_turn_abort(codex_extension_api::TurnAbortInput { contributor
reason: reason.clone(), .on_turn_abort(codex_extension_api::TurnAbortInput {
session_store: &self.services.session_extension_data, reason: reason.clone(),
thread_store: &self.services.thread_extension_data, session_store: &self.services.session_extension_data,
turn_store, thread_store: &self.services.thread_extension_data,
}); turn_store,
})
.await;
} }
} }
} }
+8 -4
View File
@@ -365,7 +365,8 @@ impl Session {
turn_state.push_pending_input(item); turn_state.push_pending_input(item);
} }
} }
self.emit_turn_start_lifecycle(turn_context.extension_data.as_ref()); self.emit_turn_start_lifecycle(turn_context.extension_data.as_ref())
.await;
let turn_extension_data = Arc::clone(&turn_context.extension_data); let turn_extension_data = Arc::clone(&turn_context.extension_data);
let mut active = self.active_turn.lock().await; let mut active = self.active_turn.lock().await;
@@ -505,7 +506,8 @@ impl Session {
} }
if let Some(turn_context) = turn_context.as_deref() { if let Some(turn_context) = turn_context.as_deref() {
self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref()); self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref())
.await;
} }
if (aborted_turn || reason == TurnAbortReason::Interrupted) if (aborted_turn || reason == TurnAbortReason::Interrupted)
&& let Err(err) = self && let Err(err) = self
@@ -553,7 +555,8 @@ impl Session {
self.handle_task_abort(task, reason.clone()).await; self.handle_task_abort(task, reason.clone()).await;
} }
if let Some(turn_context) = turn_context.as_deref() { if let Some(turn_context) = turn_context.as_deref() {
self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref()); self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref())
.await;
} }
if let Err(err) = self if let Err(err) = self
.goal_runtime_apply(GoalRuntimeEvent::TaskAborted { .goal_runtime_apply(GoalRuntimeEvent::TaskAborted {
@@ -756,7 +759,8 @@ impl Session {
.time_to_first_token_ms() .time_to_first_token_ms()
.await; .await;
if should_clear_active_turn { if should_clear_active_turn {
self.emit_turn_stop_lifecycle(turn_context.extension_data.as_ref()); self.emit_turn_stop_lifecycle(turn_context.extension_data.as_ref())
.await;
} }
if let Err(err) = self if let Err(err) = self
.goal_runtime_apply(GoalRuntimeEvent::TurnFinished { .goal_runtime_apply(GoalRuntimeEvent::TurnFinished {
+1 -1
View File
@@ -1228,7 +1228,7 @@ impl ThreadManagerState {
.finalize_thread_spawn(codex, thread_id, tracked_session_source) .finalize_thread_spawn(codex, thread_id, tracked_session_source)
.await?; .await?;
if is_resumed_thread { if is_resumed_thread {
new_thread.thread.emit_thread_resume_lifecycle(); new_thread.thread.emit_thread_resume_lifecycle().await;
if let Err(err) = new_thread.thread.apply_goal_resume_runtime_effects().await { if let Err(err) = new_thread.thread.apply_goal_resume_runtime_effects().await {
warn!("failed to apply goal resume runtime effects: {err}"); warn!("failed to apply goal resume runtime effects: {err}");
} }
+1
View File
@@ -14,5 +14,6 @@ doctest = false
workspace = true workspace = true
[dependencies] [dependencies]
async-trait = { workspace = true }
codex-protocol = { workspace = true } codex-protocol = { workspace = true }
codex-tools = { workspace = true } codex-tools = { workspace = true }
@@ -36,16 +36,17 @@ pub trait ContextContributor: Send + Sync {
/// Implementations should use these callbacks to seed, rehydrate, or flush /// Implementations should use these callbacks to seed, rehydrate, or flush
/// extension-private thread state. Heavy dependencies belong on the extension /// extension-private thread state. Heavy dependencies belong on the extension
/// value created by the host, not in these inputs. /// value created by the host, not in these inputs.
pub trait ThreadLifecycleContributor<C>: Send + Sync { #[async_trait::async_trait]
pub trait ThreadLifecycleContributor<C: Sync>: Send + Sync {
/// Called after thread-scoped extension stores are created, before later /// Called after thread-scoped extension stores are created, before later
/// contributors can read from them. /// contributors can read from them.
fn on_thread_start(&self, _input: ThreadStartInput<'_, C>) {} async fn on_thread_start(&self, _input: ThreadStartInput<'_, C>) {}
/// Called after the host constructs a runtime from persisted history. /// Called after the host constructs a runtime from persisted history.
fn on_thread_resume(&self, _input: ThreadResumeInput<'_>) {} async fn on_thread_resume(&self, _input: ThreadResumeInput<'_>) {}
/// Called before the host drops the thread runtime and thread-scoped store. /// Called before the host drops the thread runtime and thread-scoped store.
fn on_thread_stop(&self, _input: ThreadStopInput<'_>) {} async fn on_thread_stop(&self, _input: ThreadStopInput<'_>) {}
} }
/// Contributor for host-owned turn lifecycle gates. /// Contributor for host-owned turn lifecycle gates.
@@ -53,16 +54,17 @@ pub trait ThreadLifecycleContributor<C>: Send + Sync {
/// Implementations should use these callbacks to seed, observe, or clear /// Implementations should use these callbacks to seed, observe, or clear
/// extension-private turn state. The host exposes stable identifiers and /// extension-private turn state. The host exposes stable identifiers and
/// extension stores instead of core runtime objects. /// extension stores instead of core runtime objects.
#[async_trait::async_trait]
pub trait TurnLifecycleContributor: Send + Sync { pub trait TurnLifecycleContributor: Send + Sync {
/// Called after turn-scoped extension stores are created, before the task /// Called after turn-scoped extension stores are created, before the task
/// for the turn starts running. /// for the turn starts running.
fn on_turn_start(&self, _input: TurnStartInput<'_>) {} async fn on_turn_start(&self, _input: TurnStartInput<'_>) {}
/// Called before the host drops the completed turn runtime and turn store. /// Called before the host drops the completed turn runtime and turn store.
fn on_turn_stop(&self, _input: TurnStopInput<'_>) {} async fn on_turn_stop(&self, _input: TurnStopInput<'_>) {}
/// Called after the host aborts a running turn. /// Called after the host aborts a running turn.
fn on_turn_abort(&self, _input: TurnAbortInput<'_>) {} async fn on_turn_abort(&self, _input: TurnAbortInput<'_>) {}
} }
/// Contributor for host-owned configuration changes. /// Contributor for host-owned configuration changes.
+6 -6
View File
@@ -12,7 +12,7 @@ use crate::TurnItemContributor;
use crate::TurnLifecycleContributor; use crate::TurnLifecycleContributor;
/// Mutable registry used while hosts register typed runtime contributions. /// Mutable registry used while hosts register typed runtime contributions.
pub struct ExtensionRegistryBuilder<C> { pub struct ExtensionRegistryBuilder<C: Sync> {
thread_lifecycle_contributors: Vec<Arc<dyn ThreadLifecycleContributor<C>>>, thread_lifecycle_contributors: Vec<Arc<dyn ThreadLifecycleContributor<C>>>,
turn_lifecycle_contributors: Vec<Arc<dyn TurnLifecycleContributor>>, turn_lifecycle_contributors: Vec<Arc<dyn TurnLifecycleContributor>>,
config_contributors: Vec<Arc<dyn ConfigContributor<C>>>, config_contributors: Vec<Arc<dyn ConfigContributor<C>>>,
@@ -23,7 +23,7 @@ pub struct ExtensionRegistryBuilder<C> {
approval_review_contributors: Vec<Arc<dyn ApprovalReviewContributor>>, approval_review_contributors: Vec<Arc<dyn ApprovalReviewContributor>>,
} }
impl<C> Default for ExtensionRegistryBuilder<C> { impl<C: Sync> Default for ExtensionRegistryBuilder<C> {
fn default() -> Self { fn default() -> Self {
Self { Self {
thread_lifecycle_contributors: Vec::new(), thread_lifecycle_contributors: Vec::new(),
@@ -38,7 +38,7 @@ impl<C> Default for ExtensionRegistryBuilder<C> {
} }
} }
impl<C> ExtensionRegistryBuilder<C> { impl<C: Sync> ExtensionRegistryBuilder<C> {
/// Creates an empty registry builder. /// Creates an empty registry builder.
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()
@@ -103,7 +103,7 @@ impl<C> ExtensionRegistryBuilder<C> {
} }
/// Immutable typed registry produced after extensions are installed. /// Immutable typed registry produced after extensions are installed.
pub struct ExtensionRegistry<C> { pub struct ExtensionRegistry<C: Sync> {
thread_lifecycle_contributors: Vec<Arc<dyn ThreadLifecycleContributor<C>>>, thread_lifecycle_contributors: Vec<Arc<dyn ThreadLifecycleContributor<C>>>,
turn_lifecycle_contributors: Vec<Arc<dyn TurnLifecycleContributor>>, turn_lifecycle_contributors: Vec<Arc<dyn TurnLifecycleContributor>>,
config_contributors: Vec<Arc<dyn ConfigContributor<C>>>, config_contributors: Vec<Arc<dyn ConfigContributor<C>>>,
@@ -114,7 +114,7 @@ pub struct ExtensionRegistry<C> {
approval_review_contributors: Vec<Arc<dyn ApprovalReviewContributor>>, approval_review_contributors: Vec<Arc<dyn ApprovalReviewContributor>>,
} }
impl<C> ExtensionRegistry<C> { impl<C: Sync> ExtensionRegistry<C> {
/// Returns the registered thread-lifecycle contributors. /// Returns the registered thread-lifecycle contributors.
pub fn thread_lifecycle_contributors(&self) -> &[Arc<dyn ThreadLifecycleContributor<C>>] { pub fn thread_lifecycle_contributors(&self) -> &[Arc<dyn ThreadLifecycleContributor<C>>] {
&self.thread_lifecycle_contributors &self.thread_lifecycle_contributors
@@ -165,6 +165,6 @@ impl<C> ExtensionRegistry<C> {
} }
/// Creates an empty shared registry for hosts that do not register contributions. /// Creates an empty shared registry for hosts that do not register contributions.
pub fn empty_extension_registry<C>() -> Arc<ExtensionRegistry<C>> { pub fn empty_extension_registry<C: Sync>() -> Arc<ExtensionRegistry<C>> {
Arc::new(ExtensionRegistryBuilder::new().build()) Arc::new(ExtensionRegistryBuilder::new().build())
} }
+6 -4
View File
@@ -102,11 +102,12 @@ fn missing_backend_message() -> String {
"goal tools are not connected to host goal persistence yet".to_string() "goal tools are not connected to host goal persistence yet".to_string()
} }
#[async_trait]
impl<C> ThreadLifecycleContributor<C> for GoalExtension<C> impl<C> ThreadLifecycleContributor<C> for GoalExtension<C>
where where
C: Send + Sync + 'static, C: Send + Sync + 'static,
{ {
fn on_thread_start(&self, input: ThreadStartInput<'_, C>) { async fn on_thread_start(&self, input: ThreadStartInput<'_, C>) {
input input
.thread_store .thread_store
.insert(GoalExtensionConfig::from_enabled((self.goals_enabled)( .insert(GoalExtensionConfig::from_enabled((self.goals_enabled)(
@@ -135,11 +136,12 @@ where
} }
} }
#[async_trait]
impl<C> TurnLifecycleContributor for GoalExtension<C> impl<C> TurnLifecycleContributor for GoalExtension<C>
where where
C: Send + Sync + 'static, C: Send + Sync + 'static,
{ {
fn on_turn_start(&self, input: TurnStartInput<'_>) { async fn on_turn_start(&self, input: TurnStartInput<'_>) {
if !goal_enabled(input.thread_store) { if !goal_enabled(input.thread_store) {
return; return;
} }
@@ -150,7 +152,7 @@ where
accounting_state(input.thread_store).start_turn(input.turn_store.level_id()); accounting_state(input.thread_store).start_turn(input.turn_store.level_id());
} }
fn on_turn_stop(&self, input: TurnStopInput<'_>) { async fn on_turn_stop(&self, input: TurnStopInput<'_>) {
if !goal_enabled(input.thread_store) { if !goal_enabled(input.thread_store) {
return; return;
} }
@@ -164,7 +166,7 @@ where
accounting_state(input.thread_store).stop_turn(input.turn_store.level_id()); accounting_state(input.thread_store).stop_turn(input.turn_store.level_id());
} }
fn on_turn_abort(&self, input: TurnAbortInput<'_>) { async fn on_turn_abort(&self, input: TurnAbortInput<'_>) {
if !goal_enabled(input.thread_store) { if !goal_enabled(input.thread_store) {
return; return;
} }
+1
View File
@@ -14,6 +14,7 @@ doctest = false
workspace = true workspace = true
[dependencies] [dependencies]
async-trait = { workspace = true }
codex-core = { workspace = true } codex-core = { workspace = true }
codex-extension-api = { workspace = true } codex-extension-api = { workspace = true }
codex-protocol = { workspace = true } codex-protocol = { workspace = true }
+2 -1
View File
@@ -47,11 +47,12 @@ impl GuardianThreadContext {
} }
} }
#[async_trait::async_trait]
impl<S> ThreadLifecycleContributor<Config> for GuardianExtension<S> impl<S> ThreadLifecycleContributor<Config> for GuardianExtension<S>
where where
S: Send + Sync, S: Send + Sync,
{ {
fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) { async fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) {
let Ok(forked_from_thread_id) = ThreadId::from_string(input.thread_store.level_id()) else { let Ok(forked_from_thread_id) = ThreadId::from_string(input.thread_store.level_id()) else {
return; return;
}; };
+2 -1
View File
@@ -58,8 +58,9 @@ impl ContextContributor for MemoriesExtension {
} }
} }
#[async_trait::async_trait]
impl ThreadLifecycleContributor<Config> for MemoriesExtension { impl ThreadLifecycleContributor<Config> for MemoriesExtension {
fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) { async fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) {
input input
.thread_store .thread_store
.insert(MemoriesExtensionConfig::from_config(input.config)); .insert(MemoriesExtensionConfig::from_config(input.config));