Remove async-trait from extension contributors (#27383)

## Why

Extension contributors are registered behind `dyn Trait` objects, so
native `async fn`/RPITIT methods would make these traits
non-object-safe. Spell out the boxed, `Send` future contract directly so
`extension-api` no longer needs `async-trait` while retaining the
existing runtime model.

## What changed

- add a shared `ExtensionFuture` alias and use it for asynchronous
contributor methods
- migrate production and test implementations to return `Box::pin(async
move { ... })`
- remove `async-trait` dependencies where they are no longer used,
keeping it dev-only where unrelated test executors still require it

## Behavior

No behavior change is intended. Contributor futures remain boxed,
`Send`, dynamically dispatched, and lazily executed; cancellation and
callback ordering stay unchanged.

## Testing

- `just test -p codex-extension-api` (11 passed)
- affected extension crates (64 passed)
- targeted `codex-core` contributor tests (14 passed)
- `just fmt`
- `just bazel-lock-update`
- `just bazel-lock-check`

A broad local `codex-core` run compiled successfully but encountered
unrelated sandbox and missing test-binary fixture failures; CI will run
the full checks.
This commit is contained in:
jif
2026-06-10 13:31:09 +01:00
committed by GitHub
Unverified
parent ced1b8aa88
commit d2f6d23c6c
21 changed files with 736 additions and 597 deletions
+199 -184
View File
@@ -1,12 +1,12 @@
use std::sync::Arc;
use std::sync::Weak;
use async_trait::async_trait;
use codex_analytics::AnalyticsEventsClient;
use codex_core::ThreadManager;
use codex_extension_api::ConfigContributor;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionEventSink;
use codex_extension_api::ExtensionFuture;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::ThreadIdleInput;
use codex_extension_api::ThreadLifecycleContributor;
@@ -95,76 +95,83 @@ impl<C> GoalExtension<C> {
}
}
#[async_trait]
impl<C> ThreadLifecycleContributor<C> for GoalExtension<C>
where
C: Send + Sync + 'static,
{
async fn on_thread_start(&self, input: ThreadStartInput<'_, C>) {
let enabled = (self.goals_enabled)(input.config);
let tools_available_for_thread = input.persistent_thread_state_available
&& !matches!(
input.session_source,
SessionSource::SubAgent(SubAgentSource::Review)
);
input
.thread_store
.insert(GoalExtensionConfig::from_enabled(enabled));
let accounting_state = input
.thread_store
.get_or_init::<GoalAccountingState>(GoalAccountingState::default);
let Ok(thread_id) = ThreadId::from_string(input.thread_store.level_id()) else {
return;
};
let runtime = input.thread_store.get_or_init::<GoalRuntimeHandle>(|| {
GoalRuntimeHandle::new(
thread_id,
Arc::clone(&self.state_dbs),
self.event_emitter.clone(),
self.metrics.clone(),
self.thread_manager.clone(),
accounting_state,
GoalRuntimeConfig {
analytics: self.analytics.clone(),
enabled,
tools_available_for_thread,
},
)
});
runtime.set_enabled(enabled);
self.goal_service.register_runtime(&runtime);
fn on_thread_start<'a>(&'a self, input: ThreadStartInput<'a, C>) -> ExtensionFuture<'a, ()> {
Box::pin(async move {
let enabled = (self.goals_enabled)(input.config);
let tools_available_for_thread = input.persistent_thread_state_available
&& !matches!(
input.session_source,
SessionSource::SubAgent(SubAgentSource::Review)
);
input
.thread_store
.insert(GoalExtensionConfig::from_enabled(enabled));
let accounting_state = input
.thread_store
.get_or_init::<GoalAccountingState>(GoalAccountingState::default);
let Ok(thread_id) = ThreadId::from_string(input.thread_store.level_id()) else {
return;
};
let runtime = input.thread_store.get_or_init::<GoalRuntimeHandle>(|| {
GoalRuntimeHandle::new(
thread_id,
Arc::clone(&self.state_dbs),
self.event_emitter.clone(),
self.metrics.clone(),
self.thread_manager.clone(),
accounting_state,
GoalRuntimeConfig {
analytics: self.analytics.clone(),
enabled,
tools_available_for_thread,
},
)
});
runtime.set_enabled(enabled);
self.goal_service.register_runtime(&runtime);
})
}
async fn on_thread_resume(&self, input: ThreadResumeInput<'_>) {
let Some(runtime) = goal_runtime_handle(input.thread_store) else {
return;
};
fn on_thread_resume<'a>(&'a self, input: ThreadResumeInput<'a>) -> ExtensionFuture<'a, ()> {
Box::pin(async move {
let Some(runtime) = goal_runtime_handle(input.thread_store) else {
return;
};
if let Err(err) = runtime.restore_after_resume().await {
tracing::warn!(
"failed to restore goal runtime after thread resume for {}: {err}",
runtime.thread_id()
);
}
if let Err(err) = runtime.restore_after_resume().await {
tracing::warn!(
"failed to restore goal runtime after thread resume for {}: {err}",
runtime.thread_id()
);
}
})
}
async fn on_thread_idle(&self, input: ThreadIdleInput<'_>) {
let Some(runtime) = goal_runtime_handle(input.thread_store) else {
return;
};
fn on_thread_idle<'a>(&'a self, input: ThreadIdleInput<'a>) -> ExtensionFuture<'a, ()> {
Box::pin(async move {
let Some(runtime) = goal_runtime_handle(input.thread_store) else {
return;
};
if let Err(err) = runtime.continue_if_idle().await {
tracing::warn!(
"failed to continue active goal for idle thread {}: {err}",
runtime.thread_id()
);
}
if let Err(err) = runtime.continue_if_idle().await {
tracing::warn!(
"failed to continue active goal for idle thread {}: {err}",
runtime.thread_id()
);
}
})
}
async fn on_thread_stop(&self, input: ThreadStopInput<'_>) {
if let Some(runtime) = goal_runtime_handle(input.thread_store) {
self.goal_service.unregister_runtime(&runtime);
}
fn on_thread_stop<'a>(&'a self, input: ThreadStopInput<'a>) -> ExtensionFuture<'a, ()> {
Box::pin(async move {
if let Some(runtime) = goal_runtime_handle(input.thread_store) {
self.goal_service.unregister_runtime(&runtime);
}
})
}
}
@@ -187,153 +194,161 @@ where
}
}
#[async_trait]
impl<C> TurnLifecycleContributor for GoalExtension<C>
where
C: Send + Sync + 'static,
{
async fn on_turn_start(&self, input: TurnStartInput<'_>) {
let Some(runtime) = goal_runtime_handle(input.thread_store) else {
return;
};
if !runtime.is_enabled() {
return;
}
fn on_turn_start<'a>(&'a self, input: TurnStartInput<'a>) -> ExtensionFuture<'a, ()> {
Box::pin(async move {
let Some(runtime) = goal_runtime_handle(input.thread_store) else {
return;
};
if !runtime.is_enabled() {
return;
}
let accounting = runtime.accounting_state();
accounting.start_turn(
input.turn_id,
input.collaboration_mode.mode,
input.token_usage_at_turn_start,
);
if matches!(
input.collaboration_mode.mode,
codex_protocol::config_types::ModeKind::Plan
) {
accounting.clear_current_turn_goal();
return;
}
let Ok(goal) = self
.state_dbs
.thread_goals()
.get_thread_goal(runtime.thread_id())
.await
else {
return;
};
if let Some(goal) = goal
&& matches!(
goal.status,
codex_state::ThreadGoalStatus::Active
| codex_state::ThreadGoalStatus::BudgetLimited
)
{
accounting.mark_turn_goal_active(input.turn_id, goal.goal_id);
}
let accounting = runtime.accounting_state();
accounting.start_turn(
input.turn_id,
input.collaboration_mode.mode,
input.token_usage_at_turn_start,
);
if matches!(
input.collaboration_mode.mode,
codex_protocol::config_types::ModeKind::Plan
) {
accounting.clear_current_turn_goal();
return;
}
let Ok(goal) = self
.state_dbs
.thread_goals()
.get_thread_goal(runtime.thread_id())
.await
else {
return;
};
if let Some(goal) = goal
&& matches!(
goal.status,
codex_state::ThreadGoalStatus::Active
| codex_state::ThreadGoalStatus::BudgetLimited
)
{
accounting.mark_turn_goal_active(input.turn_id, goal.goal_id);
}
})
}
async fn on_turn_stop(&self, input: TurnStopInput<'_>) {
let Some(runtime) = goal_runtime_handle(input.thread_store) else {
return;
};
if !runtime.is_enabled() {
return;
}
fn on_turn_stop<'a>(&'a self, input: TurnStopInput<'a>) -> ExtensionFuture<'a, ()> {
Box::pin(async move {
let Some(runtime) = goal_runtime_handle(input.thread_store) else {
return;
};
if !runtime.is_enabled() {
return;
}
let turn_id = input.turn_store.level_id();
if let Err(err) = runtime
.account_active_goal_progress(
turn_id,
&format!("{turn_id}:turn-stop"),
codex_state::GoalAccountingMode::ActiveOnly,
BudgetLimitedGoalDisposition::ClearActive,
)
.await
{
tracing::warn!(
"failed to account active goal progress at turn stop for {turn_id}: {err}"
);
return;
}
runtime.accounting_state().finish_turn(turn_id);
let turn_id = input.turn_store.level_id();
if let Err(err) = runtime
.account_active_goal_progress(
turn_id,
&format!("{turn_id}:turn-stop"),
codex_state::GoalAccountingMode::ActiveOnly,
BudgetLimitedGoalDisposition::ClearActive,
)
.await
{
tracing::warn!(
"failed to account active goal progress at turn stop for {turn_id}: {err}"
);
return;
}
runtime.accounting_state().finish_turn(turn_id);
})
}
async fn on_turn_abort(&self, input: TurnAbortInput<'_>) {
let Some(runtime) = goal_runtime_handle(input.thread_store) else {
return;
};
if !runtime.is_enabled() {
return;
}
fn on_turn_abort<'a>(&'a self, input: TurnAbortInput<'a>) -> ExtensionFuture<'a, ()> {
Box::pin(async move {
let Some(runtime) = goal_runtime_handle(input.thread_store) else {
return;
};
if !runtime.is_enabled() {
return;
}
let turn_id = input.turn_store.level_id();
if let Err(err) = runtime
.account_active_goal_progress(
turn_id,
&format!("{turn_id}:turn-abort"),
codex_state::GoalAccountingMode::ActiveOnly,
BudgetLimitedGoalDisposition::ClearActive,
)
.await
{
tracing::warn!(
"failed to account active goal progress after turn abort for {turn_id}: {err}"
);
return;
}
runtime.accounting_state().finish_turn(turn_id);
let turn_id = input.turn_store.level_id();
if let Err(err) = runtime
.account_active_goal_progress(
turn_id,
&format!("{turn_id}:turn-abort"),
codex_state::GoalAccountingMode::ActiveOnly,
BudgetLimitedGoalDisposition::ClearActive,
)
.await
{
tracing::warn!(
"failed to account active goal progress after turn abort for {turn_id}: {err}"
);
return;
}
runtime.accounting_state().finish_turn(turn_id);
})
}
async fn on_turn_error(&self, input: TurnErrorInput<'_>) {
let Some(runtime) = goal_runtime_handle(input.thread_store) else {
return;
};
fn on_turn_error<'a>(&'a self, input: TurnErrorInput<'a>) -> ExtensionFuture<'a, ()> {
Box::pin(async move {
let Some(runtime) = goal_runtime_handle(input.thread_store) else {
return;
};
let reason = match input.error {
CodexErrorInfo::UsageLimitExceeded => ActiveGoalStopReason::UsageLimit,
// The turn has ended because the error was non-retryable or its
// retries were exhausted. Block the goal to prevent automatic
// continuation from looping and consuming tokens, as can happen
// with compaction errors.
_ => ActiveGoalStopReason::TurnError,
};
if let Err(err) = runtime
.stop_active_goal_for_turn(input.turn_id, reason)
.await
{
tracing::warn!(
error = ?input.error,
"failed to stop active goal after turn error: {err}"
);
}
let reason = match input.error {
CodexErrorInfo::UsageLimitExceeded => ActiveGoalStopReason::UsageLimit,
// The turn has ended because the error was non-retryable or its
// retries were exhausted. Block the goal to prevent automatic
// continuation from looping and consuming tokens, as can happen
// with compaction errors.
_ => ActiveGoalStopReason::TurnError,
};
if let Err(err) = runtime
.stop_active_goal_for_turn(input.turn_id, reason)
.await
{
tracing::warn!(
error = ?input.error,
"failed to stop active goal after turn error: {err}"
);
}
})
}
}
#[async_trait]
impl<C> TokenUsageContributor for GoalExtension<C>
where
C: Send + Sync + 'static,
{
async fn on_token_usage(
&self,
_session_store: &ExtensionData,
thread_store: &ExtensionData,
turn_store: &ExtensionData,
token_usage: &TokenUsageInfo,
) {
let Some(runtime) = goal_runtime_handle(thread_store) else {
return;
};
if !runtime.is_enabled() {
return;
}
fn on_token_usage<'a>(
&'a self,
_session_store: &'a ExtensionData,
thread_store: &'a ExtensionData,
turn_store: &'a ExtensionData,
token_usage: &'a TokenUsageInfo,
) -> ExtensionFuture<'a, ()> {
Box::pin(async move {
let Some(runtime) = goal_runtime_handle(thread_store) else {
return;
};
if !runtime.is_enabled() {
return;
}
let Some(_recorded) = runtime
.accounting_state()
.record_token_usage(turn_store.level_id(), &token_usage.total_token_usage)
else {
return;
};
let Some(_recorded) = runtime
.accounting_state()
.record_token_usage(turn_store.level_id(), &token_usage.total_token_usage)
else {
return;
};
})
}
}