diff --git a/codex-rs/deterministic-core/src/approval.rs b/codex-rs/deterministic-core/src/approval.rs index 0ef39903500a..9c2ed39bd674 100644 --- a/codex-rs/deterministic-core/src/approval.rs +++ b/codex-rs/deterministic-core/src/approval.rs @@ -214,6 +214,7 @@ mod tests { ownership_note: None, due_date: None, blocked_by_run_ids: vec![], + effort: None, created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(), } diff --git a/codex-rs/deterministic-core/src/lib.rs b/codex-rs/deterministic-core/src/lib.rs index f1782d29c90d..03d608005674 100644 --- a/codex-rs/deterministic-core/src/lib.rs +++ b/codex-rs/deterministic-core/src/lib.rs @@ -28,6 +28,7 @@ pub mod run_set_priority; pub mod run_assign_owner; pub mod run_set_due_date; pub mod run_set_dependencies; +pub mod run_set_effort; pub mod run_readiness; pub mod tests_run; pub mod workspace_summary; diff --git a/codex-rs/deterministic-core/src/run_annotate.rs b/codex-rs/deterministic-core/src/run_annotate.rs index 958c78d28216..a9c6bd135e0a 100644 --- a/codex-rs/deterministic-core/src/run_annotate.rs +++ b/codex-rs/deterministic-core/src/run_annotate.rs @@ -161,6 +161,7 @@ mod tests { ownership_note: None, due_date: None, blocked_by_run_ids: vec![], + effort: None, created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(), } diff --git a/codex-rs/deterministic-core/src/run_archive.rs b/codex-rs/deterministic-core/src/run_archive.rs index ddfdd9536e81..cf57200e73fe 100644 --- a/codex-rs/deterministic-core/src/run_archive.rs +++ b/codex-rs/deterministic-core/src/run_archive.rs @@ -102,6 +102,7 @@ mod tests { ownership_note: None, due_date: None, blocked_by_run_ids: vec![], + effort: None, created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(), } diff --git a/codex-rs/deterministic-core/src/run_assign_owner.rs b/codex-rs/deterministic-core/src/run_assign_owner.rs index 7055cbca4e9b..7c41bb9fb08c 100644 --- a/codex-rs/deterministic-core/src/run_assign_owner.rs +++ b/codex-rs/deterministic-core/src/run_assign_owner.rs @@ -130,6 +130,7 @@ mod tests { ownership_note: None, due_date: None, blocked_by_run_ids: vec![], + effort: None, created_at: "2024-01-01T00:00:00Z".to_string(), updated_at: "2024-01-01T00:00:00Z".to_string(), } diff --git a/codex-rs/deterministic-core/src/run_finalize.rs b/codex-rs/deterministic-core/src/run_finalize.rs index 95d45d8c4415..a5e74cb3fb8b 100644 --- a/codex-rs/deterministic-core/src/run_finalize.rs +++ b/codex-rs/deterministic-core/src/run_finalize.rs @@ -102,6 +102,7 @@ mod tests { ownership_note: None, due_date: None, blocked_by_run_ids: vec![], + effort: None, created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(), } diff --git a/codex-rs/deterministic-core/src/run_pin.rs b/codex-rs/deterministic-core/src/run_pin.rs index 02d684d2d2b1..f9a63243ea0a 100644 --- a/codex-rs/deterministic-core/src/run_pin.rs +++ b/codex-rs/deterministic-core/src/run_pin.rs @@ -92,6 +92,7 @@ mod tests { ownership_note: None, due_date: None, blocked_by_run_ids: vec![], + effort: None, created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(), } diff --git a/codex-rs/deterministic-core/src/run_prepare.rs b/codex-rs/deterministic-core/src/run_prepare.rs index 0bee1f599c4d..1dd27721e6a9 100644 --- a/codex-rs/deterministic-core/src/run_prepare.rs +++ b/codex-rs/deterministic-core/src/run_prepare.rs @@ -96,6 +96,7 @@ pub fn prepare(params: &RunPrepareParams) -> Result<(RunPrepareResult, RunState) ownership_note: None, due_date: None, blocked_by_run_ids: vec![], + effort: None, created_at: now.clone(), updated_at: now, }; diff --git a/codex-rs/deterministic-core/src/run_refresh.rs b/codex-rs/deterministic-core/src/run_refresh.rs index 20105ff3bb2c..ea308a5cd543 100644 --- a/codex-rs/deterministic-core/src/run_refresh.rs +++ b/codex-rs/deterministic-core/src/run_refresh.rs @@ -103,6 +103,7 @@ mod tests { ownership_note: None, due_date: None, blocked_by_run_ids: vec![], + effort: None, created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(), } diff --git a/codex-rs/deterministic-core/src/run_reopen.rs b/codex-rs/deterministic-core/src/run_reopen.rs index 7a4eaacfffbf..99e1098258c2 100644 --- a/codex-rs/deterministic-core/src/run_reopen.rs +++ b/codex-rs/deterministic-core/src/run_reopen.rs @@ -129,6 +129,7 @@ mod tests { ownership_note: None, due_date: None, blocked_by_run_ids: vec![], + effort: None, created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(), } diff --git a/codex-rs/deterministic-core/src/run_replan.rs b/codex-rs/deterministic-core/src/run_replan.rs index 587fad78c237..e108e77415cc 100644 --- a/codex-rs/deterministic-core/src/run_replan.rs +++ b/codex-rs/deterministic-core/src/run_replan.rs @@ -213,6 +213,7 @@ mod tests { ownership_note: None, due_date: None, blocked_by_run_ids: vec![], + effort: None, created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(), } diff --git a/codex-rs/deterministic-core/src/run_set_dependencies.rs b/codex-rs/deterministic-core/src/run_set_dependencies.rs index 5a866f8f2dbe..f726618f1d46 100644 --- a/codex-rs/deterministic-core/src/run_set_dependencies.rs +++ b/codex-rs/deterministic-core/src/run_set_dependencies.rs @@ -128,6 +128,7 @@ mod tests { ownership_note: None, due_date: None, blocked_by_run_ids: vec![], + effort: None, created_at: "2024-01-01T00:00:00Z".to_string(), updated_at: "2024-01-01T00:00:00Z".to_string(), } diff --git a/codex-rs/deterministic-core/src/run_set_due_date.rs b/codex-rs/deterministic-core/src/run_set_due_date.rs index 950a87565421..7f31b6b27aa7 100644 --- a/codex-rs/deterministic-core/src/run_set_due_date.rs +++ b/codex-rs/deterministic-core/src/run_set_due_date.rs @@ -122,6 +122,7 @@ mod tests { ownership_note: None, due_date: None, blocked_by_run_ids: vec![], + effort: None, created_at: "2024-01-01T00:00:00Z".to_string(), updated_at: "2024-01-01T00:00:00Z".to_string(), } diff --git a/codex-rs/deterministic-core/src/run_set_effort.rs b/codex-rs/deterministic-core/src/run_set_effort.rs new file mode 100644 index 000000000000..95369786cc01 --- /dev/null +++ b/codex-rs/deterministic-core/src/run_set_effort.rs @@ -0,0 +1,287 @@ +//! Deterministic run effort update (Milestone 24). +//! +//! Provides a deterministic, explicit effort-bucket surface for ChatGPT to +//! classify runs by expected execution size. +//! +//! Rules: +//! - Any run (regardless of status) may have its effort updated or cleared. +//! - The effort value must be one of the bounded enum variants: `small`, `medium`, `large`. +//! - Passing `effort: Some(None)` (JSON `null`) clears the effort estimate. +//! - Setting effort does not execute work. +//! - Setting effort does not change status, plan, retryable action, lineage, +//! archive state, pin state, snooze state, priority, ownership, due date, +//! dependencies, or any other lifecycle field. +//! - An audit entry is appended by the daemon layer. + +use anyhow::Result; +use deterministic_protocol::{RunEffort, RunSetEffortParams, RunSetEffortResult, RunState}; + +/// Set or clear the effort estimate of a run. +/// +/// Deterministic rules: +/// - Any run may have its effort updated regardless of current status. +/// - Only `effort` and `updated_at` are mutated on `state`. +/// +/// Returns the updated run state (via mutation) and a result DTO. +pub fn set_effort( + params: &RunSetEffortParams, + state: &mut RunState, +) -> Result { + let previous_effort = state.effort; + + let new_effort: Option = match ¶ms.effort { + None => { + // The field was absent — treat as no change (preserve current value). + state.effort + } + Some(None) => None, // explicit JSON null → clear + Some(Some(e)) => Some(*e), // explicit value → set + }; + + let now = chrono::Utc::now().to_rfc3339(); + state.effort = new_effort; + state.updated_at = now.clone(); + + let message = match (previous_effort, new_effort) { + (None, None) => "effort unchanged (no estimate set)".to_string(), + (None, Some(e)) => format!("effort set to {e}"), + (Some(prev), None) => format!("effort {} cleared", prev.as_str()), + (Some(prev), Some(next)) if prev == next => { + format!("effort unchanged: {next}") + } + (Some(prev), Some(next)) => { + format!("effort updated from {} to {}", prev.as_str(), next.as_str()) + } + }; + + Ok(RunSetEffortResult { + run_id: params.run_id.clone(), + status: state.status.clone(), + previous_effort, + effort: new_effort, + updated_at: now, + message, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use deterministic_protocol::{RunEffort, RunPolicy, RunPriority}; + + fn make_state(id: &str, status: &str) -> RunState { + RunState { + run_id: id.to_string(), + workspace_id: "/tmp/ws".to_string(), + status: status.to_string(), + user_goal: "test goal".to_string(), + plan: vec![], + current_step: 0, + completed_steps: vec![], + pending_steps: vec![], + last_action: None, + last_observation: None, + recommended_next_action: None, + recommended_tool: None, + latest_diff_summary: None, + latest_test_result: None, + focus_paths: vec![], + warnings: vec![], + retryable_action: None, + policy_profile: RunPolicy::default(), + finalized_outcome: None, + reopen_metadata: None, + supersedes_run_id: None, + superseded_by_run_id: None, + supersession_reason: None, + superseded_at: None, + archive_metadata: None, + unarchive_metadata: None, + annotation: None, + pin_metadata: None, + snooze_metadata: None, + priority: RunPriority::Normal, + assignee: None, + ownership_note: None, + due_date: None, + blocked_by_run_ids: vec![], + effort: None, + created_at: "2024-01-01T00:00:00Z".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + } + } + + fn params_set(run_id: &str, effort: RunEffort) -> RunSetEffortParams { + RunSetEffortParams { + run_id: run_id.into(), + effort: Some(Some(effort)), + } + } + + fn params_clear(run_id: &str) -> RunSetEffortParams { + RunSetEffortParams { + run_id: run_id.into(), + effort: Some(None), + } + } + + #[test] + fn set_effort_to_small() { + let mut state = make_state("r1", "active"); + let result = set_effort(¶ms_set("r1", RunEffort::Small), &mut state).unwrap(); + assert_eq!(result.effort, Some(RunEffort::Small)); + assert_eq!(result.previous_effort, None); + assert_eq!(state.effort, Some(RunEffort::Small)); + } + + #[test] + fn set_effort_to_medium() { + let mut state = make_state("r2", "active"); + let result = set_effort(¶ms_set("r2", RunEffort::Medium), &mut state).unwrap(); + assert_eq!(result.effort, Some(RunEffort::Medium)); + assert_eq!(state.effort, Some(RunEffort::Medium)); + } + + #[test] + fn set_effort_to_large() { + let mut state = make_state("r3", "active"); + let result = set_effort(¶ms_set("r3", RunEffort::Large), &mut state).unwrap(); + assert_eq!(result.effort, Some(RunEffort::Large)); + assert_eq!(state.effort, Some(RunEffort::Large)); + } + + #[test] + fn replace_existing_effort() { + let mut state = make_state("r4", "active"); + state.effort = Some(RunEffort::Small); + let result = set_effort(¶ms_set("r4", RunEffort::Large), &mut state).unwrap(); + assert_eq!(result.effort, Some(RunEffort::Large)); + assert_eq!(result.previous_effort, Some(RunEffort::Small)); + assert_eq!(state.effort, Some(RunEffort::Large)); + } + + #[test] + fn clear_effort() { + let mut state = make_state("r5", "active"); + state.effort = Some(RunEffort::Medium); + let result = set_effort(¶ms_clear("r5"), &mut state).unwrap(); + assert_eq!(result.effort, None); + assert_eq!(result.previous_effort, Some(RunEffort::Medium)); + assert_eq!(state.effort, None); + } + + #[test] + fn clear_when_already_none() { + let mut state = make_state("r6", "active"); + let result = set_effort(¶ms_clear("r6"), &mut state).unwrap(); + assert_eq!(result.effort, None); + assert_eq!(result.previous_effort, None); + assert!(result.message.contains("unchanged"), "{}", result.message); + } + + #[test] + fn set_same_effort_unchanged_message() { + let mut state = make_state("r7", "active"); + state.effort = Some(RunEffort::Medium); + let result = set_effort(¶ms_set("r7", RunEffort::Medium), &mut state).unwrap(); + assert_eq!(result.effort, Some(RunEffort::Medium)); + assert!(result.message.contains("unchanged"), "{}", result.message); + } + + #[test] + fn set_effort_does_not_change_status() { + let mut state = make_state("r8", "finalized:completed"); + set_effort(¶ms_set("r8", RunEffort::Small), &mut state).unwrap(); + assert_eq!(state.status, "finalized:completed"); + } + + #[test] + fn set_effort_updates_updated_at() { + let mut state = make_state("r9", "active"); + set_effort(¶ms_set("r9", RunEffort::Large), &mut state).unwrap(); + assert_ne!(state.updated_at, "2024-01-01T00:00:00Z"); + } + + #[test] + fn set_effort_on_finalized_run_succeeds() { + let mut state = make_state("r10", "finalized:completed"); + let result = set_effort(¶ms_set("r10", RunEffort::Small), &mut state).unwrap(); + assert_eq!(result.effort, Some(RunEffort::Small)); + assert_eq!(state.status, "finalized:completed"); + } + + #[test] + fn message_set() { + let mut state = make_state("r11", "active"); + let result = set_effort(¶ms_set("r11", RunEffort::Large), &mut state).unwrap(); + assert!(result.message.contains("large"), "{}", result.message); + } + + #[test] + fn message_cleared() { + let mut state = make_state("r12", "active"); + state.effort = Some(RunEffort::Large); + let result = set_effort(¶ms_clear("r12"), &mut state).unwrap(); + assert!(result.message.contains("cleared"), "{}", result.message); + } + + #[test] + fn message_updated() { + let mut state = make_state("r13", "active"); + state.effort = Some(RunEffort::Small); + let result = set_effort(¶ms_set("r13", RunEffort::Medium), &mut state).unwrap(); + assert!(result.message.contains("small"), "{}", result.message); + assert!(result.message.contains("medium"), "{}", result.message); + } + + #[test] + fn result_includes_updated_at() { + let mut state = make_state("r14", "active"); + let result = set_effort(¶ms_set("r14", RunEffort::Small), &mut state).unwrap(); + assert!(!result.updated_at.is_empty()); + } + + #[test] + fn effort_enum_ordering() { + assert!(RunEffort::Small < RunEffort::Medium); + assert!(RunEffort::Medium < RunEffort::Large); + } + + #[test] + fn effort_sort_keys_are_monotone() { + let keys: Vec = RunEffort::all().iter().map(|e| e.sort_key()).collect(); + for i in 0..keys.len() - 1 { + assert!(keys[i] < keys[i + 1], "sort_key not strictly increasing"); + } + } + + #[test] + fn effort_roundtrip_parse() { + for e in RunEffort::all() { + assert_eq!(RunEffort::parse(e.as_str()), Some(*e)); + } + } + + #[test] + fn effort_parse_invalid_returns_none() { + assert_eq!(RunEffort::parse("tiny"), None); + assert_eq!(RunEffort::parse(""), None); + assert_eq!(RunEffort::parse("LARGE"), None); + assert_eq!(RunEffort::parse("extra-large"), None); + } + + #[test] + fn no_op_absent_effort_preserves_current() { + let mut state = make_state("r15", "active"); + state.effort = Some(RunEffort::Medium); + // params.effort = None means "absent from JSON" — treat as no-op + let p = RunSetEffortParams { + run_id: "r15".into(), + effort: None, + }; + let result = set_effort(&p, &mut state).unwrap(); + // Should preserve the current value + assert_eq!(result.effort, Some(RunEffort::Medium)); + assert_eq!(state.effort, Some(RunEffort::Medium)); + } +} diff --git a/codex-rs/deterministic-core/src/run_set_priority.rs b/codex-rs/deterministic-core/src/run_set_priority.rs index 83d050241bec..dcbefaeaa27d 100644 --- a/codex-rs/deterministic-core/src/run_set_priority.rs +++ b/codex-rs/deterministic-core/src/run_set_priority.rs @@ -101,6 +101,7 @@ mod tests { ownership_note: None, due_date: None, blocked_by_run_ids: vec![], + effort: None, created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(), } diff --git a/codex-rs/deterministic-core/src/run_snooze.rs b/codex-rs/deterministic-core/src/run_snooze.rs index 7b0e4981ad43..229a68417e7c 100644 --- a/codex-rs/deterministic-core/src/run_snooze.rs +++ b/codex-rs/deterministic-core/src/run_snooze.rs @@ -94,6 +94,7 @@ mod tests { ownership_note: None, due_date: None, blocked_by_run_ids: vec![], + effort: None, created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(), } diff --git a/codex-rs/deterministic-core/src/run_supersede.rs b/codex-rs/deterministic-core/src/run_supersede.rs index cf985b76407e..6b767fee1d67 100644 --- a/codex-rs/deterministic-core/src/run_supersede.rs +++ b/codex-rs/deterministic-core/src/run_supersede.rs @@ -88,6 +88,7 @@ pub fn supersede( ownership_note: None, due_date: None, blocked_by_run_ids: vec![], + effort: None, created_at: now.clone(), updated_at: now.clone(), }; @@ -155,6 +156,7 @@ mod tests { ownership_note: None, due_date: None, blocked_by_run_ids: vec![], + effort: None, created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(), } diff --git a/codex-rs/deterministic-core/src/run_unarchive.rs b/codex-rs/deterministic-core/src/run_unarchive.rs index ab325ef4d557..47c44e12b68d 100644 --- a/codex-rs/deterministic-core/src/run_unarchive.rs +++ b/codex-rs/deterministic-core/src/run_unarchive.rs @@ -104,6 +104,7 @@ mod tests { ownership_note: None, due_date: None, blocked_by_run_ids: vec![], + effort: None, created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(), } diff --git a/codex-rs/deterministic-core/src/run_unpin.rs b/codex-rs/deterministic-core/src/run_unpin.rs index 84250073768f..cfd105a1b337 100644 --- a/codex-rs/deterministic-core/src/run_unpin.rs +++ b/codex-rs/deterministic-core/src/run_unpin.rs @@ -89,6 +89,7 @@ mod tests { ownership_note: None, due_date: None, blocked_by_run_ids: vec![], + effort: None, created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(), } diff --git a/codex-rs/deterministic-core/src/run_unsnooze.rs b/codex-rs/deterministic-core/src/run_unsnooze.rs index c09a2e7f1da6..ae1139d3889b 100644 --- a/codex-rs/deterministic-core/src/run_unsnooze.rs +++ b/codex-rs/deterministic-core/src/run_unsnooze.rs @@ -89,6 +89,7 @@ mod tests { ownership_note: None, due_date: None, blocked_by_run_ids: vec![], + effort: None, created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(), } diff --git a/codex-rs/deterministic-daemon/src/handlers.rs b/codex-rs/deterministic-daemon/src/handlers.rs index 1c7c59192d66..e7c771667dc0 100644 --- a/codex-rs/deterministic-daemon/src/handlers.rs +++ b/codex-rs/deterministic-daemon/src/handlers.rs @@ -62,6 +62,8 @@ pub fn dispatch( Method::RunSetDependencies => handle_run_set_dependencies(params, store), // Milestone 24: deterministic queue overview Method::RunsQueueOverview => handle_runs_queue_overview(params, store), + // Milestone 25: deterministic run effort estimates + Method::RunSetEffort => handle_run_set_effort(params, store), } } @@ -533,6 +535,21 @@ fn handle_runs_list( if let Some(min_count) = p.blocking_run_count_at_least { runs.retain(|r| r.blocking_run_count.unwrap_or(0) >= min_count); } + // Milestone 24: effort_filter — keep only runs with the requested effort bucket. + if let Some(filter_effort) = p.effort_filter { + runs.retain(|r| r.effort == Some(filter_effort)); + } + // Milestone 24: sort_by_effort — ascending (small → medium → large), runs with no + // effort sort last. Uses a stable sort to preserve the existing pin / updated_at + // ordering within each bucket. + if p.sort_by_effort.unwrap_or(false) { + runs.sort_by(|a, b| match (a.effort, b.effort) { + (Some(ea), Some(eb)) => ea.cmp(&eb), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + }); + } let count = runs.len(); let result = RunsListResult { runs, count }; Ok((serde_json::to_value(result)?, None)) @@ -568,6 +585,7 @@ fn handle_run_get( let priority = state.priority; let due_date = state.due_date.clone(); let blocked_by_run_ids = state.blocked_by_run_ids.clone(); + let effort = state.effort; // Milestone 23: compute blocker-impact for this run. let blocker_map = store.get_blocker_impact_map()?; @@ -606,6 +624,7 @@ fn handle_run_get( is_blocking, blocking_run_count, blocking_reason, + effort, }; Ok((serde_json::to_value(result)?, Some(state))) } @@ -1272,6 +1291,37 @@ fn handle_run_set_dependencies( Ok((serde_json::to_value(result)?, Some(state))) } +// ---- Milestone 25: deterministic run effort estimates ---- + +fn handle_run_set_effort( + params: serde_json::Value, + store: &Store, +) -> Result<(serde_json::Value, Option)> { + let p: RunSetEffortParams = serde_json::from_value(params)?; + let mut state = store + .get_run(&p.run_id)? + .ok_or_else(|| anyhow::anyhow!("unknown run: {}", p.run_id))?; + + let result = deterministic_core::run_set_effort::set_effort(&p, &mut state)?; + store.save_run(&state)?; + + let _ = store.append_audit_entry( + &p.run_id, + "run_effort_set", + &result.message, + Some( + &serde_json::json!({ + "previous_effort": result.previous_effort.map(deterministic_protocol::RunEffort::as_str), + "effort": result.effort.map(deterministic_protocol::RunEffort::as_str), + "updated_at": result.updated_at, + }) + .to_string(), + ), + ); + + Ok((serde_json::to_value(result)?, Some(state))) +} + // --------------------------------------------------------------------------- // runs.overview (Milestone 24) @@ -1470,6 +1520,7 @@ mod tests { ownership_note: None, due_date: None, blocked_by_run_ids: vec![], + effort: None, created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(), } diff --git a/codex-rs/deterministic-daemon/src/persistence.rs b/codex-rs/deterministic-daemon/src/persistence.rs index 39dbdd22ca71..55887cd161bf 100644 --- a/codex-rs/deterministic-daemon/src/persistence.rs +++ b/codex-rs/deterministic-daemon/src/persistence.rs @@ -5,7 +5,7 @@ //! access — unlike the previous JSON-file approach. use anyhow::{Context, Result}; -use deterministic_protocol::{ArchiveMetadata, PendingApproval, PinMetadata, ReopenMetadata, RetryableAction, RunAnnotation, RunHistoryEntry, RunOutcome, RunPolicy, RunPriority, RunState, RunSummary, SnoozeMetadata, UnarchiveMetadata}; +use deterministic_protocol::{ArchiveMetadata, PendingApproval, PinMetadata, ReopenMetadata, RetryableAction, RunAnnotation, RunEffort, RunHistoryEntry, RunOutcome, RunPolicy, RunPriority, RunState, RunSummary, SnoozeMetadata, UnarchiveMetadata}; use rusqlite::Connection; use std::path::Path; use std::sync::Mutex; @@ -143,6 +143,8 @@ impl Store { ("runs", "due_date", "TEXT"), // Milestone 21 columns ("runs", "blocked_by_run_ids", "TEXT NOT NULL DEFAULT '[]'"), + // Milestone 24 columns + ("runs", "effort", "TEXT"), ]; for (table, column, def) in migrations { @@ -259,6 +261,8 @@ impl Store { // Milestone 21: persist blocked_by_run_ids as JSON array. let blocked_by_run_ids_json = serde_json::to_string(&state.blocked_by_run_ids) .context("failed to serialise blocked_by_run_ids")?; + // Milestone 24: persist effort as optional string. + let effort_str: Option<&str> = state.effort.as_ref().map(|e| e.as_str()); conn.execute( "INSERT OR REPLACE INTO runs (run_id, workspace_id, user_goal, status, plan, current_step, @@ -271,8 +275,9 @@ impl Store { unarchive_metadata, annotation, pin_metadata, is_snoozed, snooze_metadata, priority, assignee, ownership_note, due_date, blocked_by_run_ids, + effort, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32, ?33, ?34, ?35, ?36, ?37, ?38, ?39)", + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32, ?33, ?34, ?35, ?36, ?37, ?38, ?39, ?40)", rusqlite::params![ state.run_id, state.workspace_id, @@ -311,6 +316,7 @@ impl Store { state.ownership_note, state.due_date, blocked_by_run_ids_json, + effort_str, state.created_at, state.updated_at, ], @@ -335,6 +341,7 @@ impl Store { unarchive_metadata, annotation, pin_metadata, snooze_metadata, priority, assignee, ownership_note, due_date, blocked_by_run_ids, + effort, created_at, updated_at FROM runs WHERE run_id = ?1", ) @@ -550,6 +557,10 @@ impl Store { ) })?; + // Milestone 24: effort — optional TEXT, default None. + let effort_str: Option = row.get(34)?; + let effort: Option = effort_str.as_deref().and_then(RunEffort::parse); + Ok(RunState { run_id: row.get(0)?, workspace_id: row.get(1)?, @@ -585,8 +596,9 @@ impl Store { ownership_note, due_date, blocked_by_run_ids, - created_at: row.get(34)?, - updated_at: row.get(35)?, + effort, + created_at: row.get(35)?, + updated_at: row.get(36)?, }) }) .context("failed to query run")?; @@ -801,7 +813,8 @@ impl Store { created_at, updated_at, outcome_kind, reopen_metadata, supersedes_run_id, superseded_by_run_id, is_archived, archive_metadata, unarchive_metadata, annotation, pin_metadata, snooze_metadata, priority, - assignee, due_date, blocked_by_run_ids + assignee, due_date, blocked_by_run_ids, + effort FROM runs {where_clause} ORDER BY CASE WHEN pin_metadata IS NOT NULL THEN 0 ELSE 1 END ASC, updated_at DESC LIMIT ?1" @@ -817,7 +830,7 @@ impl Store { // 10: supersedes_run_id 11: superseded_by_run_id // 12: is_archived 13: archive_metadata 14: unarchive_metadata // 15: annotation 16: pin_metadata 17: snooze_metadata 18: priority - // 19: assignee 20: due_date 21: blocked_by_run_ids + // 19: assignee 20: due_date 21: blocked_by_run_ids 22: effort let plan_json: String = row.get(5)?; let total_steps: usize = serde_json::from_str::>(&plan_json) .map(|v| v.len()) @@ -882,6 +895,9 @@ impl Store { serde_json::from_str(&blocked_by_run_ids_json).unwrap_or_default(); let blocked_by_count_val = blocked_by_run_ids.len(); let is_blocked_val = !blocked_by_run_ids.is_empty(); + // Milestone 24: effort — optional TEXT, default None. + let effort_str: Option = row.get(22).unwrap_or(None); + let effort: Option = effort_str.as_deref().and_then(RunEffort::parse); Ok(RunSummary { run_id: row.get(0)?, workspace_id: row.get(1)?, @@ -916,6 +932,7 @@ impl Store { is_blocking: None, blocking_run_count: None, blocking_reason: None, + effort, created_at: row.get(6)?, updated_at: row.get(7)?, }) @@ -1085,6 +1102,7 @@ mod tests { ownership_note: None, due_date: None, blocked_by_run_ids: vec![], + effort: None, created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(), } diff --git a/codex-rs/deterministic-protocol/src/methods.rs b/codex-rs/deterministic-protocol/src/methods.rs index bd2532ae2316..f2dca215ce65 100644 --- a/codex-rs/deterministic-protocol/src/methods.rs +++ b/codex-rs/deterministic-protocol/src/methods.rs @@ -55,6 +55,8 @@ pub enum Method { RunSetDependencies, // Milestone 24: deterministic queue overview RunsQueueOverview, + // Milestone 25: deterministic run effort estimates + RunSetEffort, } impl Method { @@ -107,6 +109,8 @@ impl Method { Self::RunSetDependencies => "run.set_dependencies", // Milestone 24 Self::RunsQueueOverview => "runs.overview", + // Milestone 25 + Self::RunSetEffort => "run.set_effort", } } diff --git a/codex-rs/deterministic-protocol/src/types.rs b/codex-rs/deterministic-protocol/src/types.rs index 7fb06d67e33c..ce34a368cc90 100644 --- a/codex-rs/deterministic-protocol/src/types.rs +++ b/codex-rs/deterministic-protocol/src/types.rs @@ -738,6 +738,10 @@ pub struct RunState { /// An empty list means the run is not blocked. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub blocked_by_run_ids: Vec, + /// Explicit effort bucket for this run (Milestone 24). + /// None means no estimate has been set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effort: Option, pub created_at: String, pub updated_at: String, } @@ -831,6 +835,9 @@ pub struct RunSummary { /// Concise human-readable blocker-impact summary (Milestone 23). #[serde(default, skip_serializing_if = "Option::is_none")] pub blocking_reason: Option, + /// Explicit effort bucket for this run (Milestone 24). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effort: Option, pub created_at: String, pub updated_at: String, } @@ -903,6 +910,15 @@ pub struct RunsListParams { /// When set, return only runs whose blocking_run_count is at least this value (Milestone 23). #[serde(default, skip_serializing_if = "Option::is_none")] pub blocking_run_count_at_least: Option, + /// Filter by exact effort bucket (Milestone 24). + /// When set, only runs with this effort level are returned. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effort_filter: Option, + /// When true, runs are sorted by effort ascending (small → medium → large) + /// before the default pinned-first / updated_at ordering (Milestone 24). + /// Runs with no effort sort last. + #[serde(default)] + pub sort_by_effort: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1067,6 +1083,9 @@ pub struct RunGetResult { /// Concise human-readable blocker-impact summary (Milestone 23). #[serde(default, skip_serializing_if = "Option::is_none")] pub blocking_reason: Option, + /// Explicit effort bucket for this run (Milestone 24). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effort: Option, } // --------------------------------------------------------------------------- @@ -1767,3 +1786,101 @@ pub struct RunSetDependenciesResult { /// Short confirmation message. pub message: String, } + +// --------------------------------------------------------------------------- +// run.set_effort (Milestone 24) +// --------------------------------------------------------------------------- + +/// Deterministic run effort bucket. +/// +/// Three levels from smallest to largest: `small < medium < large`. +/// The default for all new and existing runs is `None` (no estimate set). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RunEffort { + Small, + Medium, + Large, +} + +impl RunEffort { + /// Canonical string representation used on the wire and in SQLite. + pub fn as_str(self) -> &'static str { + match self { + Self::Small => "small", + Self::Medium => "medium", + Self::Large => "large", + } + } + + /// Parse a string into a [`RunEffort`]. + pub fn parse(s: &str) -> Option { + match s { + "small" => Some(Self::Small), + "medium" => Some(Self::Medium), + "large" => Some(Self::Large), + _ => None, + } + } + + /// All valid effort values, ordered from smallest to largest. + pub fn all() -> &'static [RunEffort] { + &[Self::Small, Self::Medium, Self::Large] + } + + /// Numeric sort key: higher value = larger effort (for SQL ORDER BY). + pub fn sort_key(self) -> i64 { + match self { + Self::Small => 0, + Self::Medium => 1, + Self::Large => 2, + } + } +} + +impl fmt::Display for RunEffort { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Parameters for `run.set_effort` — explicit deterministic run effort update. +/// +/// Setting effort is deterministic, explicit, and audited. It updates only +/// the effort field and does not execute work, change status, refresh, replan, +/// reopen, finalize, archive, unarchive, pin, unpin, snooze, reprioritize, +/// assign ownership, or change due dates. +/// +/// Pass `effort: null` (i.e., `Some(None)`) to clear the effort estimate. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RunSetEffortParams { + /// ID of the run whose effort is being set. + pub run_id: String, + /// The new effort level, or `null` to clear. + /// + /// - `Some(Some(effort))` → set effort to this value + /// - `Some(None)` → clear the effort estimate + /// - `None` → no-op (absent from JSON; treated as no change) + pub effort: Option>, +} + +/// Result of `run.set_effort`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RunSetEffortResult { + /// ID of the run whose effort was updated. + pub run_id: String, + /// Current status of the run (unchanged). + pub status: String, + /// The effort value before this update. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_effort: Option, + /// The new effort value, or `None` if cleared. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effort: Option, + /// ISO 8601 timestamp of when this update was applied. + pub updated_at: String, + /// Confirmation message. + pub message: String, +}