Fix watchdog owner input activity

This commit is contained in:
Friel
2026-04-01 00:03:46 +00:00
parent cb41cd79b4
commit 68bcc7e0bb
23 changed files with 413 additions and 3 deletions
@@ -2619,6 +2619,17 @@
],
"type": "object"
},
"ThreadInputActivityParams": {
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"type": "object"
},
"ThreadListParams": {
"properties": {
"archived": {
@@ -3701,6 +3712,30 @@
"title": "Thread/shellCommandRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/inputActivity"
],
"title": "Thread/inputActivityRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadInputActivityParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/inputActivityRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -482,6 +482,30 @@
"title": "Thread/shellCommandRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"thread/inputActivity"
],
"title": "Thread/inputActivityRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ThreadInputActivityParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/inputActivityRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -12320,6 +12344,24 @@
"ThreadId": {
"type": "string"
},
"ThreadInputActivityParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadInputActivityParams",
"type": "object"
},
"ThreadInputActivityResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ThreadInputActivityResponse",
"type": "object"
},
"ThreadItem": {
"oneOf": [
{
@@ -1057,6 +1057,30 @@
"title": "Thread/shellCommandRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/inputActivity"
],
"title": "Thread/inputActivityRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadInputActivityParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/inputActivityRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -10166,6 +10190,24 @@
"ThreadId": {
"type": "string"
},
"ThreadInputActivityParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadInputActivityParams",
"type": "object"
},
"ThreadInputActivityResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ThreadInputActivityResponse",
"type": "object"
},
"ThreadItem": {
"oneOf": [
{
@@ -0,0 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadInputActivityParams",
"type": "object"
}
@@ -0,0 +1,5 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ThreadInputActivityResponse",
"type": "object"
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadInputActivityParams = { threadId: string, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadInputActivityResponse = Record<string, never>;
@@ -273,6 +273,8 @@ export type { ThreadCompactStartParams } from "./ThreadCompactStartParams";
export type { ThreadCompactStartResponse } from "./ThreadCompactStartResponse";
export type { ThreadForkParams } from "./ThreadForkParams";
export type { ThreadForkResponse } from "./ThreadForkResponse";
export type { ThreadInputActivityParams } from "./ThreadInputActivityParams";
export type { ThreadInputActivityResponse } from "./ThreadInputActivityResponse";
export type { ThreadItem } from "./ThreadItem";
export type { ThreadListParams } from "./ThreadListParams";
export type { ThreadListResponse } from "./ThreadListResponse";
@@ -301,6 +301,10 @@ client_request_definitions! {
params: v2::ThreadBackgroundTerminalsCleanParams,
response: v2::ThreadBackgroundTerminalsCleanResponse,
},
ThreadInputActivity => "thread/inputActivity" {
params: v2::ThreadInputActivityParams,
response: v2::ThreadInputActivityResponse,
},
ThreadRollback => "thread/rollback" {
params: v2::ThreadRollbackParams,
response: v2::ThreadRollbackResponse,
@@ -2992,6 +2992,18 @@ pub struct ThreadBackgroundTerminalsCleanParams {
#[ts(export_to = "v2/")]
pub struct ThreadBackgroundTerminalsCleanResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadInputActivityParams {
pub thread_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadInputActivityResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -6413,6 +6425,38 @@ mod tests {
assert_eq!(decoded, response);
}
#[test]
fn thread_input_activity_params_round_trip() {
let params = ThreadInputActivityParams {
thread_id: "thr_123".to_string(),
};
let value = serde_json::to_value(&params).expect("serialize thread/inputActivity params");
assert_eq!(
value,
json!({
"threadId": "thr_123",
})
);
let decoded = serde_json::from_value::<ThreadInputActivityParams>(value)
.expect("deserialize thread/inputActivity params");
assert_eq!(decoded, params);
}
#[test]
fn thread_input_activity_response_round_trip() {
let response = ThreadInputActivityResponse {};
let value =
serde_json::to_value(&response).expect("serialize thread/inputActivity response");
assert_eq!(value, json!({}));
let decoded = serde_json::from_value::<ThreadInputActivityResponse>(value)
.expect("deserialize thread/inputActivity response");
assert_eq!(decoded, response);
}
#[test]
fn fs_changed_notification_round_trips() {
let notification = FsChangedNotification {
+13
View File
@@ -147,6 +147,7 @@ Example with notification opt-out:
- `thread/compact/start` — trigger conversation history compaction for a thread; returns `{}` immediately while progress streams through standard turn/item notifications.
- `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream.
- `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted.
- `thread/inputActivity` — record owner-side draft activity for a thread without starting or steering a turn; returns `{}` and is primarily useful for watchdog idle timers while the user is still typing.
- `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success.
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode".
- `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. Review and manual compaction turns reject `turn/steer`.
@@ -604,6 +605,18 @@ not emit `turn/started` and does not accept turn context overrides.
active turn, or the active turn kind does not accept same-turn steering (for example review or
manual compaction), the request fails with an `invalid request` error.
### Example: Mark owner draft activity
Use `thread/inputActivity` to keep watchdog idle timing honest while the user is still editing a
draft in the client.
```json
{ "method": "thread/inputActivity", "id": 33, "params": {
"threadId": "thr_123"
} }
{ "id": 33, "result": {} }
```
### Example: Request a code review
Use `review/start` to run Codex’s reviewer on the currently checked-out project. The request takes the thread id plus a `target` describing what should be reviewed:
@@ -124,6 +124,8 @@ use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadIncrementElicitationParams;
use codex_app_server_protocol::ThreadIncrementElicitationResponse;
use codex_app_server_protocol::ThreadInputActivityParams;
use codex_app_server_protocol::ThreadInputActivityResponse;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
@@ -757,6 +759,10 @@ impl CodexMessageProcessor {
)
.await;
}
ClientRequest::ThreadInputActivity { request_id, params } => {
self.thread_input_activity(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::ThreadRollback { request_id, params } => {
self.thread_rollback(to_connection_request_id(request_id), params)
.await;
@@ -3235,6 +3241,40 @@ impl CodexMessageProcessor {
}
}
async fn thread_input_activity(
&self,
request_id: ConnectionRequestId,
params: ThreadInputActivityParams,
) {
let ThreadInputActivityParams { thread_id } = params;
let (_, thread) = match self.load_thread(&thread_id).await {
Ok(v) => v,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
match self
.submit_core_op(&request_id, thread.as_ref(), Op::NoteOwnerActivity)
.await
{
Ok(_) => {
self.outgoing
.send_response(request_id, ThreadInputActivityResponse {})
.await;
}
Err(err) => {
self.send_internal_error(
request_id,
format!("failed to record thread input activity: {err}"),
)
.await;
}
}
}
async fn thread_shell_command(
&self,
request_id: ConnectionRequestId,
+5 -1
View File
@@ -591,13 +591,17 @@ impl AgentControl {
)
.await;
if result.is_ok() {
self.watchdogs.note_owner_input(agent_id).await;
self.note_owner_input(agent_id).await;
self.state
.update_last_task_message(agent_id, last_task_message);
}
result
}
pub(crate) async fn note_owner_input(&self, agent_id: ThreadId) {
self.watchdogs.note_owner_input(agent_id).await;
}
/// Append a prebuilt message to an existing agent thread outside the normal user-input path.
#[cfg(test)]
pub(crate) async fn append_message(
+38
View File
@@ -459,6 +459,44 @@ async fn send_input_resets_watchdog_owner_idle_state() {
);
}
#[tokio::test]
async fn note_owner_input_resets_watchdog_owner_idle_state() {
let harness = AgentControlHarness::new().await;
let (owner_thread_id, _thread) = harness.start_thread().await;
let target_thread_id = ThreadId::new();
harness
.control
.register_watchdog(WatchdogRegistration {
owner_thread_id,
target_thread_id,
child_depth: 0,
interval_s: 30,
prompt: String::new(),
config: harness.config.clone(),
})
.await
.expect("watchdog registration should succeed");
assert_eq!(
harness
.control
.watchdog_owner_idle_since_is_none_for_tests(target_thread_id)
.await,
Some(false)
);
harness.control.note_owner_input(owner_thread_id).await;
assert_eq!(
harness
.control
.watchdog_owner_idle_since_is_none_for_tests(target_thread_id)
.await,
Some(true)
);
}
#[test]
fn build_agent_inbox_items_emits_function_call_and_output() {
let sender_thread_id = ThreadId::new();
+7
View File
@@ -4585,6 +4585,13 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
handlers::interrupt(&sess).await;
false
}
Op::NoteOwnerActivity => {
sess.services
.agent_control
.note_owner_input(sess.conversation_id)
.await;
false
}
Op::CleanBackgroundTerminals => {
handlers::clean_background_terminals(&sess).await;
false
+4
View File
@@ -235,6 +235,9 @@ pub enum Op {
/// This server sends [`EventMsg::TurnAborted`] in response.
Interrupt,
/// Mark owner-side input activity without starting or steering a turn.
NoteOwnerActivity,
/// Terminate all running background terminal processes for this thread.
/// Use this when callers intentionally want to stop long-lived background shells.
CleanBackgroundTerminals,
@@ -595,6 +598,7 @@ impl Op {
pub fn kind(&self) -> &'static str {
match self {
Self::Interrupt => "interrupt",
Self::NoteOwnerActivity => "note_owner_activity",
Self::CleanBackgroundTerminals => "clean_background_terminals",
Self::RealtimeConversationStart(_) => "realtime_conversation_start",
Self::RealtimeConversationAudio(_) => "realtime_conversation_audio",
+4
View File
@@ -2907,6 +2907,10 @@ impl App {
app_server.turn_interrupt(thread_id, turn_id).await?;
Ok(true)
}
AppCommandView::NoteOwnerActivity => {
app_server.thread_input_activity(thread_id).await?;
Ok(true)
}
AppCommandView::UserTurn {
items,
cwd,
+6
View File
@@ -30,6 +30,7 @@ pub(crate) struct AppCommand(Op);
#[allow(dead_code)]
pub(crate) enum AppCommandView<'a> {
Interrupt,
NoteOwnerActivity,
CleanBackgroundTerminals,
RealtimeConversationStart(&'a ConversationStartParams),
RealtimeConversationAudio(&'a ConversationAudioParams),
@@ -113,6 +114,10 @@ impl AppCommand {
Self(Op::Interrupt)
}
pub(crate) fn note_owner_activity() -> Self {
Self(Op::NoteOwnerActivity)
}
pub(crate) fn clean_background_terminals() -> Self {
Self(Op::CleanBackgroundTerminals)
}
@@ -289,6 +294,7 @@ impl AppCommand {
pub(crate) fn view(&self) -> AppCommandView<'_> {
match &self.0 {
Op::Interrupt => AppCommandView::Interrupt,
Op::NoteOwnerActivity => AppCommandView::NoteOwnerActivity,
Op::CleanBackgroundTerminals => AppCommandView::CleanBackgroundTerminals,
Op::RealtimeConversationStart(params) => {
AppCommandView::RealtimeConversationStart(params)
+17
View File
@@ -30,6 +30,8 @@ use codex_app_server_protocol::ThreadCompactStartParams;
use codex_app_server_protocol::ThreadCompactStartResponse;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadInputActivityParams;
use codex_app_server_protocol::ThreadInputActivityResponse;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadLoadedListParams;
@@ -454,6 +456,21 @@ impl AppServerSession {
Ok(())
}
pub(crate) async fn thread_input_activity(&mut self, thread_id: ThreadId) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadInputActivityResponse = self
.client
.request_typed(ClientRequest::ThreadInputActivity {
request_id,
params: ThreadInputActivityParams {
thread_id: thread_id.to_string(),
},
})
.await
.wrap_err("thread/inputActivity failed in TUI")?;
Ok(())
}
pub(crate) async fn turn_steer(
&mut self,
thread_id: ThreadId,
+39
View File
@@ -248,6 +248,7 @@ const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading";
const PLAN_IMPLEMENTATION_TITLE: &str = "Implement this plan?";
const PLAN_IMPLEMENTATION_YES: &str = "Yes, implement this plan";
const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode";
const WATCHDOG_OWNER_ACTIVITY_SIGNAL_INTERVAL: Duration = Duration::from_secs(1);
const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan.";
const MULTI_AGENT_ENABLE_TITLE: &str = "Enable subagents?";
const MULTI_AGENT_ENABLE_YES: &str = "Yes, enable";
@@ -916,6 +917,8 @@ pub(crate) struct ChatWidget {
/// We require the second press to match this key so `Ctrl+C` followed by
/// `Ctrl+D` (or vice versa) doesn't quit accidentally.
quit_shortcut_key: Option<KeyBinding>,
// Last time we sent a lightweight owner-activity signal for the running thread.
last_watchdog_owner_activity_signal_at: Option<Instant>,
// Simple review mode flag; used to adjust layout and banners.
is_review_mode: bool,
// Snapshot of token usage to restore after review mode exits.
@@ -2322,6 +2325,7 @@ impl ChatWidget {
fn on_task_started(&mut self) {
self.agent_turn_running = true;
self.last_watchdog_owner_activity_signal_at = None;
self.turn_sleep_inhibitor
.set_turn_running(/*turn_running*/ true);
self.saw_plan_update_this_turn = false;
@@ -2392,6 +2396,7 @@ impl ChatWidget {
// Mark task stopped and request redraw now that all content is in history.
self.pending_status_indicator_restore = false;
self.agent_turn_running = false;
self.last_watchdog_owner_activity_signal_at = None;
self.turn_sleep_inhibitor
.set_turn_running(/*turn_running*/ false);
self.update_task_running_state();
@@ -4858,6 +4863,7 @@ impl ChatWidget {
pending_notification: None,
quit_shortcut_expires_at: None,
quit_shortcut_key: None,
last_watchdog_owner_activity_signal_at: None,
is_review_mode: false,
pre_review_token_info: None,
needs_final_message_separator: false,
@@ -4921,6 +4927,7 @@ impl ChatWidget {
}
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
let composer_before = self.bottom_pane.composer_text_with_pending();
match key_event {
KeyEvent {
code: KeyCode::Char(c),
@@ -5090,6 +5097,7 @@ impl ChatWidget {
InputResult::None => {}
},
}
self.maybe_signal_watchdog_owner_activity_if_draft_changed(&composer_before);
}
/// Attach a local image to the composer when the active model supports image inputs.
@@ -5114,7 +5122,9 @@ impl ChatWidget {
}
pub(crate) fn apply_external_edit(&mut self, text: String) {
let composer_before = self.bottom_pane.composer_text_with_pending();
self.bottom_pane.apply_external_edit(text);
self.maybe_signal_watchdog_owner_activity_if_draft_changed(&composer_before);
self.request_redraw();
}
@@ -5663,7 +5673,36 @@ impl ChatWidget {
}
pub(crate) fn handle_paste(&mut self, text: String) {
let composer_before = self.bottom_pane.composer_text_with_pending();
self.bottom_pane.handle_paste(text);
self.maybe_signal_watchdog_owner_activity_if_draft_changed(&composer_before);
}
fn maybe_signal_watchdog_owner_activity_if_draft_changed(&mut self, composer_before: &str) {
if self.bottom_pane.composer_text_with_pending() == composer_before {
return;
}
self.maybe_signal_watchdog_owner_activity();
}
fn maybe_signal_watchdog_owner_activity(&mut self) {
if !self.agent_turn_running {
return;
}
let Some(thread_id) = self.thread_id else {
return;
};
let now = Instant::now();
if let Some(last_signal_at) = self.last_watchdog_owner_activity_signal_at
&& now.duration_since(last_signal_at) < WATCHDOG_OWNER_ACTIVITY_SIGNAL_INTERVAL
{
return;
}
self.last_watchdog_owner_activity_signal_at = Some(now);
self.app_event_tx.send(AppEvent::SubmitThreadOp {
thread_id,
op: AppCommand::note_owner_activity().into(),
});
}
// Returns true if caller should skip rendering this frame (a future frame is scheduled).
+39
View File
@@ -2273,6 +2273,7 @@ async fn make_chatwidget_manual(
pending_notification: None,
quit_shortcut_expires_at: None,
quit_shortcut_key: None,
last_watchdog_owner_activity_signal_at: None,
is_review_mode: false,
pre_review_token_info: None,
needs_final_message_separator: false,
@@ -2333,6 +2334,21 @@ fn next_interrupt_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) {
}
}
fn next_owner_activity_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) {
loop {
match op_rx.try_recv() {
Ok(Op::NoteOwnerActivity) => return,
Ok(_) => continue,
Err(TryRecvError::Empty) => {
panic!("expected owner activity op but queue was empty")
}
Err(TryRecvError::Disconnected) => {
panic!("expected owner activity op but channel closed")
}
}
}
}
fn next_realtime_close_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) {
loop {
match op_rx.try_recv() {
@@ -2388,6 +2404,29 @@ async fn worked_elapsed_from_resets_when_timer_restarts() {
assert_eq!(chat.worked_elapsed_from(/*current_elapsed*/ 7), 4);
}
#[tokio::test]
async fn typing_during_running_turn_emits_owner_activity_op() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.agent_turn_running = true;
chat.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
next_owner_activity_op(&mut op_rx);
}
#[tokio::test]
async fn non_mutating_keypress_during_running_turn_does_not_emit_owner_activity_op() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.agent_turn_running = true;
chat.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
assert_no_submit_op(&mut op_rx);
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
}
pub(crate) async fn make_chatwidget_manual_with_sender() -> (
ChatWidget,
AppEventSender,
+2 -1
View File
@@ -58,9 +58,10 @@ Messages sent to subagents continue to arrive as normal user input.
Watchdog agents use the top-level `watchdog_interval_s` setting to decide how long the owner thread
must be idle before a check-in helper is spawned when the `agent_watchdog` feature is enabled.
Owner-side typing/input activity resets the idle timer.
```toml
watchdog_interval_s = 60
watchdog_interval_s = 10
```
## JSON Schema