From b2ad5526fef271a2d8b2a9d98c5f0eae4d51c6af Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Wed, 10 Jun 2026 18:38:18 +0800 Subject: [PATCH] {"schema":"decodex/commit/1","summary":"Require phase-scoped Codex goals","authority":"XY-878"} --- README.md | 7 +- apps/decodex/src/agent/app_server.rs | 58 +++----------- apps/decodex/src/agent/app_server/tests.rs | 58 +++++++------- apps/decodex/src/config.rs | 79 +++---------------- apps/decodex/src/orchestrator/execution.rs | 11 +-- .../src/orchestrator/harness_improvement.rs | 3 +- decodex.example.toml | 3 - docs/reference/operator-control-plane.md | 8 +- docs/runbook/self-dogfood-pilot.md | 8 +- docs/spec/app-server.md | 58 ++++++-------- docs/spec/runtime.md | 15 ++-- docs/spec/workflow-file.md | 7 +- 12 files changed, 104 insertions(+), 211 deletions(-) diff --git a/README.md b/README.md index 82e88fa13..f7180e91d 100644 --- a/README.md +++ b/README.md @@ -167,10 +167,9 @@ 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. +Phase-scoped app-server goals are mandatory for retained lane execution. Decodex +rejects a connected Codex app-server that lacks required `thread/goal/*` methods +instead of falling back to ordinary continuation. 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/app_server.rs b/apps/decodex/src/agent/app_server.rs index 3a04ae0a1..bb27ef3d2 100644 --- a/apps/decodex/src/agent/app_server.rs +++ b/apps/decodex/src/agent/app_server.rs @@ -107,6 +107,10 @@ const APP_SERVER_SCHEMA_REQUIRED_MARKERS: &[&str] = &[ "mcpServerStatus/list", "thread/start", "thread/resume", + "thread/goal/set", + "thread/goal/get", + "thread/goal/clear", + "thread/goal/updated", "turn/start", "thread/archive", "command/exec", @@ -277,7 +281,7 @@ impl AppServerPhaseGoalFailure { 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}" + "select or upgrade to a Codex app-server that supports required phase-goal method `{method}`, confirm with `decodex probe stdio://`, 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}", @@ -291,7 +295,10 @@ 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.") + write!( + formatter, + "Unsupported Codex app-server: required phase-goal method `{method}` is unavailable." + ) }, AppServerPhaseGoalFailureKind::MissingTerminalPath { phase } => write!( formatter, @@ -595,7 +602,6 @@ pub(crate) struct AppServerRunRequest<'a> { 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>, } @@ -1293,7 +1299,6 @@ pub(crate) fn probe_app_server(listen: &str) -> crate::prelude::Result 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) => { + Err(error) if app_server_method_not_found(&error) => { return Err(Report::new(AppServerPhaseGoalFailure::unsupported("thread/goal/get")) .wrap_err(error)); }, @@ -3631,12 +3623,7 @@ fn initialize_phase_goal_runtime<'a>( 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(error) if app_server_method_not_found(&error) => Err(Report::new(AppServerPhaseGoalFailure::unsupported("thread/goal/set")) .wrap_err(error)), Err(error) => Err(error), @@ -3739,29 +3726,6 @@ fn record_phase_goal_completed( 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, diff --git a/apps/decodex/src/agent/app_server/tests.rs b/apps/decodex/src/agent/app_server/tests.rs index 68def3248..23da7ea42 100644 --- a/apps/decodex/src/agent/app_server/tests.rs +++ b/apps/decodex/src/agent/app_server/tests.rs @@ -70,6 +70,9 @@ def method_not_found(message_id, method): "message": "Method not found: " + str(method), }}) +def unsupported_goal_method(method): + return method in UNSUPPORTED_GOAL_METHODS + def goal_payload(status): return { "createdAt": 1, @@ -144,14 +147,14 @@ for line in sys.stdin: "reasoningEffort": None, }) elif method == "thread/goal/set": - if UNSUPPORTED_GOAL_METHODS: + if unsupported_goal_method(method): 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: + if unsupported_goal_method(method): method_not_found(message_id, method) else: if GOAL_STATUSES: @@ -161,7 +164,7 @@ for line in sys.stdin: 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: + if unsupported_goal_method(method): method_not_found(message_id, method) else: reply(message_id, {"cleared": True}) @@ -181,7 +184,7 @@ for line in sys.stdin: "threadId": "thread-1", "turn": {"id": turn_id, "status": "running", "error": None}, }}) - if not UNSUPPORTED_GOAL_METHODS: + if not unsupported_goal_method("thread/goal/updated"): send({"method": "thread/goal/updated", "params": { "threadId": "thread-1", "turnId": turn_id, @@ -676,7 +679,6 @@ fn minimal_run_request<'a>() -> super::AppServerRunRequest<'a> { dynamic_tool_handler: None, continuation_guard: None, phase_goal_controller: None, - phase_goal_required: false, codex_account_provider: None, } } @@ -876,17 +878,18 @@ for line in sys.stdin: fn phase_goal_fake_codex_script( turn_outputs: &[&str], goal_statuses: &[&str], - unsupported_goal_methods: bool, + unsupported_goal_methods: &[&str], ) -> 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" }; + let unsupported_goal = + serde_json::to_string(unsupported_goal_methods).expect("methods should serialize"); PHASE_GOAL_FAKE_CODEX_SCRIPT_TEMPLATE .replace("__TURN_OUTPUTS__", &outputs_json) .replace("__GOAL_STATUSES__", &statuses_json) - .replace("__UNSUPPORTED_GOAL_METHODS__", unsupported_goal) + .replace("__UNSUPPORTED_GOAL_METHODS__", &unsupported_goal) } fn execute_phase_goal_fake_app_server<'a, F>( @@ -930,38 +933,35 @@ fn private_phase_goal_events(state_store: &StateStore, event_type: &str) -> Vec< } #[test] -fn phase_goal_auto_mode_falls_back_when_app_server_goal_methods_are_missing() { +fn phase_goal_set_method_is_required_when_phase_controller_is_present() { 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| { + let script = phase_goal_fake_codex_script(&["DONE"], &[], &["thread/goal/set"]); + 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"); + let error = result.expect_err("missing goal set support should fail immediately"); + let failure = error + .downcast_ref::() + .expect("missing goal support should be a typed phase-goal failure"); - 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"); + assert_eq!(failure.error_class(), "app_server_phase_goal_unsupported"); + assert!(error.to_string().contains("thread/goal/set")); } #[test] -fn phase_goal_required_mode_fails_when_goal_methods_are_missing() { +fn phase_goal_get_method_is_required_after_turn_completion() { let controller = TestPhaseGoalController::new(PhaseGoalKind::ImplementToValidationReady); - let script = phase_goal_fake_codex_script(&["DONE"], &[], true); + let script = phase_goal_fake_codex_script(&["DONE"], &[], &["thread/goal/get"]); 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 error = result.expect_err("missing goal get support should fail after the turn"); 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")); + assert!(error.to_string().contains("thread/goal/get")); } #[test] @@ -969,7 +969,7 @@ 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); + phase_goal_fake_codex_script(&["CONTINUE", "DONE"], &["complete", "complete"], &[]); let (result, state_store) = execute_phase_goal_fake_app_server(script, |request| { request.max_turns = 3; request.dynamic_tool_handler = Some(&handler); @@ -1000,7 +1000,7 @@ fn phase_goal_complete_runs_validation_transition_before_handoff_goal() { 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 script = phase_goal_fake_codex_script(&["CONTINUE", "DONE"], &["active", "active"], &[]); let (result, _state_store) = execute_phase_goal_fake_app_server(script, |request| { request.max_turns = 2; request.dynamic_tool_handler = Some(&handler); @@ -1025,7 +1025,7 @@ fn still_active_phase_goal_continues_same_thread_until_terminal_output() { 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 script = phase_goal_fake_codex_script(&["CONTINUE"], &["active"], &[]); let (result, _state_store) = execute_phase_goal_fake_app_server(script, |request| { request.max_turns = 1; request.dynamic_tool_handler = Some(&handler); @@ -1048,7 +1048,7 @@ fn still_active_phase_goal_stops_at_max_turns_with_continuation_pending() { 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 script = phase_goal_fake_codex_script(&["CONTINUE"], &["complete"], &[]); let (result, _state_store) = execute_phase_goal_fake_app_server(script, |request| { request.max_turns = 2; request.dynamic_tool_handler = Some(&handler); @@ -2528,7 +2528,6 @@ fn live_app_server_resume_round_trip_updates_marker_and_state() { dynamic_tool_handler: Some(&handler), continuation_guard: Some(&guard), phase_goal_controller: None, - phase_goal_required: false, codex_account_provider: None, }, &first_state_store, @@ -2575,7 +2574,6 @@ fn live_app_server_resume_round_trip_updates_marker_and_state() { 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 a9d6bb6eb..a39f94923 100644 --- a/apps/decodex/src/config.rs +++ b/apps/decodex/src/config.rs @@ -189,8 +189,6 @@ impl ProjectGitHubConfig { pub struct ProjectCodexConfig { #[serde(default = "default_review_level")] review: ReviewLevel, - #[serde(default = "default_goal_support_mode")] - goal_support: ProjectCodexGoalSupportMode, accounts: Option, } impl ProjectCodexConfig { @@ -199,11 +197,6 @@ impl ProjectCodexConfig { self.review } - /// 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() @@ -431,44 +424,6 @@ impl Default for ReviewLevel { } } -/// 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")? @@ -500,10 +455,6 @@ const fn default_review_level() -> ReviewLevel { ReviewLevel::Strict } -const fn default_goal_support_mode() -> ProjectCodexGoalSupportMode { - ProjectCodexGoalSupportMode::Auto -} - const fn default_privacy_classifier_timeout_ms() -> u64 { 1_000 } @@ -986,7 +937,7 @@ mod tests { use tempfile::TempDir; use crate::{ - config::{self, ProjectCodexGoalSupportMode, ReviewLevel, ServiceConfig}, + config::{self, ReviewLevel, ServiceConfig}, worktree::WorktreeManager, }; @@ -1245,20 +1196,10 @@ 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, - ), - ] { + fn rejects_removed_codex_goal_field() { + let removed_field = ["goal", "support"].join("_"); + + for removed_value in ["auto", "required", "off"] { let temp_dir = TempDir::new().expect("temp dir should exist"); let config_path = write_config_file( temp_dir.path(), @@ -1273,13 +1214,17 @@ mod tests { token_env_var = "HOME" [codex] - {codex_body} + {removed_field} = "{removed_value}" "# ), ); - let config = ServiceConfig::from_path(&config_path).expect(case_name); + let error = ServiceConfig::from_path(&config_path) + .expect_err("removed goal field should be rejected"); - assert_eq!(config.codex().goal_support(), expected_goal_support); + assert!( + error.to_string().contains(&removed_field), + "unexpected error for removed value `{removed_value}`: {error:?}" + ); } } diff --git a/apps/decodex/src/orchestrator/execution.rs b/apps/decodex/src/orchestrator/execution.rs index c2217ede5..9788f1c49 100644 --- a/apps/decodex/src/orchestrator/execution.rs +++ b/apps/decodex/src/orchestrator/execution.rs @@ -815,9 +815,7 @@ where 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 phase_goal_controller_ref = Some(&phase_goal_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( @@ -855,7 +853,6 @@ where dynamic_tool_handler: Some(&decodex_tool_bridge), continuation_guard: Some(&continuation_guard), 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), @@ -911,13 +908,13 @@ fn build_phase_goal_controller<'a>( workflow: &'a WorkflowDocument, state_store: &'a StateStore, issue_run: &'a IssueRunPlan, -) -> Option> { - project.codex().goal_support().enabled().then_some(RepoGatePhaseGoalController { +) -> RepoGatePhaseGoalController<'a> { + RepoGatePhaseGoalController { project, workflow, state_store, issue_run, - }) + } } fn phase_goal_kind_from_str(value: &str) -> Option { diff --git a/apps/decodex/src/orchestrator/harness_improvement.rs b/apps/decodex/src/orchestrator/harness_improvement.rs index 314b6899b..0338a9674 100644 --- a/apps/decodex/src/orchestrator/harness_improvement.rs +++ b/apps/decodex/src/orchestrator/harness_improvement.rs @@ -520,8 +520,7 @@ fn harness_outcome_signals( for event in events { match event.event_type() { - "phase_goal_completed" | "phase_goal_set" | "phase_goal_unavailable" => - push_phase_goal_signal(&mut signals, event), + "phase_goal_completed" | "phase_goal_set" => push_phase_goal_signal(&mut signals, event), "review_checkpoint" => push_review_signal(&mut signals, event.payload()), "loop_guardrail_checkpoint" => push_guardrail_signal(&mut signals, event.payload()), "authority_boundary_check" => push_authority_boundary_signal(&mut signals, event.payload()), diff --git a/decodex.example.toml b/decodex.example.toml index c7e5cfa8f..706c69e0f 100644 --- a/decodex.example.toml +++ b/decodex.example.toml @@ -13,9 +13,6 @@ token_env_var = "GITHUB_TOKEN" # Omit this block to use `review = "strict"`. [codex] review = "strict" -# 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 6b4e06db1..4c2e25551 100644 --- a/docs/reference/operator-control-plane.md +++ b/docs/reference/operator-control-plane.md @@ -343,9 +343,11 @@ Worktree visibility follows the owning dashboard section: - 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 + repairing accepted review findings, or preparing handoff evidence. Retained lanes + require this goal-method support; missing goal methods surface as an unsupported + app-server blocker rather than ordinary continuation. 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. - Loop guardrail stops may appear as `validation_repeat`, `no_effective_diff`, `remaining_delta_unchanged`, `review_churn`, `dependency_program_stale`, diff --git a/docs/runbook/self-dogfood-pilot.md b/docs/runbook/self-dogfood-pilot.md index f0831e19a..a21530d27 100644 --- a/docs/runbook/self-dogfood-pilot.md +++ b/docs/runbook/self-dogfood-pilot.md @@ -109,7 +109,6 @@ token_env_var = "GITHUB_TOKEN" [codex] review = "basic" -goal_support = "auto" # Optional secondary public-projection privacy guard. # [privacy_classifier] @@ -133,10 +132,9 @@ Notes: use only Self Check, `"standard"` when it should also require Decodex Review, and `"strict"` when it should additionally request GitHub Review after PR handoff. `"off"` skips review gates. If omitted, the default is `"strict"`. -- 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. +- Retained lanes require phase-scoped app-server goals. If the selected Codex + app-server lacks `thread/goal/*` methods, Decodex fails fast with an unsupported + app-server blocker instead of using ordinary non-goal continuation. - With non-strict review levels, 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 diff --git a/docs/spec/app-server.md b/docs/spec/app-server.md index dc6297170..d6284460a 100644 --- a/docs/spec/app-server.md +++ b/docs/spec/app-server.md @@ -64,18 +64,13 @@ 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. +Phase-scoped goal support is mandatory and capability-gated for retained lane +execution. App-server surfaces must expose `thread/goal/set`, `thread/goal/get`, +`thread/goal/clear`, and `thread/goal/updated`. Decodex rejects old or incompatible +app-server builds that lack these methods with a typed unsupported-app-server blocker. +It must not fall back to ordinary continuation, and it must not reject newer, beta, +alpha, or unknown app-server versions solely because of the version string when the +required goal methods work. 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. @@ -87,8 +82,9 @@ To validate an upstream app-server protocol change: target/decodex-app-server-schema-check`. 3. Confirm the generated schema contains every required marker in this spec: `initialize`, `thread/start`, `thread/resume`, `turn/start`, `thread/archive`, - `command/exec`, bounded preflight methods, `item/tool/call`, dynamic tool - `namespace`, dynamic tool `deferLoading`, `inputText`, and + `thread/goal/set`, `thread/goal/get`, `thread/goal/clear`, + `thread/goal/updated`, `command/exec`, bounded preflight methods, + `item/tool/call`, dynamic tool `namespace`, dynamic tool `deferLoading`, `inputText`, and `PluginListParams.marketplaceKinds`. 4. Run `decodex probe stdio://` and require `PROBE_OK`. 5. Update this spec or the runtime preflight only when the protocol shape or required @@ -120,24 +116,23 @@ To validate an upstream app-server protocol change: - `command/exec` for bounded app-server health checks only - `thread/start` - `thread/resume` when retrying a persisted same-thread continuation + - `thread/goal/set` + - `thread/goal/get` + - `thread/goal/clear` - `turn/start` - `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` + - `thread/goal/updated` - `turn/started` - `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. +`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. @@ -174,16 +169,15 @@ 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. If phase-scoped goals are enabled for the project and the controller has a phase - goal for this run, send `thread/goal/set`. +6. Send `thread/goal/set` with the controller-owned phase goal for the run. 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. +9. 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 a required goal method is missing, Decodex fails the run with the typed + unsupported-app-server reason instead of continuing without a goal. 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. @@ -313,9 +307,9 @@ Methods: - `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. +These methods are required for retained lane execution. Decodex calls them when a +phase-goal controller has a scoped goal for the run and treats missing methods as an +unsupported app-server capability failure. `thread/goal/set` request fields: diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index 2c52e406f..8c7bfb048 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -226,18 +226,19 @@ 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: +Phase-scoped Codex goals are a mandatory continuation contract inside this same +bounded-turn model for retained lane runs. Decodex sets 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. +Missing required app-server goal methods fail fast through the human-required +unsupported-app-server path. Decodex does not fall back to ordinary continuation +without a phase goal, and project config does not expose a mode to disable the goal +contract. After a turn completes with an active phase goal, Decodex reads the goal status and uses it only as a phase signal: @@ -515,7 +516,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. The `[codex]` table owns app-server-adjacent runtime policy such as `review` and `goal_support`. `review` accepts `"off"`, `"basic"`, `"standard"`, and `"strict"` and defaults to `"strict"` when omitted. `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 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 `review`. `review` accepts `"off"`, `"basic"`, `"standard"`, and `"strict"` and defaults to `"strict"` when omitted. Phase-scoped goal support is mandatory and is not project-configurable. 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 16325e015..c4ebf6dc6 100644 --- a/docs/spec/workflow-file.md +++ b/docs/spec/workflow-file.md @@ -120,10 +120,9 @@ 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` and -`codex.review` 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. +Codex app-server-adjacent runtime settings such as `codex.review` 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]`