Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 11 additions & 47 deletions apps/decodex/src/agent/app_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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}",
Expand All @@ -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,
Expand Down Expand Up @@ -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>,
}

Expand Down Expand Up @@ -1293,7 +1299,6 @@ pub(crate) fn probe_app_server(listen: &str) -> crate::prelude::Result<AppServer
dynamic_tool_handler: Some(&probe_tool_handler),
continuation_guard: None,
phase_goal_controller: None,
phase_goal_required: false,
codex_account_provider: None,
},
&state_store,
Expand Down Expand Up @@ -3506,20 +3511,7 @@ fn resolve_turn_completion(
};
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) => {
Err(error) if app_server_method_not_found(&error) => {
return Err(Report::new(AppServerPhaseGoalFailure::unsupported("thread/goal/get"))
.wrap_err(error));
},
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down
58 changes: 28 additions & 30 deletions apps/decodex/src/agent/app_server/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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})
Expand All @@ -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,
Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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>(
Expand Down Expand Up @@ -930,46 +933,43 @@ 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::<AppServerPhaseGoalFailure>()
.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::<AppServerPhaseGoalFailure>()
.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]
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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading