diff --git a/README.md b/README.md index 36a69c297..99d029304 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,10 @@ Project contracts are managed outside checkouts under - `WORKFLOW.md` for execution policy The redacted template for a project config lives at `decodex.example.toml`. +Project `[codex].goal_support` controls phase-scoped app-server goals. The default +`"auto"` attempts goal methods and falls back to ordinary Decodex continuation when a +selected app-server lacks them; `"required"` fails fast on missing goal support, and +`"off"` disables goal handling. When a project enables `[codex.accounts]`, the shared ChatGPT account pool is `~/.codex/decodex/accounts.jsonl`; it is global Decodex state, not a project-local file, and project configs do not own an account-pool path override. Set diff --git a/apps/decodex/src/agent.rs b/apps/decodex/src/agent.rs index 2dd32c9e8..7bdef106c 100644 --- a/apps/decodex/src/agent.rs +++ b/apps/decodex/src/agent.rs @@ -10,9 +10,11 @@ mod tracker_tool_bridge; pub(crate) use self::{ app_server::{ ACTIVE_RUN_IDLE_TIMEOUT, AppServerCapabilityPreflightFailure, AppServerDynamicToolFailure, - AppServerRunRequest, AppServerRunResult, AppServerThreadArchiveRequest, - AppServerTurnFailure, TurnContinuationGuard, archive_app_server_thread_after_success, - execute_app_server_run, probe_app_server, protocol_activity_idle_timeout, + AppServerPhaseGoalFailure, AppServerRunRequest, AppServerRunResult, + AppServerThreadArchiveRequest, AppServerTurnFailure, PhaseGoalController, PhaseGoalKind, + PhaseGoalSpec, PhaseGoalTransition, TurnContinuationGuard, + archive_app_server_thread_after_success, execute_app_server_run, probe_app_server, + protocol_activity_idle_timeout, }, codex_accounts::{CodexAccountPool, CodexAccountProvider}, decodex_tool_bridge::{DecodexRunContext, DecodexToolBridge}, diff --git a/apps/decodex/src/agent/app_server.rs b/apps/decodex/src/agent/app_server.rs index 878f0d7ba..fb7ac9b93 100644 --- a/apps/decodex/src/agent/app_server.rs +++ b/apps/decodex/src/agent/app_server.rs @@ -15,7 +15,7 @@ use std::{ }; use color_eyre::Report; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::{self, Value}; use time::OffsetDateTime; @@ -30,10 +30,11 @@ use self::protocol::{ ModelListParams, ModelListResponse, ModelProviderCapabilitiesReadResponse, ModelSummary, PermissionGrantScope, PermissionsRequestApprovalResponse, PluginListParams, PluginListResponse, ProbeDynamicToolHandler, RunOutcome, RuntimeConfigSummary, SkillsListParams, - SkillsListResponse, ThreadArchiveRequest, ThreadResumeRequest, ThreadSessionResponse, - ThreadStartRequest, ThreadStatusChangedNotification, ToolRequestUserInputResponse, - TurnCompletedNotification, TurnError, TurnInterruptRequest, TurnStartRequest, TurnSteerRequest, - UserInput, + SkillsListResponse, ThreadArchiveRequest, ThreadGoal, ThreadGoalClearParams, + ThreadGoalGetParams, ThreadGoalSetParams, ThreadGoalStatus, ThreadGoalUpdatedNotification, + ThreadResumeRequest, ThreadSessionResponse, ThreadStartRequest, + ThreadStatusChangedNotification, ToolRequestUserInputResponse, TurnCompletedNotification, + TurnError, TurnInterruptRequest, TurnStartRequest, TurnSteerRequest, UserInput, }; use crate::{ agent::{ @@ -155,6 +156,154 @@ pub(crate) trait TurnContinuationGuard { } } +pub(crate) trait PhaseGoalController { + fn initial_phase_goal(&self) -> crate::prelude::Result>; + fn phase_goal_completed( + &self, + phase: PhaseGoalKind, + ) -> crate::prelude::Result; +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum PhaseGoalKind { + ImplementToValidationReady, + RepairValidationFailures, + RepairAcceptedReviewFindings, + HandoffEvidence, +} +impl PhaseGoalKind { + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::ImplementToValidationReady => "implement_to_validation_ready", + Self::RepairValidationFailures => "repair_validation_failures", + Self::RepairAcceptedReviewFindings => "repair_accepted_review_findings", + Self::HandoffEvidence => "handoff_evidence", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum PhaseGoalTransition { + Continue(PhaseGoalSpec), + CompleteRun, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AppServerPhaseGoalFailureKind { + Unsupported { method: &'static str }, + MissingTerminalPath { phase: PhaseGoalKind }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AppServerDynamicToolFailureKind { + Protocol, + Tool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum AppServerCapabilityPreflightStatus { + Ok, + Blocked, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum AppServerCapabilityPreflightFailureKind { + MethodFailed { method: &'static str, error: String, timed_out: bool }, + BlockedState, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RequestWaitPhase { + Initialize, + AccountLogin, + ThreadStart, + ThreadResume, + TurnStart, + TurnExecution, +} +impl RequestWaitPhase { + fn label(self) -> &'static str { + match self { + Self::Initialize => "initialize", + Self::AccountLogin => "account/login/start", + Self::ThreadStart => "thread/start", + Self::ThreadResume => "thread/resume", + Self::TurnStart => "turn/start", + Self::TurnExecution => "turn execution", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PhaseGoalSpec { + pub(crate) phase: PhaseGoalKind, + pub(crate) objective: String, + pub(crate) token_budget: Option, +} +impl PhaseGoalSpec { + pub(crate) fn new( + phase: PhaseGoalKind, + objective: impl Into, + token_budget: Option, + ) -> Self { + Self { phase, objective: objective.into(), token_budget } + } +} + +#[derive(Debug)] +pub(crate) struct AppServerPhaseGoalFailure { + kind: AppServerPhaseGoalFailureKind, +} +impl AppServerPhaseGoalFailure { + fn unsupported(method: &'static str) -> Self { + Self { kind: AppServerPhaseGoalFailureKind::Unsupported { method } } + } + + fn missing_terminal_path(phase: PhaseGoalKind) -> Self { + Self { kind: AppServerPhaseGoalFailureKind::MissingTerminalPath { phase } } + } + + pub(crate) fn error_class(&self) -> &'static str { + match self.kind { + AppServerPhaseGoalFailureKind::Unsupported { .. } => + "app_server_phase_goal_unsupported", + AppServerPhaseGoalFailureKind::MissingTerminalPath { .. } => + "phase_goal_terminal_path_missing", + } + } + + pub(crate) fn terminal_next_action(&self, recovery_gate: &str) -> String { + match self.kind { + AppServerPhaseGoalFailureKind::Unsupported { method } => format!( + "select a Codex app-server with `{method}` support or set `codex.goal_support = \"auto\"` or `\"off\"`, restart `decodex serve`, {recovery_gate}" + ), + AppServerPhaseGoalFailureKind::MissingTerminalPath { phase } => format!( + "inspect the retained lane after phase goal `{}` completed without a terminal Decodex path, finish validation/review/handoff or route manual attention, {recovery_gate}", + phase.as_str() + ), + } + } +} + +impl Display for AppServerPhaseGoalFailure { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + match self.kind { + AppServerPhaseGoalFailureKind::Unsupported { method } => { + write!(formatter, "Codex app-server goal method `{method}` is unavailable.") + }, + AppServerPhaseGoalFailureKind::MissingTerminalPath { phase } => write!( + formatter, + "Phase goal `{}` completed without a Decodex terminal completion path.", + phase.as_str() + ), + } + } +} + +impl Error for AppServerPhaseGoalFailure {} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub(crate) struct AppServerCapabilityPreflightReport { checks: Vec, @@ -445,6 +594,8 @@ pub(crate) struct AppServerRunRequest<'a> { pub(crate) command_exec_health_check: Option, pub(crate) dynamic_tool_handler: Option<&'a dyn DynamicToolHandler>, pub(crate) continuation_guard: Option<&'a dyn TurnContinuationGuard>, + pub(crate) phase_goal_controller: Option<&'a dyn PhaseGoalController>, + pub(crate) phase_goal_required: bool, pub(crate) codex_account_provider: Option<&'a dyn CodexAccountProvider>, } @@ -506,6 +657,13 @@ pub(crate) struct AppServerRunResult { pub(crate) event_count: i64, pub(crate) final_output: String, pub(crate) continuation_pending: bool, + pub(crate) phase_goal_status: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub(crate) struct PhaseGoalRunStatus { + pub(crate) phase: PhaseGoalKind, + pub(crate) status: String, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -757,6 +915,8 @@ impl ProtocolActivityAccumulator { struct RunRecorder<'a> { state_store: &'a StateStore, + project_id: &'a str, + issue_id: &'a str, run_id: &'a str, attempt_number: i64, activity_marker_path: Option<&'a PathBuf>, @@ -767,14 +927,35 @@ struct RunRecorder<'a> { protocol_activity: ProtocolActivityAccumulator, } impl<'a> RunRecorder<'a> { + #[cfg(test)] fn new( state_store: &'a StateStore, run_id: &'a str, attempt_number: i64, activity_marker_path: Option<&'a PathBuf>, + ) -> Self { + Self::new_with_context( + state_store, + "unknown", + "unknown", + run_id, + attempt_number, + activity_marker_path, + ) + } + + fn new_with_context( + state_store: &'a StateStore, + project_id: &'a str, + issue_id: &'a str, + run_id: &'a str, + attempt_number: i64, + activity_marker_path: Option<&'a PathBuf>, ) -> Self { Self { state_store, + project_id, + issue_id, run_id, attempt_number, activity_marker_path, @@ -786,6 +967,14 @@ impl<'a> RunRecorder<'a> { } } + fn project_id(&self) -> &str { + self.project_id + } + + fn issue_id(&self) -> &str { + self.issue_id + } + fn mark_activity(&self) -> crate::prelude::Result<()> { if let Some(marker_path) = self.activity_marker_path { write_activity_marker_best_effort(marker_path, self.run_id, self.attempt_number); @@ -907,6 +1096,12 @@ struct TurnLoopResult { turn_count: u32, final_output: String, continuation_pending: bool, + phase_goal_status: Option, +} + +struct PhaseGoalRuntime<'a> { + controller: &'a dyn PhaseGoalController, + active_goal: PhaseGoalSpec, } #[derive(Clone, Copy)] @@ -993,47 +1188,6 @@ impl DynamicToolFailureDiagnostic { } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum AppServerDynamicToolFailureKind { - Protocol, - Tool, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "snake_case")] -enum AppServerCapabilityPreflightStatus { - Ok, - Blocked, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -enum AppServerCapabilityPreflightFailureKind { - MethodFailed { method: &'static str, error: String, timed_out: bool }, - BlockedState, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum RequestWaitPhase { - Initialize, - AccountLogin, - ThreadStart, - ThreadResume, - TurnStart, - TurnExecution, -} -impl RequestWaitPhase { - fn label(self) -> &'static str { - match self { - Self::Initialize => "initialize", - Self::AccountLogin => "account/login/start", - Self::ThreadStart => "thread/start", - Self::ThreadResume => "thread/resume", - Self::TurnStart => "turn/start", - Self::TurnExecution => "turn execution", - } - } -} - pub(crate) fn protocol_activity_idle_timeout( protocol_activity: Option<&state::ProtocolActivitySummary>, base_timeout: Duration, @@ -1138,6 +1292,8 @@ pub(crate) fn probe_app_server(listen: &str) -> crate::prelude::Result &'static str { fn protocol_activity_detail(event_type: &str, payload_value: Option<&Value>) -> Option { let normalized = event_type.to_ascii_lowercase(); + if matches!(event_type, "thread/goal/set" | "thread/goal/get" | "thread/goal/updated") { + return phase_goal_activity_detail(payload_value); + } if event_type == "thread/status/changed" { return payload_value.and_then(|value| { string_at_paths(value, &[&["params", "status", "type"], &["status", "type"]]) @@ -1448,6 +1607,20 @@ fn protocol_activity_detail(event_type: &str, payload_value: Option<&Value>) -> None } +fn phase_goal_activity_detail(payload_value: Option<&Value>) -> Option { + let value = payload_value?; + let status = string_at_paths( + value, + &[&["payload", "status"], &["params", "goal", "status"], &["goal", "status"], &["status"]], + )?; + let phase = string_at_paths(value, &[&["phase"], &["payload", "phase"]]); + + Some(match phase { + Some(phase) => format!("{phase}/{status}"), + None => status, + }) +} + fn protocol_steer_detail(payload_value: Option<&Value>) -> Option { let value = payload_value?; let outcome = @@ -2252,8 +2425,10 @@ fn execute_app_server_run_inner( request: &AppServerRunRequest<'_>, state_store: &StateStore, ) -> crate::prelude::Result { - let mut recorder = RunRecorder::new( + let mut recorder = RunRecorder::new_with_context( state_store, + &request.project_id, + &request.issue_id, &request.run_id, request.attempt_number, request.activity_marker_path.as_ref(), @@ -2325,6 +2500,7 @@ fn execute_app_server_run_inner( event_count: state_store.event_count(&request.run_id)?, final_output: turn_result.final_output, continuation_pending: turn_result.continuation_pending, + phase_goal_status: turn_result.phase_goal_status, }) } @@ -3220,6 +3396,12 @@ fn execute_turn_loop( ) -> crate::prelude::Result { let mut next_input = request.user_input.clone(); let mut turn_count = 0_u32; + let mut phase_goal_runtime = + initialize_phase_goal_runtime(client, recorder, request, thread_id)?; + let mut phase_goal_status = phase_goal_runtime.as_ref().map(|runtime| PhaseGoalRunStatus { + phase: runtime.active_goal.phase, + status: ThreadGoalStatus::Active.as_str().to_owned(), + }); loop { let turn_id = start_turn_for_run( @@ -3242,17 +3424,32 @@ fn execute_turn_loop( let final_turn_id = outcome.turn_id; let final_output = outcome.final_output; - if let Some(continuation_pending) = - resolve_turn_completion(request, turn_count, &final_output)? - { + if let Some((continuation_pending, observed_phase_goal_status)) = resolve_turn_completion( + client, + recorder, + request, + &mut phase_goal_runtime, + thread_id, + turn_count, + &final_output, + )? { + if observed_phase_goal_status.is_some() { + phase_goal_status = observed_phase_goal_status; + } + return Ok(TurnLoopResult { turn_id: final_turn_id, turn_count, final_output, continuation_pending, + phase_goal_status, }); } + phase_goal_status = phase_goal_runtime.as_ref().map(|runtime| PhaseGoalRunStatus { + phase: runtime.active_goal.phase, + status: ThreadGoalStatus::Active.as_str().to_owned(), + }); next_input = request.continuation_user_input.clone().unwrap_or_else(|| request.user_input.clone()); } @@ -3289,11 +3486,116 @@ fn start_turn_for_run( } fn resolve_turn_completion( + client: &mut AppServerClient, + recorder: &mut RunRecorder<'_>, + request: &AppServerRunRequest<'_>, + phase_goal_runtime: &mut Option>, + thread_id: &str, + turn_count: u32, + final_output: &str, +) -> crate::prelude::Result)>> { + let completion_status = classify_turn_completion(request.dynamic_tool_handler, final_output)?; + + if phase_goal_runtime.is_some() { + let observed_goal_result = { + let runtime = phase_goal_runtime + .as_ref() + .expect("phase goal runtime should be present after is_some check"); + + get_thread_phase_goal(client, recorder, thread_id, runtime) + }; + let observed_goal = match observed_goal_result { + Ok(goal) => goal, + Err(error) if !request.phase_goal_required && app_server_method_not_found(&error) => { + record_phase_goal_unavailable(recorder, "thread/goal/get", &error)?; + + *phase_goal_runtime = None; + + return resolve_turn_completion_without_phase_goal( + request, + turn_count, + completion_status, + final_output, + ) + .map(|result| result.map(|continuation_pending| (continuation_pending, None))); + }, + Err(error) if request.phase_goal_required && app_server_method_not_found(&error) => { + return Err(Report::new(AppServerPhaseGoalFailure::unsupported("thread/goal/get")) + .wrap_err(error)); + }, + Err(error) => return Err(error), + }; + let runtime = phase_goal_runtime + .as_mut() + .expect("phase goal runtime should still be present after goal status read"); + let observed_status = PhaseGoalRunStatus { + phase: runtime.active_goal.phase, + status: observed_goal.status.as_str().to_owned(), + }; + + if observed_goal.status == ThreadGoalStatus::Complete { + let transition = runtime.controller.phase_goal_completed(runtime.active_goal.phase)?; + + record_phase_goal_completed(recorder, runtime.active_goal.phase, &observed_goal)?; + + match transition { + PhaseGoalTransition::Continue(next_goal) => { + if completion_status == TurnCompletionStatus::Complete { + return Ok(Some((false, Some(observed_status)))); + } + + set_thread_phase_goal(client, recorder, thread_id, &next_goal)?; + + runtime.active_goal = next_goal; + + if turn_count >= request.max_turns { + return Ok(Some((true, Some(observed_status)))); + } + if continuation_boundary_reached(request.continuation_guard, turn_count)? { + return Ok(Some((true, Some(observed_status)))); + } + + return Ok(None); + }, + PhaseGoalTransition::CompleteRun => { + if completion_status == TurnCompletionStatus::Complete { + clear_thread_phase_goal_best_effort(client, recorder, thread_id); + + return Ok(Some((false, Some(observed_status)))); + } + + return Err(Report::new(AppServerPhaseGoalFailure::missing_terminal_path( + runtime.active_goal.phase, + ))); + }, + } + } + if completion_status == TurnCompletionStatus::Complete { + clear_thread_phase_goal_best_effort(client, recorder, thread_id); + + return Ok(Some((false, Some(observed_status)))); + } + if turn_count >= request.max_turns { + return Ok(Some((true, Some(observed_status)))); + } + if continuation_boundary_reached(request.continuation_guard, turn_count)? { + return Ok(Some((true, Some(observed_status)))); + } + + return Ok(None); + } + + resolve_turn_completion_without_phase_goal(request, turn_count, completion_status, final_output) + .map(|result| result.map(|continuation_pending| (continuation_pending, None))) +} + +fn resolve_turn_completion_without_phase_goal( request: &AppServerRunRequest<'_>, turn_count: u32, + completion_status: TurnCompletionStatus, final_output: &str, ) -> crate::prelude::Result> { - match classify_turn_completion(request.dynamic_tool_handler, final_output)? { + match completion_status { TurnCompletionStatus::Complete => Ok(Some(false)), TurnCompletionStatus::Continue => { if request.max_turns <= 1 { @@ -3314,6 +3616,180 @@ fn resolve_turn_completion( } } +fn initialize_phase_goal_runtime<'a>( + client: &mut AppServerClient, + recorder: &mut RunRecorder<'_>, + request: &'a AppServerRunRequest<'_>, + thread_id: &str, +) -> crate::prelude::Result>> { + let Some(controller) = request.phase_goal_controller else { + return Ok(None); + }; + let Some(active_goal) = controller.initial_phase_goal()? else { + return Ok(None); + }; + + match set_thread_phase_goal(client, recorder, thread_id, &active_goal) { + Ok(()) => Ok(Some(PhaseGoalRuntime { controller, active_goal })), + Err(error) if !request.phase_goal_required && app_server_method_not_found(&error) => { + record_phase_goal_unavailable(recorder, "thread/goal/set", &error)?; + + Ok(None) + }, + Err(error) if request.phase_goal_required && app_server_method_not_found(&error) => + Err(Report::new(AppServerPhaseGoalFailure::unsupported("thread/goal/set")) + .wrap_err(error)), + Err(error) => Err(error), + } +} + +fn set_thread_phase_goal( + client: &mut AppServerClient, + recorder: &mut RunRecorder<'_>, + thread_id: &str, + goal: &PhaseGoalSpec, +) -> crate::prelude::Result<()> { + let response = client.set_thread_goal(ThreadGoalSetParams { + thread_id: thread_id.to_owned(), + objective: Some(goal.objective.clone()), + status: Some(ThreadGoalStatus::Active), + token_budget: goal.token_budget, + })?; + let payload = serde_json::json!({ + "phase": goal.phase.as_str(), + "status": response.goal.status.as_str(), + "threadId": response.goal.thread_id, + "tokenBudget": response.goal.token_budget, + "tokensUsed": response.goal.tokens_used, + "timeUsedSeconds": response.goal.time_used_seconds, + }); + + recorder.record("thread/goal/set", &payload.to_string())?; + + record_phase_goal_private_event(recorder, "phase_goal_set", goal.phase, &payload) +} + +fn get_thread_phase_goal( + client: &mut AppServerClient, + recorder: &mut RunRecorder<'_>, + thread_id: &str, + runtime: &PhaseGoalRuntime<'_>, +) -> crate::prelude::Result { + let response = + client.get_thread_goal(ThreadGoalGetParams { thread_id: thread_id.to_owned() })?; + let goal = response.goal.ok_or_else(|| { + Report::new(AppServerPhaseGoalFailure::missing_terminal_path(runtime.active_goal.phase)) + .wrap_err("Codex app-server returned no active phase goal for a goal-controlled lane.") + })?; + let payload = serde_json::json!({ + "phase": runtime.active_goal.phase.as_str(), + "status": goal.status.as_str(), + "threadId": goal.thread_id, + "tokenBudget": goal.token_budget, + "tokensUsed": goal.tokens_used, + "timeUsedSeconds": goal.time_used_seconds, + }); + + recorder.record("thread/goal/get", &payload.to_string())?; + + record_phase_goal_private_event( + recorder, + "phase_goal_status", + runtime.active_goal.phase, + &payload, + )?; + + Ok(goal) +} + +fn clear_thread_phase_goal_best_effort( + client: &mut AppServerClient, + recorder: &mut RunRecorder<'_>, + thread_id: &str, +) { + match client.clear_thread_goal(ThreadGoalClearParams { thread_id: thread_id.to_owned() }) { + Ok(response) => { + let payload = serde_json::json!({ "cleared": response.cleared, "threadId": thread_id }); + + if let Err(error) = recorder.record("thread/goal/clear", &payload.to_string()) { + tracing::warn!(?error, "Failed to record app-server goal clear response."); + } + }, + Err(error) => + tracing::warn!(?error, "Failed to clear app-server phase goal after terminal path."), + } +} + +fn record_phase_goal_completed( + recorder: &mut RunRecorder<'_>, + phase: PhaseGoalKind, + goal: &ThreadGoal, +) -> crate::prelude::Result<()> { + let payload = serde_json::json!({ + "schema": "decodex.phase_goal_signal/1", + "phase": phase.as_str(), + "signal": "goal_complete", + "threadId": goal.thread_id, + "status": goal.status.as_str(), + "tokenBudget": goal.token_budget, + "tokensUsed": goal.tokens_used, + "timeUsedSeconds": goal.time_used_seconds, + }); + + record_phase_goal_private_event(recorder, "phase_goal_completed", phase, &payload) +} + +fn record_phase_goal_unavailable( + recorder: &mut RunRecorder<'_>, + method: &'static str, + error: &Report, +) -> crate::prelude::Result<()> { + recorder.state_store.append_private_execution_event( + recorder.project_id(), + recorder.issue_id(), + recorder.run_id, + recorder.attempt_number, + "phase_goal_unavailable", + serde_json::json!({ + "schema": "decodex.phase_goal_signal/1", + "signal": "goal_unavailable", + "method": method, + "fallback": "no_goal", + "error": error.to_string(), + }), + )?; + + Ok(()) +} + +fn record_phase_goal_private_event( + recorder: &mut RunRecorder<'_>, + event_type: &str, + phase: PhaseGoalKind, + payload: &Value, +) -> crate::prelude::Result<()> { + recorder.state_store.append_private_execution_event( + recorder.project_id(), + recorder.issue_id(), + recorder.run_id, + recorder.attempt_number, + event_type, + serde_json::json!({ + "schema": "decodex.phase_goal_signal/1", + "phase": phase.as_str(), + "payload": payload, + }), + )?; + + Ok(()) +} + +fn app_server_method_not_found(error: &Report) -> bool { + let text = error.to_string().to_lowercase(); + + text.contains("-32601") || text.contains("method not found") +} + fn build_thread_start_request( request: &AppServerRunRequest<'_>, ) -> crate::prelude::Result { @@ -3652,6 +4128,19 @@ fn handle_turn_execution_notification( payload.turn.status ); }, + "thread/goal/updated" => { + let payload: ThreadGoalUpdatedNotification = + serde_json::from_value(notification.params.clone())?; + + if payload.thread_id != target_thread_id + || payload.turn_id.as_deref().is_some_and(|turn_id| turn_id != target_turn_id) + { + return Ok(None); + } + + let _status = payload.goal.status; + }, + "thread/goal/cleared" => {}, _ => {}, } diff --git a/apps/decodex/src/agent/app_server/protocol.rs b/apps/decodex/src/agent/app_server/protocol.rs index fbc381333..3bd6df972 100644 --- a/apps/decodex/src/agent/app_server/protocol.rs +++ b/apps/decodex/src/agent/app_server/protocol.rs @@ -153,6 +153,27 @@ impl AppServerClient { self.connection.request("thread/archive", ¶ms, REQUEST_TIMEOUT) } + pub(super) fn set_thread_goal( + &mut self, + params: ThreadGoalSetParams, + ) -> crate::prelude::Result { + self.connection.request("thread/goal/set", ¶ms, REQUEST_TIMEOUT) + } + + pub(super) fn get_thread_goal( + &mut self, + params: ThreadGoalGetParams, + ) -> crate::prelude::Result { + self.connection.request("thread/goal/get", ¶ms, REQUEST_TIMEOUT) + } + + pub(super) fn clear_thread_goal( + &mut self, + params: ThreadGoalClearParams, + ) -> crate::prelude::Result { + self.connection.request("thread/goal/clear", ¶ms, REQUEST_TIMEOUT) + } + #[allow(dead_code)] pub(super) fn start_turn( &mut self, @@ -390,6 +411,84 @@ pub(super) struct ThreadArchiveRequest { #[derive(Debug, Deserialize)] pub(super) struct ThreadArchiveResponse {} +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) enum ThreadGoalStatus { + Active, + Paused, + Blocked, + UsageLimited, + BudgetLimited, + Complete, +} +impl ThreadGoalStatus { + pub(super) const fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::Paused => "paused", + Self::Blocked => "blocked", + Self::UsageLimited => "usageLimited", + Self::BudgetLimited => "budgetLimited", + Self::Complete => "complete", + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct ThreadGoalSetParams { + pub(super) thread_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) objective: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) token_budget: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct ThreadGoalGetParams { + pub(super) thread_id: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct ThreadGoalClearParams { + pub(super) thread_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct ThreadGoal { + #[allow(dead_code)] + pub(super) created_at: i64, + #[allow(dead_code)] + pub(super) objective: String, + pub(super) status: ThreadGoalStatus, + pub(super) thread_id: String, + pub(super) time_used_seconds: i64, + pub(super) token_budget: Option, + pub(super) tokens_used: i64, + #[allow(dead_code)] + pub(super) updated_at: i64, +} + +#[derive(Debug, Deserialize)] +pub(super) struct ThreadGoalSetResponse { + pub(super) goal: ThreadGoal, +} + +#[derive(Debug, Deserialize)] +pub(super) struct ThreadGoalGetResponse { + pub(super) goal: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct ThreadGoalClearResponse { + pub(super) cleared: bool, +} + #[derive(Clone, Debug, Deserialize)] pub(super) struct ThreadSessionResponse { pub(super) thread: Thread, @@ -758,6 +857,14 @@ pub(super) struct TurnCompletedNotification { pub(super) turn: TurnStatusPayload, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct ThreadGoalUpdatedNotification { + pub(super) goal: ThreadGoal, + pub(super) thread_id: String, + pub(super) turn_id: Option, +} + #[derive(Debug)] pub(super) struct ErrorNotification { pub(super) error: TurnError, diff --git a/apps/decodex/src/agent/app_server/tests.rs b/apps/decodex/src/agent/app_server/tests.rs index fe574cb5c..8a2accd31 100644 --- a/apps/decodex/src/agent/app_server/tests.rs +++ b/apps/decodex/src/agent/app_server/tests.rs @@ -15,12 +15,14 @@ use crate::{ agent::{ app_server::{ APP_SERVER_SCHEMA_REQUIRED_MARKERS, AppServerCapabilityPreflightFailure, - AppServerCapabilityPreflightReport, AppServerDynamicToolFailure, AppServerRunResult, - AppServerThreadArchiveRequest, AppServerTurnFailure, CommandExecHealthCheck, - CommandExecResponse, EffectiveThreadConfig, InitializeResponse, - ModelProviderCapabilitiesReadResponse, PluginListResponse, ProbeDynamicToolHandler, - REQUEST_TIMEOUT, RequestWaitPhase, RunRecorder, RuntimeConfigSummary, - SkillsListResponse, TurnContinuationGuard, UserInput, + AppServerCapabilityPreflightReport, AppServerDynamicToolFailure, + AppServerPhaseGoalFailure, AppServerRunResult, AppServerThreadArchiveRequest, + AppServerTurnFailure, CommandExecHealthCheck, CommandExecResponse, + EffectiveThreadConfig, InitializeResponse, ModelProviderCapabilitiesReadResponse, + PhaseGoalController, PhaseGoalKind, PhaseGoalSpec, PhaseGoalTransition, + PluginListResponse, ProbeDynamicToolHandler, REQUEST_TIMEOUT, RequestWaitPhase, + RunRecorder, RuntimeConfigSummary, SkillsListResponse, TurnContinuationGuard, + UserInput, }, json_rpc::{ AppServerHomePreflightFailure, AppServerOutputTimeout, AppServerProcessEnv, @@ -40,6 +42,164 @@ use crate::{ test_support::TestEnvVarGuard, }; +const PHASE_GOAL_FAKE_CODEX_SCRIPT_TEMPLATE: &str = r#"#!/usr/bin/env python3 +import json +import os +import sys + +TURN_OUTPUTS = __TURN_OUTPUTS__ +GOAL_STATUSES = __GOAL_STATUSES__ +UNSUPPORTED_GOAL_METHODS = __UNSUPPORTED_GOAL_METHODS__ + +goal = { + "objective": "", + "tokenBudget": None, +} +turn_count = 0 +goal_get_count = 0 + +def send(value): + print(json.dumps(value), flush=True) + +def reply(message_id, result): + send({"id": message_id, "result": result}) + +def method_not_found(message_id, method): + send({"id": message_id, "error": { + "code": -32601, + "message": "Method not found: " + str(method), + }}) + +def goal_payload(status): + return { + "createdAt": 1, + "objective": goal["objective"], + "status": status, + "threadId": "thread-1", + "timeUsedSeconds": 0, + "tokenBudget": goal["tokenBudget"], + "tokensUsed": 0, + "updatedAt": 1, + } + +for line in sys.stdin: + message = json.loads(line) + method = message.get("method") + message_id = message.get("id") + params = message.get("params") or {} + + if method == "initialize": + reply(message_id, { + "userAgent": "codex-cli 0.136.0", + "codexHome": os.environ["CODEX_HOME"], + "platformFamily": "unix", + "platformOs": "macos", + }) + elif method == "initialized": + continue + elif method == "config/read": + reply(message_id, {"config": { + "model": "gpt-5.5", + "model_provider": "openai", + "approval_policy": {"type": "never"}, + "sandbox_mode": {"type": "dangerFullAccess"}, + }}) + elif method == "model/list": + reply(message_id, {"data": [{ + "id": "gpt-5.5", + "model": "gpt-5.5", + "displayName": "GPT-5.5", + "isDefault": True, + "hidden": False, + }], "nextCursor": None}) + elif method == "modelProvider/capabilities/read": + reply(message_id, {"imageGeneration": True, "namespaceTools": True, "webSearch": True}) + elif method == "skills/list": + cwd = params.get("cwds", [""])[0] + reply(message_id, {"data": [{"cwd": cwd, "errors": [], "skills": [{ + "enabled": True, + "name": "fake-skill", + "scope": "user", + }]}]}) + elif method == "plugin/list": + reply(message_id, {"marketplaces": [{"name": "fake", "plugins": [{ + "enabled": True, + "id": "fake-plugin", + "installed": True, + "name": "Fake Plugin", + }]}], "marketplaceLoadErrors": []}) + elif method == "mcpServerStatus/list": + reply(message_id, {"data": [], "nextCursor": None}) + elif method == "thread/start": + reply(message_id, { + "thread": {"id": "thread-1"}, + "model": "gpt-5.5", + "modelProvider": "openai", + "serviceTier": None, + "cwd": params.get("cwd"), + "instructionSources": [], + "approvalPolicy": {"type": "never"}, + "approvalsReviewer": "user", + "sandbox": {"type": "dangerFullAccess"}, + "reasoningEffort": None, + }) + elif method == "thread/goal/set": + if UNSUPPORTED_GOAL_METHODS: + method_not_found(message_id, method) + else: + goal["objective"] = params.get("objective") or "" + goal["tokenBudget"] = params.get("tokenBudget") + reply(message_id, {"goal": goal_payload("active")}) + elif method == "thread/goal/get": + if UNSUPPORTED_GOAL_METHODS: + method_not_found(message_id, method) + else: + if GOAL_STATUSES: + status = GOAL_STATUSES[min(goal_get_count, len(GOAL_STATUSES) - 1)] + else: + status = "active" + goal_get_count += 1 + reply(message_id, {"goal": None if status == "none" else goal_payload(status)}) + elif method == "thread/goal/clear": + if UNSUPPORTED_GOAL_METHODS: + method_not_found(message_id, method) + else: + reply(message_id, {"cleared": True}) + elif method == "turn/start": + turn_count += 1 + turn_id = "turn-" + str(turn_count) + if TURN_OUTPUTS: + output = TURN_OUTPUTS[min(turn_count - 1, len(TURN_OUTPUTS) - 1)] + else: + output = "DONE" + reply(message_id, {"turn": {"id": turn_id, "status": "running", "error": None}}) + send({"method": "thread/status/changed", "params": { + "threadId": "thread-1", + "status": {"type": "active", "activeFlags": []}, + }}) + send({"method": "turn/started", "params": { + "threadId": "thread-1", + "turn": {"id": turn_id, "status": "running", "error": None}, + }}) + if not UNSUPPORTED_GOAL_METHODS: + send({"method": "thread/goal/updated", "params": { + "threadId": "thread-1", + "turnId": turn_id, + "goal": goal_payload("active"), + }}) + send({"method": "item/completed", "params": { + "threadId": "thread-1", + "turnId": turn_id, + "item": {"type": "agentMessage", "text": output}, + }}) + send({"method": "turn/completed", "params": { + "threadId": "thread-1", + "turn": {"id": turn_id, "status": "completed", "error": None}, + }}) + else: + method_not_found(message_id, method) +"#; + struct RejectingCompletionHandler; impl DynamicToolHandler for RejectingCompletionHandler { fn tool_specs(&self) -> Vec { @@ -229,6 +389,54 @@ impl TurnContinuationGuard for LiveResumeBoundaryGuard { } } +struct ContinueTokenCompletionHandler; +impl DynamicToolHandler for ContinueTokenCompletionHandler { + fn tool_specs(&self) -> Vec { + Vec::new() + } + + fn handle_call(&self, _tool_name: &str, _arguments: Value) -> DynamicToolCallResponse { + DynamicToolCallResponse::failure(String::from("unused")) + } + + fn classify_turn_completion(&self, final_output: &str) -> Result { + Ok(if final_output.trim() == "CONTINUE" { + TurnCompletionStatus::Continue + } else { + TurnCompletionStatus::Complete + }) + } +} + +struct TestPhaseGoalController { + initial_phase: PhaseGoalKind, +} +impl TestPhaseGoalController { + fn new(initial_phase: PhaseGoalKind) -> Self { + Self { initial_phase } + } +} + +impl PhaseGoalController for TestPhaseGoalController { + fn initial_phase_goal(&self) -> Result> { + Ok(Some(PhaseGoalSpec::new(self.initial_phase, "test phase goal", None))) + } + + fn phase_goal_completed(&self, phase: PhaseGoalKind) -> Result { + Ok(match phase { + PhaseGoalKind::ImplementToValidationReady + | PhaseGoalKind::RepairValidationFailures + | PhaseGoalKind::RepairAcceptedReviewFindings => + PhaseGoalTransition::Continue(PhaseGoalSpec::new( + PhaseGoalKind::HandoffEvidence, + "prepare handoff evidence", + None, + )), + PhaseGoalKind::HandoffEvidence => PhaseGoalTransition::CompleteRun, + }) + } +} + fn notification_message(method: &str, params: Value) -> WireMessage { WireMessage { raw: params.to_string(), @@ -278,6 +486,7 @@ fn probe_result_shape_is_stable() { event_count: 3, final_output: String::from("PROBE_OK"), continuation_pending: false, + phase_goal_status: None, }; assert_eq!(result.final_output, "PROBE_OK"); @@ -466,6 +675,8 @@ fn minimal_run_request<'a>() -> super::AppServerRunRequest<'a> { command_exec_health_check: None, dynamic_tool_handler: None, continuation_guard: None, + phase_goal_controller: None, + phase_goal_required: false, codex_account_provider: None, } } @@ -662,6 +873,196 @@ for line in sys.stdin: "# } +fn phase_goal_fake_codex_script( + turn_outputs: &[&str], + goal_statuses: &[&str], + unsupported_goal_methods: bool, +) -> String { + let outputs_json = serde_json::to_string(turn_outputs).expect("turn outputs should serialize"); + let statuses_json = + serde_json::to_string(goal_statuses).expect("goal statuses should serialize"); + let unsupported_goal = if unsupported_goal_methods { "True" } else { "False" }; + + PHASE_GOAL_FAKE_CODEX_SCRIPT_TEMPLATE + .replace("__TURN_OUTPUTS__", &outputs_json) + .replace("__GOAL_STATUSES__", &statuses_json) + .replace("__UNSUPPORTED_GOAL_METHODS__", unsupported_goal) +} + +fn execute_phase_goal_fake_app_server<'a, F>( + script: String, + configure: F, +) -> (Result, StateStore) +where + F: FnOnce(&mut super::AppServerRunRequest<'a>), +{ + let temp_dir = TempDir::new().expect("tempdir should create"); + let worktree_path = temp_dir.path().join("worktree"); + let fake_bin_dir = install_fake_codex_script(&temp_dir, &script); + let path_env = env::var("PATH").unwrap_or_default(); + let _path_guard = + TestEnvVarGuard::set("PATH", &format!("{}:{path_env}", fake_bin_dir.display())); + let state_store = StateStore::open_in_memory().expect("state store should open"); + let mut request = minimal_run_request(); + + fs::create_dir_all(&worktree_path).expect("worktree directory should create"); + + request.run_id = String::from("phase-goal-run"); + request.issue_id = String::from("phase-goal-issue"); + request.cwd = worktree_path.display().to_string(); + request.timeout = Duration::from_secs(5); + + configure(&mut request); + + let result = super::execute_app_server_run(&request, &state_store); + + (result, state_store) +} + +fn private_phase_goal_events(state_store: &StateStore, event_type: &str) -> Vec { + state_store + .list_private_execution_events("test-project", "phase-goal-issue", "phase-goal-run", 1) + .expect("private phase goal events should load") + .into_iter() + .filter(|event| event.event_type() == event_type) + .map(|event| event.payload().clone()) + .collect() +} + +#[test] +fn phase_goal_auto_mode_falls_back_when_app_server_goal_methods_are_missing() { + let controller = TestPhaseGoalController::new(PhaseGoalKind::ImplementToValidationReady); + let script = phase_goal_fake_codex_script(&["DONE"], &[], true); + let (result, state_store) = execute_phase_goal_fake_app_server(script, |request| { + request.phase_goal_controller = Some(&controller); + request.phase_goal_required = false; + }); + let result = result.expect("auto mode should fall back to no-goal execution"); + let unavailable_events = private_phase_goal_events(&state_store, "phase_goal_unavailable"); + + assert_eq!(result.final_output, "DONE"); + assert_eq!(result.phase_goal_status, None); + assert_eq!(unavailable_events.len(), 1); + assert_eq!(unavailable_events[0]["method"], "thread/goal/set"); + assert_eq!(unavailable_events[0]["fallback"], "no_goal"); +} + +#[test] +fn phase_goal_required_mode_fails_when_goal_methods_are_missing() { + let controller = TestPhaseGoalController::new(PhaseGoalKind::ImplementToValidationReady); + let script = phase_goal_fake_codex_script(&["DONE"], &[], true); + let (result, _state_store) = execute_phase_goal_fake_app_server(script, |request| { + request.phase_goal_controller = Some(&controller); + request.phase_goal_required = true; + }); + let error = result.expect_err("required goal mode should fail on missing app-server support"); + let failure = error + .downcast_ref::() + .expect("missing goal support should be a typed phase-goal failure"); + + assert_eq!(failure.error_class(), "app_server_phase_goal_unsupported"); + assert!(error.to_string().contains("thread/goal/set")); +} + +#[test] +fn phase_goal_complete_runs_validation_transition_before_handoff_goal() { + let handler = ContinueTokenCompletionHandler; + let controller = TestPhaseGoalController::new(PhaseGoalKind::ImplementToValidationReady); + let script = + phase_goal_fake_codex_script(&["CONTINUE", "DONE"], &["complete", "complete"], false); + let (result, state_store) = execute_phase_goal_fake_app_server(script, |request| { + request.max_turns = 3; + request.dynamic_tool_handler = Some(&handler); + request.phase_goal_controller = Some(&controller); + }); + let result = result.expect("completed phase goal should advance to handoff evidence goal"); + let completed_events = private_phase_goal_events(&state_store, "phase_goal_completed"); + let goal_set_events = private_phase_goal_events(&state_store, "phase_goal_set"); + + assert_eq!(result.turn_count, 2); + assert_eq!(result.final_output, "DONE"); + assert_eq!( + result.phase_goal_status, + Some(super::PhaseGoalRunStatus { + phase: PhaseGoalKind::HandoffEvidence, + status: String::from("complete"), + }) + ); + assert_eq!( + completed_events.iter().filter_map(|event| event["phase"].as_str()).collect::>(), + vec!["implement_to_validation_ready", "handoff_evidence"] + ); + assert_eq!(goal_set_events.len(), 2); + assert_eq!(goal_set_events[1]["phase"], "handoff_evidence"); +} + +#[test] +fn still_active_phase_goal_continues_same_thread_until_terminal_output() { + let handler = ContinueTokenCompletionHandler; + let controller = TestPhaseGoalController::new(PhaseGoalKind::ImplementToValidationReady); + let script = phase_goal_fake_codex_script(&["CONTINUE", "DONE"], &["active", "active"], false); + let (result, _state_store) = execute_phase_goal_fake_app_server(script, |request| { + request.max_turns = 2; + request.dynamic_tool_handler = Some(&handler); + request.phase_goal_controller = Some(&controller); + }); + let result = result.expect("active goal should allow another bounded turn"); + + assert_eq!(result.turn_count, 2); + assert_eq!(result.turn_id, "turn-2"); + assert_eq!(result.final_output, "DONE"); + assert!(!result.continuation_pending); + assert_eq!( + result.phase_goal_status, + Some(super::PhaseGoalRunStatus { + phase: PhaseGoalKind::ImplementToValidationReady, + status: String::from("active"), + }) + ); +} + +#[test] +fn still_active_phase_goal_stops_at_max_turns_with_continuation_pending() { + let handler = ContinueTokenCompletionHandler; + let controller = TestPhaseGoalController::new(PhaseGoalKind::ImplementToValidationReady); + let script = phase_goal_fake_codex_script(&["CONTINUE"], &["active"], false); + let (result, _state_store) = execute_phase_goal_fake_app_server(script, |request| { + request.max_turns = 1; + request.dynamic_tool_handler = Some(&handler); + request.phase_goal_controller = Some(&controller); + }); + let result = result.expect("active goal should exit cleanly at max_turns"); + + assert_eq!(result.turn_count, 1); + assert!(result.continuation_pending); + assert_eq!( + result.phase_goal_status, + Some(super::PhaseGoalRunStatus { + phase: PhaseGoalKind::ImplementToValidationReady, + status: String::from("active"), + }) + ); +} + +#[test] +fn phase_goal_handoff_complete_without_terminal_completion_is_invalid() { + let handler = ContinueTokenCompletionHandler; + let controller = TestPhaseGoalController::new(PhaseGoalKind::HandoffEvidence); + let script = phase_goal_fake_codex_script(&["CONTINUE"], &["complete"], false); + let (result, _state_store) = execute_phase_goal_fake_app_server(script, |request| { + request.max_turns = 2; + request.dynamic_tool_handler = Some(&handler); + request.phase_goal_controller = Some(&controller); + }); + let error = result.expect_err("handoff goal completion cannot replace terminal path"); + let failure = error + .downcast_ref::() + .expect("missing terminal path should be a typed phase-goal failure"); + + assert_eq!(failure.error_class(), "phase_goal_terminal_path_missing"); + assert!(error.to_string().contains("handoff_evidence")); +} + #[test] fn archive_thread_after_success_calls_app_server_archive_and_records_event() { let temp_dir = TempDir::new().expect("tempdir should create"); @@ -2072,6 +2473,8 @@ fn live_app_server_resume_round_trip_updates_marker_and_state() { command_exec_health_check: None, dynamic_tool_handler: Some(&handler), continuation_guard: Some(&guard), + phase_goal_controller: None, + phase_goal_required: false, codex_account_provider: None, }, &first_state_store, @@ -2117,6 +2520,8 @@ fn live_app_server_resume_round_trip_updates_marker_and_state() { command_exec_health_check: None, dynamic_tool_handler: Some(&handler), continuation_guard: None, + phase_goal_controller: None, + phase_goal_required: false, codex_account_provider: None, }, &resumed_state_store, diff --git a/apps/decodex/src/config.rs b/apps/decodex/src/config.rs index 73e305328..4994206ca 100644 --- a/apps/decodex/src/config.rs +++ b/apps/decodex/src/config.rs @@ -190,6 +190,8 @@ pub struct ProjectCodexConfig { internal_review_mode: InternalReviewMode, #[serde(default = "default_external_review_enabled")] external_review_enabled: bool, + #[serde(default = "default_goal_support_mode")] + goal_support: ProjectCodexGoalSupportMode, accounts: Option, } impl ProjectCodexConfig { @@ -203,6 +205,11 @@ impl ProjectCodexConfig { self.external_review_enabled } + /// Whether app-server phase-scoped goal support is enabled for lane runs. + pub fn goal_support(&self) -> ProjectCodexGoalSupportMode { + self.goal_support + } + /// Optional ChatGPT accounts used to seed Codex app-server auth. pub fn accounts(&self) -> Option<&ProjectCodexAccountsConfig> { self.accounts.as_ref() @@ -232,6 +239,7 @@ impl Default for ProjectCodexConfig { Self { internal_review_mode: default_internal_review_mode(), external_review_enabled: default_external_review_enabled(), + goal_support: default_goal_support_mode(), accounts: None, } } @@ -427,6 +435,44 @@ impl Default for InternalReviewMode { } } +/// Project policy for app-server phase-scoped goal support. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProjectCodexGoalSupportMode { + /// Use phase goals when the app-server supports them, otherwise fall back safely. + Auto, + /// Require app-server phase goal methods for every eligible lane run. + Required, + /// Disable app-server phase goals. + Off, +} +impl ProjectCodexGoalSupportMode { + /// Config string for this mode. + pub const fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Required => "required", + Self::Off => "off", + } + } + + /// Whether Decodex should attempt to use app-server goal methods. + pub const fn enabled(self) -> bool { + !matches!(self, Self::Off) + } + + /// Whether missing app-server goal methods should fail the run. + pub const fn required(self) -> bool { + matches!(self, Self::Required) + } +} + +impl Default for ProjectCodexGoalSupportMode { + fn default() -> Self { + default_goal_support_mode() + } +} + /// Canonical repository root for the current Git checkout. pub fn canonical_repo_root_for_checkout(cwd: &Path) -> Result> { let worktree_root = git_absolute_rev_parse(cwd, "show-toplevel")? @@ -462,6 +508,10 @@ const fn default_internal_review_mode() -> InternalReviewMode { InternalReviewMode::Loop } +const fn default_goal_support_mode() -> ProjectCodexGoalSupportMode { + ProjectCodexGoalSupportMode::Auto +} + const fn default_privacy_classifier_timeout_ms() -> u64 { 1_000 } @@ -944,7 +994,7 @@ mod tests { use tempfile::TempDir; use crate::{ - config::{self, InternalReviewMode, ServiceConfig}, + config::{self, InternalReviewMode, ProjectCodexGoalSupportMode, ServiceConfig}, worktree::WorktreeManager, }; @@ -1214,6 +1264,45 @@ mod tests { } } + #[test] + fn parses_codex_goal_support_modes() { + for (case_name, codex_body, expected_goal_support) in [ + ("default goal support", "", ProjectCodexGoalSupportMode::Auto), + ( + "explicit required goal support", + r#"goal_support = "required""#, + ProjectCodexGoalSupportMode::Required, + ), + ( + "explicit off goal support", + r#"goal_support = "off""#, + ProjectCodexGoalSupportMode::Off, + ), + ] { + let temp_dir = TempDir::new().expect("temp dir should exist"); + let config_path = write_config_file( + temp_dir.path(), + &format!( + r#" + service_id = "pubfi" + + [tracker] + api_key_env_var = "HOME" + + [github] + token_env_var = "HOME" + + [codex] + {codex_body} + "# + ), + ); + let config = ServiceConfig::from_path(&config_path).expect(case_name); + + assert_eq!(config.codex().goal_support(), expected_goal_support); + } + } + #[test] fn project_privacy_classifier_defaults_to_disabled() { let temp_dir = TempDir::new().expect("temp dir should exist"); diff --git a/apps/decodex/src/orchestrator.rs b/apps/decodex/src/orchestrator.rs index dd20a6e46..8db567fc8 100644 --- a/apps/decodex/src/orchestrator.rs +++ b/apps/decodex/src/orchestrator.rs @@ -34,7 +34,7 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use crate::{agent, default_branch_sync, git_credentials, maintenance, state}; #[rustfmt::skip] -use crate::{agent::{ACTIVE_RUN_IDLE_TIMEOUT, AppServerCapabilityPreflightFailure, AppServerDynamicToolFailure, AppServerHomePreflightFailure, AppServerProcessEnv, AppServerRunRequest, AppServerRunResult, AppServerTransportFailure, AppServerTurnFailure, ISSUE_DELIVERY_CLOSEOUT_COMPLETE_TOOL_NAME, ISSUE_LABEL_ADD_TOOL_NAME, ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_HANDOFF_TOOL_NAME, ISSUE_REVIEW_REPAIR_COMPLETE_TOOL_NAME, ISSUE_TERMINAL_FINALIZE_TOOL_NAME, ISSUE_TRANSITION_TOOL_NAME, DecodexRunContext, DecodexToolBridge, ReviewExecutionMode, ReviewHandoffContext, ReviewHandoffWritebackFailed, ReviewPolicyStopReason, ReviewPolicyStopRequested, RunCompletionDisposition, TrackerToolBridge, TurnContinuationGuard}, config::{InternalReviewMode, ServiceConfig}, git_credentials::GitCredentialSource, github, prelude::{Result, eyre}, state::{ChildAgentActivityBucket, ChildAgentActivitySummary, CodexAccountActivitySummary, ProjectRegistration, ProjectRunStatus, ProtocolActivitySummary, RUN_OPERATION_AGENT_RUN, RUN_OPERATION_APP_SERVER_PREFLIGHT, RUN_OPERATION_GIT_CREDENTIALS, RUN_OPERATION_IDLE, RUN_OPERATION_RECONCILIATION, RUN_OPERATION_REPO_GATE, RUN_OPERATION_REVIEW_WRITEBACK, RUN_OPERATION_WAITING_EXTERNAL, ReviewHandoffMarker, ReviewOrchestrationMarker, RunActivityMarker, RunAttempt, StateStore, WorktreeMapping}, tracker::{IssueTracker, TrackerComment, TrackerIssue, linear::LinearClient, records}, workflow::{WorkflowDocument, WorkflowExecution}, worktree::{WorktreeManager, WorktreeSpec}}; +use crate::{agent::{ACTIVE_RUN_IDLE_TIMEOUT, AppServerCapabilityPreflightFailure, AppServerDynamicToolFailure, AppServerHomePreflightFailure, AppServerPhaseGoalFailure, AppServerProcessEnv, AppServerRunRequest, AppServerRunResult, AppServerTransportFailure, AppServerTurnFailure, ISSUE_DELIVERY_CLOSEOUT_COMPLETE_TOOL_NAME, ISSUE_LABEL_ADD_TOOL_NAME, ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_HANDOFF_TOOL_NAME, ISSUE_REVIEW_REPAIR_COMPLETE_TOOL_NAME, ISSUE_TERMINAL_FINALIZE_TOOL_NAME, ISSUE_TRANSITION_TOOL_NAME, DecodexRunContext, DecodexToolBridge, PhaseGoalController, PhaseGoalKind, PhaseGoalSpec, PhaseGoalTransition, ReviewExecutionMode, ReviewHandoffContext, ReviewHandoffWritebackFailed, ReviewPolicyStopReason, ReviewPolicyStopRequested, RunCompletionDisposition, TrackerToolBridge, TurnContinuationGuard}, config::{InternalReviewMode, ServiceConfig}, git_credentials::GitCredentialSource, github, prelude::{Result, eyre}, state::{ChildAgentActivityBucket, ChildAgentActivitySummary, CodexAccountActivitySummary, ProjectRegistration, ProjectRunStatus, ProtocolActivitySummary, RUN_OPERATION_AGENT_RUN, RUN_OPERATION_APP_SERVER_PREFLIGHT, RUN_OPERATION_GIT_CREDENTIALS, RUN_OPERATION_IDLE, RUN_OPERATION_RECONCILIATION, RUN_OPERATION_REPO_GATE, RUN_OPERATION_REVIEW_WRITEBACK, RUN_OPERATION_WAITING_EXTERNAL, ReviewHandoffMarker, ReviewOrchestrationMarker, RunActivityMarker, RunAttempt, StateStore, WorktreeMapping}, tracker::{IssueTracker, TrackerComment, TrackerIssue, linear::LinearClient, records}, workflow::{WorkflowDocument, WorkflowExecution}, worktree::{WorktreeManager, WorktreeSpec}}; include!("orchestrator/types.rs"); diff --git a/apps/decodex/src/orchestrator/execution.rs b/apps/decodex/src/orchestrator/execution.rs index e4baaa405..2909aada9 100644 --- a/apps/decodex/src/orchestrator/execution.rs +++ b/apps/decodex/src/orchestrator/execution.rs @@ -112,6 +112,199 @@ where run_result: &'a AppServerRunResult, } +struct RepoGatePhaseGoalController<'a> { + project: &'a ServiceConfig, + workflow: &'a WorkflowDocument, + state_store: &'a StateStore, + issue_run: &'a IssueRunPlan, +} +impl RepoGatePhaseGoalController<'_> { + fn initial_phase_goal_kind(&self) -> PhaseGoalKind { + match self.issue_run.dispatch_mode { + IssueDispatchMode::Normal | IssueDispatchMode::Retry => + PhaseGoalKind::ImplementToValidationReady, + IssueDispatchMode::ReviewRepair => PhaseGoalKind::RepairAcceptedReviewFindings, + IssueDispatchMode::Closeout => PhaseGoalKind::HandoffEvidence, + } + } + + fn latest_persisted_phase_goal(&self) -> Result> { + let events = self.state_store.list_private_execution_events( + self.project.service_id(), + &self.issue_run.issue.id, + &self.issue_run.run_id, + self.issue_run.attempt_number, + )?; + + Ok(events + .iter() + .rev() + .filter(|event| event.event_type() == "phase_goal_next") + .find_map(|event| event.payload().get("phase").and_then(Value::as_str)) + .and_then(phase_goal_kind_from_str)) + } + + fn validate_phase_goal_output(&self, phase: PhaseGoalKind) -> Result { + let selected_repo_gate = select_repo_gate_for_worktree( + self.workflow.frontmatter().execution(), + &self.issue_run.worktree.path, + ); + + write_run_operation_marker_best_effort( + &self.issue_run.worktree.path, + &self.issue_run.run_id, + self.issue_run.attempt_number, + RUN_OPERATION_REPO_GATE, + ); + + match run_repo_gate_commands( + selected_repo_gate.canonicalize_commands(), + selected_repo_gate.verify_commands(), + &self.issue_run.worktree.path, + ) { + Ok(()) => { + self.record_phase_goal_transition( + phase, + "validation_pass", + json!({ "nextPhase": PhaseGoalKind::HandoffEvidence.as_str() }), + )?; + + let next_goal = self.phase_goal_spec(PhaseGoalKind::HandoffEvidence, None); + + self.persist_next_phase_goal(&next_goal, "validation_pass")?; + + Ok(PhaseGoalTransition::Continue(next_goal)) + }, + Err(error) => { + if let Some(repo_gate_failure) = error.downcast_ref::() { + self.record_phase_goal_transition( + phase, + "validation_fail", + json!({ + "errorClass": repo_gate_failure.error_class(), + "disposition": repo_gate_failure.disposition().as_str(), + }), + )?; + + if repo_gate_failure.disposition() == RepoGateFailureDisposition::ContinueRepair { + let detail = format!( + "Repo gate failed with `{}`. Inspect the worktree, run the registered canonicalize and verify commands, and repair only the validation failure.", + repo_gate_failure.error_class() + ); + let next_goal = + self.phase_goal_spec(PhaseGoalKind::RepairValidationFailures, Some(&detail)); + + self.persist_next_phase_goal(&next_goal, "validation_fail")?; + + return Ok(PhaseGoalTransition::Continue(next_goal)); + } + } + + Err(error) + }, + } + } + + fn phase_goal_spec( + &self, + phase: PhaseGoalKind, + detail: Option<&str>, + ) -> PhaseGoalSpec { + let objective = match phase { + PhaseGoalKind::ImplementToValidationReady => format!( + "Decodex phase: {}\nProduce the smallest coherent implementation and documentation change for {} that is ready for the registered Decodex repo gate. Do not push, request review, or treat goal completion as issue completion.", + phase.as_str(), + self.issue_run.issue.identifier + ), + PhaseGoalKind::RepairValidationFailures => format!( + "Decodex phase: {}\nRepair repo-gate failures for {} in the current worktree without widening issue scope. {}", + phase.as_str(), + self.issue_run.issue.identifier, + detail.unwrap_or("Run the registered canonicalize and verify commands before completing this phase.") + ), + PhaseGoalKind::RepairAcceptedReviewFindings => format!( + "Decodex phase: {}\nRepair accepted review findings for {} on the retained PR head without widening issue scope. Do not request fresh external review before Decodex validation.", + phase.as_str(), + self.issue_run.issue.identifier + ), + PhaseGoalKind::HandoffEvidence => format!( + "Decodex phase: {}\nAfter Decodex validation, prepare PR-backed handoff evidence for {}: run the bounded review policy as instructed, push the branch when ready, create or update the non-draft PR, then record the required Decodex terminal path. Goal completion alone is not issue success.", + phase.as_str(), + self.issue_run.issue.identifier + ), + }; + + PhaseGoalSpec::new(phase, objective, None) + } + + fn persist_next_phase_goal(&self, goal: &PhaseGoalSpec, reason: &str) -> Result<()> { + self.state_store.append_private_execution_event( + self.project.service_id(), + &self.issue_run.issue.id, + &self.issue_run.run_id, + self.issue_run.attempt_number, + "phase_goal_next", + json!({ + "schema": "decodex.phase_goal_signal/1", + "phase": goal.phase.as_str(), + "reason": reason, + }), + )?; + + Ok(()) + } + + fn record_phase_goal_transition( + &self, + phase: PhaseGoalKind, + signal: &str, + payload: Value, + ) -> Result<()> { + self.state_store.append_private_execution_event( + self.project.service_id(), + &self.issue_run.issue.id, + &self.issue_run.run_id, + self.issue_run.attempt_number, + "phase_goal_transition", + json!({ + "schema": "decodex.phase_goal_signal/1", + "phase": phase.as_str(), + "signal": signal, + "payload": payload, + }), + )?; + + Ok(()) + } +} + +impl PhaseGoalController for RepoGatePhaseGoalController<'_> { + fn initial_phase_goal(&self) -> Result> { + if let Some(phase) = self.latest_persisted_phase_goal()? { + return Ok(Some(self.phase_goal_spec(phase, None))); + } + + Ok(Some(self.phase_goal_spec(self.initial_phase_goal_kind(), None))) + } + + fn phase_goal_completed(&self, phase: PhaseGoalKind) -> Result { + match phase { + PhaseGoalKind::HandoffEvidence => { + self.record_phase_goal_transition( + phase, + "handoff_evidence_goal_complete", + json!({ "terminalPathRequired": true }), + )?; + + Ok(PhaseGoalTransition::CompleteRun) + }, + PhaseGoalKind::ImplementToValidationReady + | PhaseGoalKind::RepairValidationFailures + | PhaseGoalKind::RepairAcceptedReviewFindings => self.validate_phase_goal_output(phase), + } + } +} + struct ThreadArchiveCandidate { run_id: String, attempt_number: i64, @@ -578,6 +771,13 @@ where ); let decodex_tool_bridge = DecodexToolBridge::new(&tracker_tool_bridge, build_decodex_run_context(workflow, issue_run)); + let phase_goal_controller = + build_phase_goal_controller(project, workflow, state_store, issue_run); + let phase_goal_controller_ref = phase_goal_controller + .as_ref() + .map(|controller| controller as &dyn PhaseGoalController); + let continuation_user_input = + build_issue_run_continuation_user_input(project, workflow, issue_run, &review_context); let run_result = agent::execute_app_server_run( &AppServerRunRequest { project_id: project.service_id().to_owned(), @@ -602,27 +802,22 @@ where issue_run, &review_context, ), - max_turns: workflow.frontmatter().execution().max_turns(), - timeout: ACTIVE_RUN_IDLE_TIMEOUT, - process_env: agent_git_credentials.process_env().clone(), - continuation_user_input: Some(build_continuation_user_input( - &issue_run.issue, - workflow, - issue_run.dispatch_mode, - review_context.recorded_pr_url.as_deref(), - workflow.frontmatter().tracker().success_state(), - project.codex().internal_review_mode(), - )), + max_turns: workflow.frontmatter().execution().max_turns(), + timeout: ACTIVE_RUN_IDLE_TIMEOUT, + process_env: agent_git_credentials.process_env().clone(), + continuation_user_input: Some(continuation_user_input), activity_marker_path: Some(issue_run.worktree.path.clone()), resume_thread_id: resolve_resume_thread_id(state_store, issue_run)?, ephemeral_thread: false, command_exec_health_check: None, dynamic_tool_handler: Some(&decodex_tool_bridge), continuation_guard: Some(&continuation_guard), - codex_account_provider: codex_account_pool - .as_ref() - .map(|pool| pool as &dyn CodexAccountProvider), - }, + phase_goal_controller: phase_goal_controller_ref, + phase_goal_required: project.codex().goal_support().required(), + codex_account_provider: codex_account_pool + .as_ref() + .map(|pool| pool as &dyn CodexAccountProvider), + }, state_store, ) .map_err(|error| { @@ -653,6 +848,46 @@ where }) } +fn build_issue_run_continuation_user_input( + project: &ServiceConfig, + workflow: &WorkflowDocument, + issue_run: &IssueRunPlan, + review_context: &ReviewHandoffContext, +) -> String { + build_continuation_user_input( + &issue_run.issue, + workflow, + issue_run.dispatch_mode, + review_context.recorded_pr_url.as_deref(), + workflow.frontmatter().tracker().success_state(), + project.codex().internal_review_mode(), + ) +} + +fn build_phase_goal_controller<'a>( + project: &'a ServiceConfig, + workflow: &'a WorkflowDocument, + state_store: &'a StateStore, + issue_run: &'a IssueRunPlan, +) -> Option> { + project.codex().goal_support().enabled().then_some(RepoGatePhaseGoalController { + project, + workflow, + state_store, + issue_run, + }) +} + +fn phase_goal_kind_from_str(value: &str) -> Option { + match value { + "implement_to_validation_ready" => Some(PhaseGoalKind::ImplementToValidationReady), + "repair_validation_failures" => Some(PhaseGoalKind::RepairValidationFailures), + "repair_accepted_review_findings" => Some(PhaseGoalKind::RepairAcceptedReviewFindings), + "handoff_evidence" => Some(PhaseGoalKind::HandoffEvidence), + _ => None, + } +} + fn build_issue_turn_continuation_guard<'a, T>( tracker: &'a T, tracker_tool_bridge: &'a TrackerToolBridge<'a>, @@ -1458,6 +1693,7 @@ fn truncate_private_diagnostic_text(text: &str) -> String { fn run_failure_requires_terminal_attention(error: &Report) -> bool { error.downcast_ref::().is_some() || error.downcast_ref::().is_some() + || error.downcast_ref::().is_some() || error.downcast_ref::().is_some() || error.downcast_ref::().is_some() || error.downcast_ref::().is_some() @@ -1663,6 +1899,8 @@ fn retained_progress_source_error_class(error: &Report) -> Option<&'static str> error.downcast_ref::() { Some(app_server_failure.error_class()) + } else if let Some(app_server_failure) = error.downcast_ref::() { + Some(app_server_failure.error_class()) } else if let Some(app_server_failure) = error.downcast_ref::() { diff --git a/apps/decodex/src/orchestrator/git_ops.rs b/apps/decodex/src/orchestrator/git_ops.rs index ff398ee9d..3aed90bba 100644 --- a/apps/decodex/src/orchestrator/git_ops.rs +++ b/apps/decodex/src/orchestrator/git_ops.rs @@ -7,6 +7,15 @@ mod repo_gate_failure { RetryAfterBackoff, NeedsHumanAttention, } + impl RepoGateFailureDisposition { + pub(super) const fn as_str(self) -> &'static str { + match self { + Self::ContinueRepair => "continue_repair", + Self::RetryAfterBackoff => "retry_after_backoff", + Self::NeedsHumanAttention => "needs_human_attention", + } + } + } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) enum RepoGateFailureKind { diff --git a/apps/decodex/src/orchestrator/selection.rs b/apps/decodex/src/orchestrator/selection.rs index b9a806fbc..81b4c46ac 100644 --- a/apps/decodex/src/orchestrator/selection.rs +++ b/apps/decodex/src/orchestrator/selection.rs @@ -249,6 +249,8 @@ fn terminal_failure_comment_details( error.downcast_ref::() { (app_server_failure.error_class(), app_server_failure.terminal_next_action(recovery_gate)) + } else if let Some(app_server_failure) = error.downcast_ref::() { + (app_server_failure.error_class(), app_server_failure.terminal_next_action(recovery_gate)) } else if let Some(app_server_failure) = error.downcast_ref::() { diff --git a/apps/decodex/src/orchestrator/tests.rs b/apps/decodex/src/orchestrator/tests.rs index f659d328a..40d34a748 100644 --- a/apps/decodex/src/orchestrator/tests.rs +++ b/apps/decodex/src/orchestrator/tests.rs @@ -19,14 +19,15 @@ use color_eyre::{Report, eyre}; use tempfile::TempDir; use time::OffsetDateTime; -use crate::tracker::records; +use crate::{orchestrator::RepoGatePhaseGoalController, tracker::records}; #[rustfmt::skip] use crate::agent::{ ACTIVE_RUN_IDLE_TIMEOUT, MODEL_EXECUTION_IDLE_TIMEOUT, AppServerCapabilityPreflightFailure, AppServerHomePreflightFailure, AppServerTransportFailure, AppServerTurnFailure, - DynamicToolHandler, ReviewPolicyStopReason, ReviewPolicyStopRequested, TrackerToolBridge, - TurnContinuationGuard, + DynamicToolHandler, PhaseGoalController, PhaseGoalKind, PhaseGoalSpec, + PhaseGoalTransition, ReviewPolicyStopReason, ReviewPolicyStopRequested, + TrackerToolBridge, TurnContinuationGuard, }; #[rustfmt::skip] use crate::config::{InternalReviewMode, ServiceConfig}; diff --git a/apps/decodex/src/orchestrator/tests/runtime/repo_gate.rs b/apps/decodex/src/orchestrator/tests/runtime/repo_gate.rs index b1499faaa..9af6977c7 100644 --- a/apps/decodex/src/orchestrator/tests/runtime/repo_gate.rs +++ b/apps/decodex/src/orchestrator/tests/runtime/repo_gate.rs @@ -118,6 +118,74 @@ fn repo_gate_falls_back_to_full_gate_when_changed_file_classification_is_unavail assert_eq!(selection.verify_commands(), ["cargo make check"]); } +#[test] +fn phase_goal_completion_runs_repo_gate_and_persists_handoff_phase() { + let workflow_markdown = sample_workflow_markdown( + "pubfi", + &[], + "Phase goal validation policy.\n", + 3, + ) + .replace( + "canonicalize_commands = []", + "canonicalize_commands = [\"printf canonicalized > phase-canonicalized.txt\"]", + ) + .replace( + "verify_commands = []", + "verify_commands = [\"test -f phase-canonicalized.txt && printf verified > phase-verified.txt\"]", + ); + let (_temp_dir, config, workflow) = + temp_project_layout_with_workflow_markdown(&workflow_markdown); + let issue = sample_issue("In Progress", &[tracker::automation_active_label(TEST_SERVICE_ID).as_str()]); + let state_store = StateStore::open_in_memory().expect("state store should open"); + let issue_run = IssueRunPlan { + issue: issue.clone(), + issue_state: String::from("In Progress"), + initial_issue_state: String::from("Todo"), + worktree: WorktreeSpec { + branch_name: String::from("x/pubfi-pub-101"), + issue_identifier: issue.identifier.clone(), + path: config.repo_root().to_path_buf(), + reused_existing: false, + }, + retry_project_slug: String::from("pubfi"), + dispatch_mode: IssueDispatchMode::Normal, + attempt_number: 1, + run_id: String::from("pub-101-attempt-1"), + retry_budget_base: 0, + }; + let controller = RepoGatePhaseGoalController { + project: &config, + workflow: &workflow, + state_store: &state_store, + issue_run: &issue_run, + }; + let transition = controller + .phase_goal_completed(PhaseGoalKind::ImplementToValidationReady) + .expect("completed implementation phase should run the repo gate"); + let events = state_store + .list_private_execution_events(TEST_SERVICE_ID, &issue.id, &issue_run.run_id, 1) + .expect("private phase goal events should load"); + + assert!(config.repo_root().join("phase-canonicalized.txt").exists()); + assert!(config.repo_root().join("phase-verified.txt").exists()); + assert!(matches!( + transition, + PhaseGoalTransition::Continue(PhaseGoalSpec { + phase: PhaseGoalKind::HandoffEvidence, + .. + }) + )); + assert!(events.iter().any(|event| { + event.event_type() == "phase_goal_transition" + && event.payload()["signal"] == "validation_pass" + })); + assert!(events.iter().any(|event| { + event.event_type() == "phase_goal_next" + && event.payload()["phase"] == "handoff_evidence" + })); +} + #[test] fn repo_gate_shell_falls_back_to_non_login_posix_sh_for_missing_absolute_shell() { let (shell, shell_flag) = orchestrator::repo_gate_shell_from_env(Some(OsString::from( diff --git a/apps/decodex/src/orchestrator/tests/runtime/thread_archive.rs b/apps/decodex/src/orchestrator/tests/runtime/thread_archive.rs index 28cd0e72b..2587f4a8e 100644 --- a/apps/decodex/src/orchestrator/tests/runtime/thread_archive.rs +++ b/apps/decodex/src/orchestrator/tests/runtime/thread_archive.rs @@ -61,6 +61,7 @@ fn completed_issue_thread_archive_candidates_include_prior_terminal_attempts() { event_count: 1, final_output: String::new(), continuation_pending: false, + phase_goal_status: None, }; let candidates = super::completed_issue_thread_archive_candidates(&state_store, &issue_run, &run_result) diff --git a/decodex.example.toml b/decodex.example.toml index f096d4c78..67366fb7c 100644 --- a/decodex.example.toml +++ b/decodex.example.toml @@ -14,6 +14,9 @@ token_env_var = "GITHUB_TOKEN" [codex] external_review_enabled = true internal_review_mode = "loop" +# Phase-scoped app-server goals: "auto" falls back when unsupported, "required" +# fails dispatch when goal methods are unavailable, and "off" disables them. +goal_support = "auto" # Optional shared Codex account pool. # Uncomment the block to use the global `~/.codex/decodex/accounts.jsonl` pool. diff --git a/docs/reference/operator-control-plane.md b/docs/reference/operator-control-plane.md index c84338452..d14392a0b 100644 --- a/docs/reference/operator-control-plane.md +++ b/docs/reference/operator-control-plane.md @@ -131,7 +131,7 @@ work that needs full private payload values. | Surface | Owns | Does Not Own | | --- | --- | --- | -| Runtime SQLite DB | active leases, attempts, run-control channels, protocol events, private execution events, Decision Contracts, worktree mappings, retry state, retained PR state, review-policy checkpoints with structured independent-review detail, phase timing, connector backoff, project registry | human backlog grooming or durable team-visible issue history | +| Runtime SQLite DB | active leases, attempts, run-control channels, protocol events, private execution events, Decision Contracts, worktree mappings, retry state, retained PR state, review-policy checkpoints with structured independent-review detail, phase-goal signals, phase timing, connector backoff, project registry | human backlog grooming or durable team-visible issue history | | Central project config | `service_id`, repo root, worktree root, tracker/GitHub credential env-var names, enabled project registration | per-run state or issue ownership | | Project `WORKFLOW.md` | repo policy, validation gate, state names, retry/review policy | runtime ownership, queue labels, credentials, model overrides | | Linear | team-visible issue state, queue/active/manual-attention labels, coarse execution ledger comments, progress/failure/handoff/closeout summaries | high-frequency runtime truth, heartbeat, token pressure, raw attempts, private execution evidence, connector retry budgets | @@ -219,7 +219,7 @@ protocol activity durable outside the local operator surface. | --- | --- | | `Accounts` | Shared Codex account pool and usage table from `~/.codex/decodex/accounts.jsonl` when `[codex.accounts]` is enabled for a project. Account identity can be obscured from the `Account` column header eye without changing the underlying snapshot. The row weight column shows the capacity multiplier used for pool usage estimates: `pro` accounts count as `20x`, and all other plans count as `1x`. Usage probes read Codex `/wham/usage` for window capacity and `/wham/profiles/me` for profile token stats such as lifetime tokens, peak daily tokens, longest task, streaks, and daily token activity. Selecting an account writes the global `[codex.accounts].fixed_account` selector in `~/.codex/decodex/config.toml`; clearing it returns all new account-pool runs to balanced account selection. Account display-name rerolls write `[codex.account_names.offsets]` in the same global config so Decodex App and the dashboard share the privacy-preserving names. Theme, sort, and identity-visibility preferences are client-local presentation state. The selector is global and does not pin a project to an account. | | `Projects` | Fleet-level project table. The section-level filter toggles between active project work and the full registry. Location is its own compact path column and can be obscured from the location header eye. `Activity` shows a relative timestamp or `-`; `Work` is `running/waiting/attention`. It should not duplicate per-lane details already shown below. | -| `Running Lanes` | Active leased or live-executing issue lanes. A lane here is currently owned by this local control plane, or a live process/thread/protocol marker still explains active execution even when the queue lease is not held. It shows issue identity, phase, operation, attempt, queue lease state, execution liveness, thread/protocol status, child-agent activity when captured, timing, branch, and worktree. | +| `Running Lanes` | Active leased or live-executing issue lanes. A lane here is currently owned by this local control plane, or a live process/thread/protocol marker still explains active execution even when the queue lease is not held. It shows issue identity, phase, operation, attempt, queue lease state, execution liveness, thread/protocol status, child-agent activity when captured, phase-goal status when app-server reports it, timing, branch, and worktree. | | `Intake Queue` | Queued tracker issues before execution. Candidates are classified as `ready`, capacity-waiting, claimed without a matching local lane, blocked, or closed/stale. A blocked queued candidate can still show an attached `.worktrees/XY-*` path when the queue owns the attention state; if that worktree has tracked changes after stalled reconciliation, failure writeback, or retries, the candidate is partial retained progress and not just a generic stalled or retry-budget hold. Running lanes are not repeated as normal intake work. | | `Review & Landing` | Retained PR lanes after review handoff. This section owns post-review repair, wait-for-review, ready-to-land, closeout, cleanup, and blocked retained-lane visibility. | | `Recovery Worktrees` | Retained local worktrees that are not currently owned by `Running Lanes`, `Review & Landing`, or queued attention in `Intake Queue`. This is the cleanup or recovery inbox for recovered paths, retained PR leftovers, and cleanup-only local worktrees. Empty is the normal healthy state. | @@ -310,6 +310,13 @@ Worktree visibility follows the owning dashboard section: or undeclared app-server tool requests are protocol failures; declared Decodex tools that return `success = false` remain tool failures the model can correct within the same turn. +- Phase-goal protocol activity may appear as `thread/goal/set`, + `thread/goal/get`, `thread/goal/updated`, or `thread/goal/clear`. These events + help explain whether a retained lane is implementing, repairing validation, + repairing accepted review findings, or preparing handoff evidence. Goal status is + diagnostic phase evidence only; it is not a Run Ledger outcome and does not replace + repo validation, bounded review, PR handoff, manual attention, landing, closeout, or + terminal finalization. - `Review & Landing` means a retained PR lane still owns the path for review repair, landing, closeout, or retained-lane cleanup. - `missing_review_handoff_record` in `Review & Landing` means Decodex found a retained diff --git a/docs/runbook/self-dogfood-pilot.md b/docs/runbook/self-dogfood-pilot.md index 332d54fe3..eea166647 100644 --- a/docs/runbook/self-dogfood-pilot.md +++ b/docs/runbook/self-dogfood-pilot.md @@ -110,6 +110,7 @@ token_env_var = "GITHUB_TOKEN" [codex] internal_review_mode = "prompt" external_review_enabled = false +goal_support = "auto" # Optional secondary public-projection privacy guard. # [privacy_classifier] @@ -131,6 +132,10 @@ Notes: - `github.token_env_var` is required for PR-backed review handoff validation and post-review PR-state inspection and must name the environment variable that stores the GitHub token. - For the self-dogfood pilot, use `codex.internal_review_mode = "loop"` for the runtime-owned self-review checkpoint loop, `"prompt"` to add only `Review your work repeatedly and fix any logic bugs until no new issues are found.`, or `"off"` to skip internal self-review. If omitted, the default is `"loop"`. - Keep `codex.external_review_enabled = false` when the retained lane should skip the runtime-owned `@codex review` request and rely on the PR-backed handoff plus the normal PR landing checks. +- Use `codex.goal_support = "auto"` to let retained lanes use phase-scoped app-server + goals when the selected Codex build supports them and fall back safely when it does + not. Use `"required"` only for a project that must fail fast when goal methods are + absent, and `"off"` to disable goal handling. - With `codex.external_review_enabled = false`, a one-shot `decodex run` may continue draining the same retained lane after review handoff if the retained landing gates are already satisfied. If those gates are still pending, the run exits cleanly at the retained waiting boundary instead of spinning. - `[privacy_classifier]` is optional and disabled when omitted. If enabled, `endpoint` must be an operator-managed loopback HTTP service; Decodex sends only rendered diff --git a/docs/spec/app-server.md b/docs/spec/app-server.md index caaf7a52d..108d3d5fe 100644 --- a/docs/spec/app-server.md +++ b/docs/spec/app-server.md @@ -64,6 +64,22 @@ Support evidence has two distinct layers: `decodex probe stdio://` reports the probe result with `preflight_checks`, `thread`, `turn`, `events`, and `output`. A passing probe must include `output=PROBE_OK`. +Phase-scoped goal support is optional and capability-gated. App-server surfaces that +support goals may expose `thread/goal/set`, `thread/goal/get`, `thread/goal/clear`, +and `thread/goal/updated`. These methods and events are not part of the required MVP +preflight because older app-server builds may omit them. Normal dispatch detects goal +support by attempting the goal methods for a configured phase-goal lane: + +- `codex.goal_support = "auto"` attempts goal support and records + `phase_goal_unavailable` private evidence before falling back to ordinary Decodex + continuation when a goal method is missing. +- `codex.goal_support = "required"` treats missing goal methods as an app-server + phase-goal failure and routes the lane to the human-required path. +- `codex.goal_support = "off"` does not call goal methods. + +Goal events are phase signals only. A `complete` goal status triggers Decodex-owned +validation or handoff policy; it never satisfies terminal issue completion by itself. + To validate an upstream app-server protocol change: 1. Install or select the target Codex binary locally without disrupting active lanes. @@ -108,6 +124,10 @@ To validate an upstream app-server protocol change: - `thread/archive` after successful completion writeback, for every locally recorded terminal attempt thread on the issue that has not already recorded a successful archive event +- Optional phase-goal requests: + - `thread/goal/set` + - `thread/goal/get` + - `thread/goal/clear` - Required notifications for the MVP: - `thread/started` - `thread/status/changed` @@ -115,6 +135,9 @@ To validate an upstream app-server protocol change: - `turn/completed` Additional notifications may be recorded opportunistically for diagnostics. +When available, `thread/goal/updated` is recorded as local protocol activity and may +summarize the active phase and status for operator readback. It is not a public +tracker signal. The follow-up alignment phase should also record tool-related requests and notifications needed for issue-scoped tracker writes. @@ -151,11 +174,20 @@ app-server connection for active turns. 4. When `[codex.accounts]` is enabled, select a shared ChatGPT account and send `account/login/start` with `chatgptAuthTokens`. 5. Send `thread/start`. -6. Send `turn/start`. -7. Consume notifications until that turn reaches a terminal outcome. -8. If the project-owned continuation policy allows another same-thread turn, send another `turn/start` on the same thread. -9. Persist the local run journal and classify the bounded run result. -10. After successful completion writeback, best-effort archive all locally recorded +6. If phase-scoped goals are enabled for the project and the controller has a phase + goal for this run, send `thread/goal/set`. +7. Send `turn/start`. +8. Consume notifications until that turn reaches a terminal outcome. +9. If a phase goal is active, send `thread/goal/get` after the turn completes. If the + goal status is `complete`, Decodex runs the next owned phase transition such as + repository validation, validation repair, review repair, or handoff evidence. If + the goal remains active and bounded continuation is allowed, Decodex may start + another turn on the same thread. If the goal method is missing in `auto` mode, + Decodex records the fallback and resumes ordinary continuation classification. +10. If the project-owned continuation policy allows another same-thread turn, send + another `turn/start` on the same thread. +11. Persist the local run journal and classify the bounded run result. +12. After successful completion writeback, best-effort archive all locally recorded terminal attempt threads for the issue so prior failed retry attempts do not keep the Codex conversation list visible. @@ -273,6 +305,43 @@ The resume request owns these fields: Decodex must not inject project-owned config, model, personality, service-tier, sandbox, or approval-policy overrides into `thread/resume`. Resumed child runs keep inheriting runtime defaults from the active Codex runtime. +## `thread/goal/*` + +Methods: + +- `thread/goal/set` +- `thread/goal/get` +- `thread/goal/clear` + +These methods are optional. Decodex only calls them when centralized project config +enables `codex.goal_support` and a phase-goal controller has a scoped goal for the +run. + +`thread/goal/set` request fields: + +- `threadId` +- `objective` +- `status`, set to `active` by Decodex +- `tokenBudget`, optional + +`thread/goal/get` request fields: + +- `threadId` + +`thread/goal/clear` request fields: + +- `threadId` + +Goal responses should include the active `goal` object with `threadId`, `objective`, +`status`, optional `tokenBudget`, `tokensUsed`, `timeUsedSeconds`, and timestamps. +Decodex recognizes `active`, `paused`, `blocked`, `usageLimited`, `budgetLimited`, +and `complete` statuses. A missing goal after Decodex set one is an invalid +phase-goal state, not a successful no-op. + +Decodex clears a thread goal best-effort only after the lane reaches an explicit +terminal completion path. Clear failures remain diagnostics and do not replace the +terminal tracker contract. + ## `turn/start` Method: diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index 3267fd49a..361bb3185 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -200,6 +200,42 @@ After each `app-server` turn completes, `decodex` must resolve one continuation - invalid completion signaling - If the turn records both signals, or records one terminal path but fails to finalize it explicitly, the attempt is invalid and must fail rather than guessing a completion path. +Phase-scoped Codex goals are an optional continuation helper inside this same +bounded-turn model. When centralized project config enables `codex.goal_support`, +Decodex may set one scoped goal for the active phase: + +- `implement_to_validation_ready` +- `repair_validation_failures` +- `repair_accepted_review_findings` +- `handoff_evidence` + +`auto` goal support falls back to ordinary continuation if the selected app-server +does not expose goal methods. `required` goal support fails fast through the +human-required path when goal methods are missing. `off` disables goal calls. + +After a turn completes with an active phase goal, Decodex reads the goal status and +uses it only as a phase signal: + +- `complete` on implementation or repair phases triggers Decodex-owned repository + validation. Validation pass records `validation_pass` and may set the + `handoff_evidence` goal; validation failure records `validation_fail` and either + sets `repair_validation_failures` for continued repair or follows the existing + repo-gate human-attention classification. +- `active`, `paused`, `blocked`, `usageLimited`, or `budgetLimited` do not bypass + `execution.max_turns`, continuation guard checks, retry backoff, or manual-attention + policy. If the bounded turn budget is exhausted, the run exits at a continuation + boundary and the control-plane retry path decides the next re-entry. +- `complete` on `handoff_evidence` is valid only when the agent also recorded one + explicit Decodex terminal path. Without `issue_review_handoff` plus + `issue_terminal_finalize(path = "review_handoff")`, or the manual-attention pair, + the turn is invalid and must fail rather than treating goal completion as success. + +Phase-goal telemetry is local runtime evidence. It must distinguish +`goal_complete`, `validation_pass`, `validation_fail`, review `clean`, review +`findings`, terminal `review_handoff`, and terminal `manual_attention`. These signals +may appear in private execution events and operator protocol activity, but Linear +receives only the existing low-frequency lifecycle projections. + ## Tracker write ownership - Preferred steady state: the coding agent writes tracker state transitions, comments, and handoff data for the currently leased issue through issue-scoped runtime tools. @@ -414,7 +450,7 @@ mutations, or duplicate comment for that logical event. The local runtime store is the global Decodex SQLite database for one local installation. It lives at `~/.codex/decodex/runtime.sqlite3`, not inside any registered project checkout or worktree. Every row that belongs to a repo is scoped by `project_id`. Decodex logs live beside that database under `~/.codex/decodex/logs/`, the optional shared Codex account pool lives at `~/.codex/decodex/accounts.jsonl`, global operator config lives at `~/.codex/decodex/config.toml`, bounded local account usage estimates live at `~/.codex/decodex/account-usage-history.jsonl`, and agent-readable derived evidence lives under `~/.codex/decodex/agent-evidence//`; vendor-qualified app-data directories and per-project runtime databases are not part of the runtime contract. Global operator config owns account-pool routing and shared account display-name offsets. Account usage history owns local seven-day display estimates and non-secret account capacity weights only; it does not contain token material and does not decide scheduling. UI-only preferences such as theme, table sorting, and local privacy visibility are not runtime state. -Project contracts live outside registered repositories under `~/.codex/decodex/projects//`. Each project directory must contain `project.toml` and `WORKFLOW.md`; arbitrary project file names such as `.toml` are not part of the contract. `project.toml` must set `[paths].repo_root` so the project contract is explicit. The `[github]` table owns the routed token environment variable and may also set `command_path` when the expected `gh` binary should be explicit for GUI-launched runs. Project registration stores the centralized `config_path`, target `repo_root`, `worktree_root`, and workflow path in the global runtime database. Commands that start inside a registered checkout or lane worktree resolve the project through that registry; they do not discover or trust worktree-local config files. Project config refreshes preserve an existing enabled or disabled registry toggle; only explicit operator commands such as `decodex project add `, `decodex project enable `, and `decodex project disable ` may change that toggle. `decodex serve` schedules and polls enabled registered projects from the global runtime database; the operator and App projections must still expose active runtime DB-backed attempts for disabled projects because pause is a future-dispatch control, not a visibility or ownership deletion. It must not scan `.codex` history, repo-local config files, or currently open worktrees to infer additional projects. +Project contracts live outside registered repositories under `~/.codex/decodex/projects//`. Each project directory must contain `project.toml` and `WORKFLOW.md`; arbitrary project file names such as `.toml` are not part of the contract. `project.toml` must set `[paths].repo_root` so the project contract is explicit. The `[github]` table owns the routed token environment variable and may also set `command_path` when the expected `gh` binary should be explicit for GUI-launched runs. The `[codex]` table owns app-server-adjacent runtime policy such as `internal_review_mode`, `external_review_enabled`, and `goal_support`. `goal_support` accepts `"auto"`, `"required"`, and `"off"` and defaults to `"auto"` when omitted. Project registration stores the centralized `config_path`, target `repo_root`, `worktree_root`, and workflow path in the global runtime database. Commands that start inside a registered checkout or lane worktree resolve the project through that registry; they do not discover or trust worktree-local config files. Project config refreshes preserve an existing enabled or disabled registry toggle; only explicit operator commands such as `decodex project add `, `decodex project enable `, and `decodex project disable ` may change that toggle. `decodex serve` schedules and polls enabled registered projects from the global runtime database; the operator and App projections must still expose active runtime DB-backed attempts for disabled projects because pause is a future-dispatch control, not a visibility or ownership deletion. It must not scan `.codex` history, repo-local config files, or currently open worktrees to infer additional projects. `project.toml` may also configure `[privacy_classifier]` with a loopback HTTP `endpoint` and bounded `timeout_ms` for an operator-managed local classifier runtime. diff --git a/docs/spec/workflow-file.md b/docs/spec/workflow-file.md index df9e4413d..49cffd432 100644 --- a/docs/spec/workflow-file.md +++ b/docs/spec/workflow-file.md @@ -120,6 +120,11 @@ Removed fields: Child-run execution policy is not part of the project-owned workflow contract. `decodex` must let `codex app-server` inherit sandbox and approval behavior from the active Codex runtime instead of declaring repo-local overrides in `WORKFLOW.md`. +Codex app-server-adjacent runtime settings such as `codex.goal_support`, +`codex.internal_review_mode`, and `codex.external_review_enabled` belong to the +centralized project `project.toml`, not to `WORKFLOW.md`. `WORKFLOW.md` still owns +the bounded turn budget and repo gate used after a phase goal completes. + ## `[execution]` Purpose: Define target-repository execution and validation policy.