From d0ae8e2a35d3e78e1619ee42db618c5416d5e1b0 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Tue, 12 May 2026 13:17:26 +0800 Subject: [PATCH] {"schema":"decodex/commit/1","summary":"Fix review performance and quality issues","authority":"manual"} --- apps/decodex/src/agent/app_server.rs | 4 +- apps/decodex/src/agent/app_server/tests.rs | 3 + apps/decodex/src/git_credentials.rs | 132 +- apps/decodex/src/github.rs | 46 +- apps/decodex/src/orchestrator/daemon.rs | 35 +- apps/decodex/src/orchestrator/execution.rs | 37 +- .../decodex/src/orchestrator/operator_http.rs | 22 +- .../src/orchestrator/pull_request_review.rs | 25 +- apps/decodex/src/orchestrator/status.rs | 32 +- .../orchestrator/tests/retry/scheduling.rs | 231 +- .../src/orchestrator/tests/retry/selection.rs | 160 +- .../review_landing/classification_checks.rs | 9 +- .../tests/review_landing/review_state.rs | 6 +- apps/decodex/src/orchestrator/types.rs | 2 +- apps/decodex/src/state/internal.rs | 20 +- apps/decodex/src/state/models.rs | 14 + apps/decodex/src/state/store.rs | 96 +- apps/decodex/src/tracker.rs | 16 + apps/decodex/src/tracker/linear.rs | 11 +- apps/decodex/src/worktree.rs | 45 +- scripts/github/backfill_release_range.py | 27 +- scripts/github/build_change_bundle.py | 37 +- scripts/github/build_release_delta.py | 116 +- scripts/github/contracts.py | 10 + scripts/github/run_codex_analysis.py | 29 +- scripts/github/sync_latest_signals.py | 13 +- scripts/github/test_build_release_delta.py | 3 +- site/.astro/collections/signals.schema.json | 12 + site/src/components/ReleaseDeltaPanel.astro | 311 +- site/src/components/ResetStatusWidget.astro | 21 +- site/src/components/SignalCard.astro | 23 + site/src/content.config.ts | 2 + .../release-deltas/openai-codex-latest.json | 17521 +--------------- site/src/lib/release-delta.ts | 18 +- site/src/lib/signal-feed.ts | 2 + site/src/pages/index.astro | 1 + 36 files changed, 1456 insertions(+), 17636 deletions(-) diff --git a/apps/decodex/src/agent/app_server.rs b/apps/decodex/src/agent/app_server.rs index a9d5e49..2a249a6 100644 --- a/apps/decodex/src/agent/app_server.rs +++ b/apps/decodex/src/agent/app_server.rs @@ -3346,9 +3346,7 @@ fn normalized_home_path(path: &Path) -> PathBuf { fn thread_resume_error_allows_fallback(error: &Report) -> bool { let message = error.to_string().to_lowercase(); - message.contains("no rollout found for thread id") - || message.contains("thread not found") - || message.contains("failed to load rollout") + message.contains("no rollout found for thread id") || message.contains("thread not found") } fn handle_dynamic_tool_call( diff --git a/apps/decodex/src/agent/app_server/tests.rs b/apps/decodex/src/agent/app_server/tests.rs index 9f33fa7..902227a 100644 --- a/apps/decodex/src/agent/app_server/tests.rs +++ b/apps/decodex/src/agent/app_server/tests.rs @@ -791,6 +791,9 @@ fn app_server_turn_failure_classifies_operator_attention() { fn thread_resume_fallback_only_allows_missing_thread_errors() { assert!(super::thread_resume_error_allows_fallback(&eyre::eyre!("thread not found"))); assert!(super::thread_resume_error_allows_fallback(&eyre::eyre!( + "no rollout found for thread id thread-1" + ))); + assert!(!super::thread_resume_error_allows_fallback(&eyre::eyre!( "failed to load rollout from disk" ))); assert!(!super::thread_resume_error_allows_fallback(&eyre::eyre!( diff --git a/apps/decodex/src/git_credentials.rs b/apps/decodex/src/git_credentials.rs index c4da583..099bffb 100644 --- a/apps/decodex/src/git_credentials.rs +++ b/apps/decodex/src/git_credentials.rs @@ -1,6 +1,6 @@ #[cfg(unix)] use std::os::unix::fs::PermissionsExt as _; use std::{ - fs, + env, fs, io::ErrorKind, path::{Path, PathBuf}, process::{self, Command}, @@ -18,6 +18,8 @@ const GITHUB_SSH_URL_PREFIXES: &[&str] = &[ "ssh://git@github.com-x/", "ssh://git@github.com-y/", ]; +const GIT_CONFIG_ENV_REMOVE_FLOOR: usize = 64; + static NEXT_ASKPASS_ID: AtomicU64 = AtomicU64::new(0); #[derive(Clone, Copy)] @@ -47,44 +49,6 @@ impl<'a> GitCredentialSource<'a> { } } -#[derive(Clone, Default, Eq, PartialEq)] -pub(crate) enum GitSigningConfig { - #[default] - Preserve, - DisableInherited, - SigningKey(String), -} -impl GitSigningConfig { - pub(crate) fn from_local_git_config(repo_root: &Path) -> Result { - let output = Command::new("git") - .arg("-C") - .arg(repo_root) - .args(["config", "--local", "--includes", "--get", "user.signingkey"]) - .output()?; - - if output.status.success() { - let signing_key = String::from_utf8_lossy(&output.stdout).trim().to_owned(); - - return if signing_key.is_empty() { - Ok(Self::DisableInherited) - } else { - Ok(Self::SigningKey(signing_key)) - }; - } - if output.status.code() == Some(1) { - return Ok(Self::Preserve); - } - - let stderr = String::from_utf8_lossy(&output.stderr); - - eyre::bail!( - "Failed to inspect local Git signing key in `{}`: {}", - repo_root.display(), - stderr.trim() - ); - } -} - #[derive(Clone, Default, Eq, PartialEq)] pub(crate) struct GitCredentialEnvironment { github_token_env_var: Option, @@ -121,6 +85,8 @@ impl GitCredentialEnvironment { } pub(crate) fn apply_to(&self, command: &mut Command) { + clear_injected_git_config(command); + command .env("GH_PROMPT_DISABLED", "1") .env("GIT_TERMINAL_PROMPT", "0") @@ -183,6 +149,7 @@ impl GitAskpassGuard { Ok(Self { path }) } } + impl Drop for GitAskpassGuard { fn drop(&mut self) { if let Err(error) = fs::remove_file(&self.path) @@ -197,6 +164,59 @@ impl Drop for GitAskpassGuard { } } +#[derive(Clone, Default, Eq, PartialEq)] +pub(crate) enum GitSigningConfig { + #[default] + Preserve, + DisableInherited, + SigningKey(String), +} +impl GitSigningConfig { + pub(crate) fn from_local_git_config(repo_root: &Path) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(repo_root) + .args(["config", "--local", "--includes", "--get", "user.signingkey"]) + .output()?; + + if output.status.success() { + let signing_key = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + + return if signing_key.is_empty() { + Ok(Self::DisableInherited) + } else { + Ok(Self::SigningKey(signing_key)) + }; + } + if output.status.code() == Some(1) { + return Ok(Self::Preserve); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + + eyre::bail!( + "Failed to inspect local Git signing key in `{}`: {}", + repo_root.display(), + stderr.trim() + ); + } +} + +pub(crate) fn clear_injected_git_config(command: &mut Command) { + let config_count = env::var("GIT_CONFIG_COUNT") + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + + command.env_remove("GIT_CONFIG_COUNT"); + command.env_remove("GIT_CONFIG_PARAMETERS"); + + for index in 0..config_count.max(GIT_CONFIG_ENV_REMOVE_FLOOR) { + command.env_remove(format!("GIT_CONFIG_KEY_{index}")); + command.env_remove(format!("GIT_CONFIG_VALUE_{index}")); + } +} + pub(crate) fn scoped_github_askpass_path(root: &Path, label: &str) -> PathBuf { let safe_label = sanitize_path_component(label); let id = NEXT_ASKPASS_ID.fetch_add(1, Ordering::Relaxed); @@ -240,3 +260,37 @@ fn sanitize_path_component(value: &str) -> String { if sanitized.is_empty() { String::from("git") } else { sanitized } } + +#[cfg(test)] +mod tests { + use std::{ffi::OsStr, process::Command}; + + use crate::git_credentials::GitCredentialEnvironment; + + #[test] + fn apply_to_scrubs_inherited_git_config_injection() { + let mut command = Command::new("git"); + + command + .env("GIT_CONFIG_PARAMETERS", "commit.gpgsign=true") + .env("GIT_CONFIG_COUNT", "1") + .env("GIT_CONFIG_KEY_0", "commit.gpgsign") + .env("GIT_CONFIG_VALUE_0", "true"); + + GitCredentialEnvironment::default().apply_to(&mut command); + + assert_env_removed(&command, "GIT_CONFIG_PARAMETERS"); + assert_env_removed(&command, "GIT_CONFIG_COUNT"); + assert_env_removed(&command, "GIT_CONFIG_KEY_0"); + assert_env_removed(&command, "GIT_CONFIG_VALUE_0"); + } + + fn assert_env_removed(command: &Command, name: &str) { + let target = OsStr::new(name); + + assert!( + command.get_envs().any(|(key, value)| key == target && value.is_none()), + "`{name}` should be explicitly removed from child environment" + ); + } +} diff --git a/apps/decodex/src/github.rs b/apps/decodex/src/github.rs index 1016765..4e14ea7 100644 --- a/apps/decodex/src/github.rs +++ b/apps/decodex/src/github.rs @@ -5,10 +5,12 @@ use std::{ time::{Duration, Instant}, }; +use color_eyre::Report; use serde::Deserialize; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use crate::{ + git_credentials, prelude::{Result, eyre}, pull_request::PullRequestLandingState, }; @@ -211,6 +213,8 @@ struct CommitViewCommit { } pub(crate) fn configure_gh_command(command: &mut Command, github_token: &str) { + git_credentials::clear_injected_git_config(command); + command .env("GH_TOKEN", github_token) .env("GITHUB_TOKEN", github_token) @@ -453,7 +457,8 @@ pub(crate) fn wait_for_pull_request_merge_commit( match inspect_pull_request_merge_commit(cwd, pr_url, github_token) { Ok(merge_commit) => return Ok(merge_commit), Err(error) if Instant::now() >= deadline => return Err(error), - Err(_error) => {}, + Err(error) if merge_commit_wait_error_is_retryable(&error) => {}, + Err(error) => return Err(error), }; thread::sleep(Duration::from_secs(1)); @@ -515,7 +520,8 @@ pub(crate) fn wait_for_commit_subject( match inspect_commit_subject(cwd, pr_url, commit_oid, github_token) { Ok(subject) => return Ok(subject), Err(error) if Instant::now() >= deadline => return Err(error), - Err(_error) => {}, + Err(error) if commit_subject_wait_error_is_retryable(&error) => {}, + Err(error) => return Err(error), }; thread::sleep(Duration::from_secs(1)); @@ -753,10 +759,26 @@ fn next_pull_request_review_threads_cursor( }) } +fn merge_commit_wait_error_is_retryable(error: &Report) -> bool { + let message = error.to_string(); + + message.contains("did not reach `MERGED` state after landing") + || message.contains("does not expose a merge commit after merge") +} + +fn commit_subject_wait_error_is_retryable(error: &Report) -> bool { + let message = error.to_string().to_ascii_lowercase(); + + message.contains("failed to inspect merge commit") + && (message.contains("not found") || message.contains("http 404")) +} + #[cfg(test)] mod tests { use std::ffi::OsStr; + use crate::prelude::eyre; + #[test] fn parses_pull_request_url() { let locator = super::parse_pull_request_url("https://github.com/hack-ink/decodex/pull/20") @@ -833,6 +855,26 @@ mod tests { ); } + #[test] + fn merge_commit_wait_retries_only_visibility_errors() { + assert!(super::merge_commit_wait_error_is_retryable(&eyre::eyre!( + "Pull request `https://github.com/hack-ink/decodex/pull/1` does not expose a merge commit after merge." + ))); + assert!(!super::merge_commit_wait_error_is_retryable(&eyre::eyre!( + "Failed to inspect merge result for `https://github.com/hack-ink/decodex/pull/1`: HTTP 401" + ))); + } + + #[test] + fn commit_subject_wait_retries_only_not_found_visibility_errors() { + assert!(super::commit_subject_wait_error_is_retryable(&eyre::eyre!( + "Failed to inspect merge commit `abc` for `https://github.com/hack-ink/decodex/pull/1`: HTTP 404 Not Found" + ))); + assert!(!super::commit_subject_wait_error_is_retryable(&eyre::eyre!( + "Failed to inspect merge commit `abc` for `https://github.com/hack-ink/decodex/pull/1`: HTTP 401 Unauthorized" + ))); + } + #[test] fn repository_match_rejects_foreign_pull_request_url() { let repository = super::RepositoryContext { diff --git a/apps/decodex/src/orchestrator/daemon.rs b/apps/decodex/src/orchestrator/daemon.rs index f707d72..99d41bc 100644 --- a/apps/decodex/src/orchestrator/daemon.rs +++ b/apps/decodex/src/orchestrator/daemon.rs @@ -791,38 +791,11 @@ where T: IssueTracker, { let now = Instant::now(); + let Some(first_entry) = retry_queue.next_entry().cloned() else { + return Ok(RetryDispatchDecision::Continue); + }; - loop { - let Some(first_entry) = retry_queue.next_entry().cloned() else { - return Ok(RetryDispatchDecision::Continue); - }; - - if now >= first_entry.ready_at { - break; - } - - let Some(issue) = refresh_issue(tracker, &first_entry.issue_id)? else { - clear_retry_schedule_and_release(retry_queue, state_store, &first_entry.issue_id)?; - - continue; - }; - - if matches!( - evaluate_retry_entry_retention_policy( - tracker, - &issue, - project, - workflow, - state_store, - &first_entry, - )?, - RetryEntryRetentionDecision::Drop - ) { - clear_retry_schedule_and_release(retry_queue, state_store, &first_entry.issue_id)?; - - continue; - } - + if now < first_entry.ready_at { tracing::debug!( issue_id = first_entry.issue_id, retry_kind = ?first_entry.kind, diff --git a/apps/decodex/src/orchestrator/execution.rs b/apps/decodex/src/orchestrator/execution.rs index a318944..44a2099 100644 --- a/apps/decodex/src/orchestrator/execution.rs +++ b/apps/decodex/src/orchestrator/execution.rs @@ -204,9 +204,15 @@ where { let body = format!("Decodex execution event: {}", record.event_type); - tracker::create_linear_execution_event_comment(tracker, issue_id, &body, record)?; + if state_store.record_linear_execution_event(record)? + && let Err(error) = tracker::create_linear_execution_event_comment_without_remote_scan( + tracker, issue_id, &body, record, + ) + { + state_store.forget_linear_execution_event(&record.idempotency_key)?; - state_store.record_linear_execution_event(record)?; + return Err(error); + } Ok(()) } @@ -1186,15 +1192,26 @@ where }, ); - tracker::create_linear_execution_event_comment( - tracker, - &issue_run.issue.id, - &comment, - &event, - )?; - if let Some(state_store) = runtime.state_store { - state_store.record_linear_execution_event(&event)?; + if state_store.record_linear_execution_event(&event)? + && let Err(error) = tracker::create_linear_execution_event_comment_without_remote_scan( + tracker, + &issue_run.issue.id, + &comment, + &event, + ) + { + state_store.forget_linear_execution_event(&event.idempotency_key)?; + + return Err(error); + } + } else { + tracker::create_linear_execution_event_comment( + tracker, + &issue_run.issue.id, + &comment, + &event, + )?; } Ok(TerminalFailureOutcome { diff --git a/apps/decodex/src/orchestrator/operator_http.rs b/apps/decodex/src/orchestrator/operator_http.rs index 7cbadb3..4043a7a 100644 --- a/apps/decodex/src/orchestrator/operator_http.rs +++ b/apps/decodex/src/orchestrator/operator_http.rs @@ -75,6 +75,10 @@ impl DashboardEventHub { clients.retain(|client| client.send(event.clone()).is_ok()); } + fn has_clients(&self) -> bool { + self.clients.lock().is_ok_and(|clients| !clients.is_empty()) + } + #[cfg(test)] fn close_clients_for_test(&self) { if let Ok(mut clients) = self.clients.lock() { @@ -355,6 +359,13 @@ fn run_operator_run_activity_websocket_broadcasts( Ok(()) | Err(RecvTimeoutError::Disconnected) => return, Err(RecvTimeoutError::Timeout) => {}, } + + if !dashboard_events.has_clients() { + last_fingerprint = None; + + continue; + } + match build_operator_run_activity_event(&state_store) { Ok(event) => { if last_fingerprint.as_deref() == Some(event.fingerprint.as_slice()) { @@ -394,9 +405,10 @@ fn build_operator_run_activity_event(state_store: &StateStore) -> Result Result PullRequestReviewState { - PullRequestReviewState { +) -> Result { + Ok(PullRequestReviewState { url: pull_request.url.clone(), state: pull_request.state.clone(), is_draft: pull_request.is_draft, @@ -120,14 +120,14 @@ fn pull_request_review_state_from_page( .nodes .iter() .map(issue_comment_state_from_node) - .collect(), + .collect::>>()?, reviews: pull_request .reviews .nodes .iter() .filter_map(review_summary_state_from_node) .collect(), - } + }) } fn merge_pull_request_review_state_page( @@ -184,9 +184,13 @@ fn merge_pull_request_issue_comment_page( eyre::bail!("Pull request issue comment state changed while paginating `{}`.", review_state.url); } + let mut comment_ids = + review_state.issue_comments.iter().map(|comment| comment.database_id).collect::>(); + for comment in pull_request.comments.nodes.iter().map(issue_comment_state_from_node) { - if review_state.issue_comments.iter().any(|existing| existing.database_id == comment.database_id) - { + let comment = comment?; + + if !comment_ids.insert(comment.database_id) { eyre::bail!( "Pull request issue comments repeated while paginating `{}`.", review_state.url @@ -216,19 +220,18 @@ fn pull_request_status_check_rollup_state( fn issue_comment_state_from_node( comment: &PullRequestIssueCommentNode, -) -> PullRequestIssueCommentState { - PullRequestIssueCommentState { +) -> Result { + Ok(PullRequestIssueCommentState { database_id: comment.database_id, author_login: comment.author.as_ref().map(|author| author.login.clone()), body: comment.body.clone(), - created_at_unix_epoch: parse_github_timestamp_to_unix_epoch(&comment.created_at) - .expect("pull request issue comment timestamp should parse"), + created_at_unix_epoch: parse_github_timestamp_to_unix_epoch(&comment.created_at)?, external_review_eyes_reaction_count: reaction_group_actor_count( &comment.reaction_groups, "EYES", EXTERNAL_REVIEW_ACTOR_LOGIN, ), - } + }) } fn review_summary_state_from_node( diff --git a/apps/decodex/src/orchestrator/status.rs b/apps/decodex/src/orchestrator/status.rs index dad9d07..e6ab8ea 100644 --- a/apps/decodex/src/orchestrator/status.rs +++ b/apps/decodex/src/orchestrator/status.rs @@ -137,20 +137,18 @@ fn build_operator_status_snapshot( limit: usize, ) -> crate::prelude::Result { let now_unix_epoch = OffsetDateTime::now_utc().unix_timestamp(); - let mut active_runs = state_store - .list_active_runs(project.service_id())? + let (active_runs, recent_runs) = state_store.list_project_runs(project.service_id(), limit)?; + let recent_runs = recent_runs .into_iter() - .map(|run| operator_run_status(project, state_store, run, now_unix_epoch)) + .map(|run| operator_run_status(project, run, now_unix_epoch)) + .collect::>>()?; + let mut active_runs = active_runs + .into_iter() + .map(|run| operator_run_status(project, run, now_unix_epoch)) .collect::>>()? .into_iter() .filter(operator_run_counts_as_active) .collect::>(); - let recent_run_fetch_limit = limit.saturating_add(active_runs.len()); - let recent_runs = state_store - .list_recent_runs(project.service_id(), recent_run_fetch_limit)? - .into_iter() - .map(|run| operator_run_status(project, state_store, run, now_unix_epoch)) - .collect::>>()?; let mut active_run_ids = active_runs.iter().map(|run| run.run_id.clone()).collect::>(); @@ -3270,12 +3268,11 @@ fn hydrate_status_snapshot_state( fn operator_run_status( project: &ServiceConfig, - state_store: &StateStore, run: ProjectRunStatus, now_unix_epoch: i64, ) -> crate::prelude::Result { let marker = load_operator_run_marker(&run)?; - let timing = operator_run_timing(state_store, run.run_id(), marker.as_ref(), now_unix_epoch)?; + let timing = operator_run_timing(&run, marker.as_ref(), now_unix_epoch); let app_server_state = operator_run_app_server_state(&run, marker.as_ref()); let protocol_summary = operator_run_protocol_summary(&run, marker.as_ref()); let status = @@ -3404,18 +3401,17 @@ fn load_operator_run_marker( } fn operator_run_timing( - state_store: &StateStore, - run_id: &str, + run: &ProjectRunStatus, marker: Option<&RunActivityMarker>, now_unix_epoch: i64, -) -> crate::prelude::Result { +) -> OperatorRunTiming { let process_id = marker.and_then(RunActivityMarker::process_id); let last_run_activity_unix_epoch = max_optional_i64( - state_store.last_run_activity_unix_epoch(run_id)?, + Some(run.last_run_activity_unix_epoch()), marker.and_then(RunActivityMarker::last_activity_unix_epoch), ); let last_protocol_activity_unix_epoch = max_optional_i64( - state_store.last_protocol_activity_unix_epoch(run_id)?, + run.last_event_at_unix(), marker.and_then(RunActivityMarker::last_protocol_activity_unix_epoch), ); let last_progress_unix_epoch = max_optional_i64( @@ -3423,7 +3419,7 @@ fn operator_run_timing( last_protocol_activity_unix_epoch, ); - Ok(OperatorRunTiming { + OperatorRunTiming { process_alive: process_id.map(process_is_alive), process_id, last_run_activity_unix_epoch, @@ -3434,7 +3430,7 @@ fn operator_run_timing( last_protocol_activity_unix_epoch, now_unix_epoch, ), - }) + } } fn operator_run_app_server_state( diff --git a/apps/decodex/src/orchestrator/tests/retry/scheduling.rs b/apps/decodex/src/orchestrator/tests/retry/scheduling.rs index 5c61147..e37acd0 100644 --- a/apps/decodex/src/orchestrator/tests/retry/scheduling.rs +++ b/apps/decodex/src/orchestrator/tests/retry/scheduling.rs @@ -607,125 +607,132 @@ fn schedule_retry_after_child_exit_keeps_blocked_closeout_retry_for_completed_is } #[test] -fn queued_review_repair_retry_handles_backoff_budget_and_ownership() { - { - let (_temp_dir, config, workflow) = temp_project_layout(); - let issue = sample_service_owned_issue("In Review"); - let tracker = - FakeTracker::with_refresh_snapshots(vec![issue.clone()], vec![vec![issue.clone()]]); - let state_store = StateStore::open_in_memory().expect("state store should open"); - let mut retry_queue = RetryQueue::default(); +fn future_review_repair_retry_keeps_backoff_window_until_due() { + let (_temp_dir, config, workflow) = temp_project_layout(); + let issue = sample_service_owned_issue("In Review"); + let tracker = + FakeTracker::with_refresh_snapshots(vec![issue.clone()], vec![vec![issue.clone()]]); + let state_store = StateStore::open_in_memory().expect("state store should open"); + let mut retry_queue = RetryQueue::default(); - retry_queue.upsert(RetryEntry { - issue_id: issue.id.clone(), - retry_project_slug: issue - .project_slug - .clone() - .expect("sample issue should carry a project slug"), - continuation_initial_issue_state: None, - dispatch_mode: IssueDispatchMode::ReviewRepair, - kind: RetryKind::Failure, - attempt: 1, - ready_at: Instant::now() + Duration::from_secs(60), - }); - - let decision = orchestrator::plan_due_retry_run( - &mut retry_queue, - &tracker, - &config, - &workflow, - &state_store, - ) - .expect("future review-repair retry should stay queued"); - - assert!(matches!( - decision, - RetryDispatchDecision::Blocked{ excluded_issue_ids } - if excluded_issue_ids == vec![issue.id.clone()] - )); - assert!( - retry_queue.entries.contains_key(&issue.id), - "review-repair retries should keep their queued backoff window until ready" - ); - } - { - let (_temp_dir, config, workflow) = temp_project_layout(); - let issue = sample_service_owned_issue("In Review"); - let tracker = - FakeTracker::with_refresh_snapshots(vec![issue.clone()], vec![vec![issue.clone()]]); - let state_store = StateStore::open_in_memory().expect("state store should open"); - let mut retry_queue = RetryQueue::default(); + retry_queue.upsert(RetryEntry { + issue_id: issue.id.clone(), + retry_project_slug: issue + .project_slug + .clone() + .expect("sample issue should carry a project slug"), + continuation_initial_issue_state: None, + dispatch_mode: IssueDispatchMode::ReviewRepair, + kind: RetryKind::Failure, + attempt: 1, + ready_at: Instant::now() + Duration::from_secs(60), + }); - for attempt in 1..=3 { - state_store - .record_run_attempt(&format!("run-{attempt}"), &issue.id, attempt, "failed") - .expect("failed repair attempt should record"); - } + let decision = orchestrator::plan_due_retry_run( + &mut retry_queue, + &tracker, + &config, + &workflow, + &state_store, + ) + .expect("future review-repair retry should stay queued"); - retry_queue.upsert(RetryEntry { - issue_id: issue.id.clone(), - retry_project_slug: issue - .project_slug - .clone() - .expect("sample issue should carry a project slug"), - continuation_initial_issue_state: None, - dispatch_mode: IssueDispatchMode::ReviewRepair, - kind: RetryKind::Failure, - attempt: 3, - ready_at: Instant::now() + Duration::from_secs(60), - }); - - let decision = orchestrator::plan_due_retry_run( - &mut retry_queue, - &tracker, - &config, - &workflow, - &state_store, - ) - .expect("exhausted review-repair retry should be dropped"); + assert!(matches!( + decision, + RetryDispatchDecision::Blocked{ excluded_issue_ids } + if excluded_issue_ids == vec![issue.id.clone()] + )); + assert!( + retry_queue.entries.contains_key(&issue.id), + "review-repair retries should keep their queued backoff window until ready" + ); + assert_eq!( + tracker.refresh_snapshots.borrow().len(), + 1, + "future review-repair retry planning should not refresh tracker state before the retry is due" + ); +} - assert!(matches!(decision, RetryDispatchDecision::Continue)); - assert!( - retry_queue.entries.is_empty(), - "exhausted review-repair retry should not hold the queued claim" - ); +#[test] +fn due_review_repair_retry_drops_after_backoff_budget_exhausted() { + let (_temp_dir, config, workflow) = temp_project_layout(); + let issue = sample_service_owned_issue("In Review"); + let tracker = + FakeTracker::with_refresh_snapshots(vec![issue.clone()], vec![vec![issue.clone()]]); + let state_store = StateStore::open_in_memory().expect("state store should open"); + let mut retry_queue = RetryQueue::default(); + + for attempt in 1..=3 { + state_store + .record_run_attempt(&format!("run-{attempt}"), &issue.id, attempt, "failed") + .expect("failed repair attempt should record"); } - { - let (_temp_dir, config, workflow) = temp_project_layout(); - let issue = sample_issue("In Review", &[]); - let tracker = - FakeTracker::with_refresh_snapshots(vec![issue.clone()], vec![vec![issue.clone()]]); - let state_store = StateStore::open_in_memory().expect("state store should open"); - let mut retry_queue = RetryQueue::default(); - retry_queue.upsert(RetryEntry { - issue_id: issue.id.clone(), - retry_project_slug: issue - .project_slug - .clone() - .expect("sample issue should carry a project slug"), - continuation_initial_issue_state: None, - dispatch_mode: IssueDispatchMode::ReviewRepair, - kind: RetryKind::Failure, - attempt: 1, - ready_at: Instant::now() + Duration::from_secs(60), - }); - - let decision = orchestrator::plan_due_retry_run( - &mut retry_queue, - &tracker, - &config, - &workflow, - &state_store, - ) - .expect("review-repair retry planning should succeed"); + retry_queue.upsert(RetryEntry { + issue_id: issue.id.clone(), + retry_project_slug: issue + .project_slug + .clone() + .expect("sample issue should carry a project slug"), + continuation_initial_issue_state: None, + dispatch_mode: IssueDispatchMode::ReviewRepair, + kind: RetryKind::Failure, + attempt: 3, + ready_at: Instant::now(), + }); - assert!(matches!(decision, RetryDispatchDecision::Continue)); - assert!( - !retry_queue.entries.contains_key(&issue.id), - "review-repair retries should be dropped when active ownership is gone" - ); - } + let decision = orchestrator::plan_due_retry_run( + &mut retry_queue, + &tracker, + &config, + &workflow, + &state_store, + ) + .expect("exhausted review-repair retry should be dropped"); + + assert!(matches!(decision, RetryDispatchDecision::Continue)); + assert!( + retry_queue.entries.is_empty(), + "exhausted review-repair retry should not hold the queued claim" + ); +} + +#[test] +fn due_review_repair_retry_drops_when_active_ownership_is_gone() { + let (_temp_dir, config, workflow) = temp_project_layout(); + let issue = sample_issue("In Review", &[]); + let tracker = + FakeTracker::with_refresh_snapshots(vec![issue.clone()], vec![vec![issue.clone()]]); + let state_store = StateStore::open_in_memory().expect("state store should open"); + let mut retry_queue = RetryQueue::default(); + + retry_queue.upsert(RetryEntry { + issue_id: issue.id.clone(), + retry_project_slug: issue + .project_slug + .clone() + .expect("sample issue should carry a project slug"), + continuation_initial_issue_state: None, + dispatch_mode: IssueDispatchMode::ReviewRepair, + kind: RetryKind::Failure, + attempt: 1, + ready_at: Instant::now(), + }); + + let decision = orchestrator::plan_due_retry_run( + &mut retry_queue, + &tracker, + &config, + &workflow, + &state_store, + ) + .expect("review-repair retry planning should succeed"); + + assert!(matches!(decision, RetryDispatchDecision::Continue)); + assert!( + !retry_queue.entries.contains_key(&issue.id), + "review-repair retries should be dropped when active ownership is gone" + ); } #[test] diff --git a/apps/decodex/src/orchestrator/tests/retry/selection.rs b/apps/decodex/src/orchestrator/tests/retry/selection.rs index 62ca14d..755a7da 100644 --- a/apps/decodex/src/orchestrator/tests/retry/selection.rs +++ b/apps/decodex/src/orchestrator/tests/retry/selection.rs @@ -81,6 +81,11 @@ fn queued_retry_blocks_normal_candidate_selection_until_due() { if excluded_issue_ids == vec![issue.id.clone()] )); assert!(!retry_queue.is_empty(), "future retry should keep the queued claim"); + assert_eq!( + tracker.refresh_snapshots.borrow().len(), + 1, + "future retry planning should not refresh tracker state before the retry is due" + ); } #[test] @@ -121,6 +126,11 @@ fn queued_retry_stays_blocked_when_project_lookup_blips_before_due_time() { if excluded_issue_ids == vec![issue.id.clone()] )); assert!(!retry_queue.is_empty(), "future retry should keep the queued claim"); + assert_eq!( + tracker.refresh_snapshots.borrow().len(), + 1, + "future retry planning should not refresh tracker state before the retry is due" + ); } #[test] @@ -260,53 +270,97 @@ fn future_retry_claim_stays_blocked_when_issue_moves_to_another_project_before_d retry_queue.entries.contains_key(&issue.id), "future retries should keep their queued claim until due when the issue is still active" ); + assert_eq!( + tracker.refresh_snapshots.borrow().len(), + 1, + "future retry planning should not refresh tracker state before the retry is due" + ); } #[test] -fn retry_claim_releases_when_issue_becomes_non_active() { - for (ready_delay, description) in [ - (Duration::from_secs(60), "future"), - (Duration::from_secs(0), "due"), - ] { - let (_temp_dir, config, workflow) = temp_project_layout(); - let issue = selection_sample_service_owned_issue("In Review"); - let tracker = - FakeTracker::with_refresh_snapshots(vec![issue.clone()], vec![vec![issue.clone()]]); - let state_store = StateStore::open_in_memory().expect("state store should open"); - let mut retry_queue = RetryQueue::default(); - - retry_queue.upsert(RetryEntry { - issue_id: issue.id.clone(), - retry_project_slug: issue - .project_slug - .clone() - .expect("sample issue should carry a project slug"), - continuation_initial_issue_state: None, - dispatch_mode: IssueDispatchMode::Retry, - kind: RetryKind::Failure, - attempt: 1, - ready_at: Instant::now() + ready_delay, - }); - - let decision = orchestrator::plan_due_retry_run( - &mut retry_queue, - &tracker, - &config, - &workflow, - &state_store, - ) - .expect("retry planning should succeed"); - - assert!(matches!(decision, orchestrator::RetryDispatchDecision::Continue)); - assert!( - retry_queue.is_empty(), - "{description} non-active issue should release the queued claim" - ); - } +fn future_retry_claim_stays_blocked_when_issue_becomes_non_active_before_due_time() { + let (_temp_dir, config, workflow) = temp_project_layout(); + let issue = selection_sample_service_owned_issue("In Review"); + let tracker = + FakeTracker::with_refresh_snapshots(vec![issue.clone()], vec![vec![issue.clone()]]); + let state_store = StateStore::open_in_memory().expect("state store should open"); + let mut retry_queue = RetryQueue::default(); + + retry_queue.upsert(RetryEntry { + issue_id: issue.id.clone(), + retry_project_slug: issue + .project_slug + .clone() + .expect("sample issue should carry a project slug"), + continuation_initial_issue_state: None, + dispatch_mode: IssueDispatchMode::Retry, + kind: RetryKind::Failure, + attempt: 1, + ready_at: Instant::now() + Duration::from_secs(60), + }); + + let decision = orchestrator::plan_due_retry_run( + &mut retry_queue, + &tracker, + &config, + &workflow, + &state_store, + ) + .expect("retry planning should succeed"); + + assert!(matches!( + decision, + RetryDispatchDecision::Blocked{ excluded_issue_ids } + if excluded_issue_ids == vec![issue.id.clone()] + )); + assert!( + retry_queue.entries.contains_key(&issue.id), + "future retry should keep the local claim until due instead of polling remote state" + ); + assert_eq!( + tracker.refresh_snapshots.borrow().len(), + 1, + "future retry planning should not refresh tracker state before the retry is due" + ); } #[test] -fn future_retry_claim_release_clears_persisted_retry_marker() { +fn due_retry_claim_releases_when_issue_becomes_non_active() { + let (_temp_dir, config, workflow) = temp_project_layout(); + let issue = selection_sample_service_owned_issue("In Review"); + let tracker = + FakeTracker::with_refresh_snapshots(vec![issue.clone()], vec![vec![issue.clone()]]); + let state_store = StateStore::open_in_memory().expect("state store should open"); + let mut retry_queue = RetryQueue::default(); + + retry_queue.upsert(RetryEntry { + issue_id: issue.id.clone(), + retry_project_slug: issue + .project_slug + .clone() + .expect("sample issue should carry a project slug"), + continuation_initial_issue_state: None, + dispatch_mode: IssueDispatchMode::Retry, + kind: RetryKind::Failure, + attempt: 1, + ready_at: Instant::now(), + }); + + let decision = orchestrator::plan_due_retry_run( + &mut retry_queue, + &tracker, + &config, + &workflow, + &state_store, + ) + .expect("retry planning should succeed"); + + assert!(matches!(decision, orchestrator::RetryDispatchDecision::Continue)); + assert!(retry_queue.is_empty(), "due non-active issue should release the queued claim"); +} + +#[test] +fn due_retry_claim_release_clears_persisted_retry_marker() { let (_temp_dir, config, workflow) = temp_project_layout(); let issue = selection_sample_service_owned_issue("In Review"); let tracker = @@ -329,7 +383,7 @@ fn future_retry_claim_release_clears_persisted_retry_marker() { "run-1", 1, "failure", - OffsetDateTime::now_utc().unix_timestamp() + 60, + OffsetDateTime::now_utc().unix_timestamp(), ) .expect("retry schedule should write"); @@ -343,7 +397,7 @@ fn future_retry_claim_release_clears_persisted_retry_marker() { dispatch_mode: IssueDispatchMode::Retry, kind: RetryKind::Failure, attempt: 1, - ready_at: Instant::now() + Duration::from_secs(60), + ready_at: Instant::now(), }); let decision = orchestrator::plan_due_retry_run( @@ -356,7 +410,7 @@ fn future_retry_claim_release_clears_persisted_retry_marker() { .expect("retry planning should succeed"); assert!(matches!(decision, orchestrator::RetryDispatchDecision::Continue)); - assert!(retry_queue.is_empty(), "non-active issue should release the queued claim early"); + assert!(retry_queue.is_empty(), "non-active issue should release the queued claim when due"); let marker = state::read_run_activity_marker_snapshot(&worktree_path) .expect("marker should load") @@ -506,7 +560,7 @@ fn due_continuation_retry_stays_queued_when_global_concurrency_is_exhausted() { } #[test] -fn future_retry_claim_releases_when_issue_returns_to_todo_before_due_time() { +fn future_retry_claim_stays_blocked_when_issue_returns_to_todo_before_due_time() { let (_temp_dir, config, workflow) = temp_project_layout(); let issue = selection_sample_service_owned_issue("Todo"); let tracker = @@ -536,8 +590,20 @@ fn future_retry_claim_releases_when_issue_returns_to_todo_before_due_time() { ) .expect("retry planning should succeed"); - assert!(matches!(decision, orchestrator::RetryDispatchDecision::Continue)); - assert!(retry_queue.is_empty(), "todo issues should not retain queued retry claims"); + assert!(matches!( + decision, + RetryDispatchDecision::Blocked{ excluded_issue_ids } + if excluded_issue_ids == vec![issue.id.clone()] + )); + assert!( + retry_queue.entries.contains_key(&issue.id), + "future retry should keep the local claim until due instead of polling remote state" + ); + assert_eq!( + tracker.refresh_snapshots.borrow().len(), + 1, + "future retry planning should not refresh tracker state before the retry is due" + ); } #[test] diff --git a/apps/decodex/src/orchestrator/tests/review_landing/classification_checks.rs b/apps/decodex/src/orchestrator/tests/review_landing/classification_checks.rs index eb5ac9b..b2bd69b 100644 --- a/apps/decodex/src/orchestrator/tests/review_landing/classification_checks.rs +++ b/apps/decodex/src/orchestrator/tests/review_landing/classification_checks.rs @@ -515,7 +515,8 @@ fn merge_pull_request_review_state_page_counts_unresolved_threads_across_pages() let mut review_state = orchestrator::pull_request_review_state_from_page( &repository, repository.pull_request.as_ref().expect("pull request should exist"), - ); + ) + .expect("review state should build"); let next_page = sample_pull_request_review_state_page( "https://github.com/hack-ink/decodex/pull/174", "x/pubfi-pub-101", @@ -550,7 +551,8 @@ fn merge_pull_request_issue_comment_page_appends_comments_across_pages() { let mut review_state = orchestrator::pull_request_review_state_from_page( &repository, repository.pull_request.as_ref().expect("pull request should exist"), - ); + ) + .expect("review state should build"); let next_page = PullRequestIssueCommentsNode { url: String::from("https://github.com/hack-ink/decodex/pull/174"), comments: PullRequestIssueCommentConnection { @@ -617,7 +619,8 @@ fn assert_review_state_page_rejects_changed_metadata( let mut review_state = orchestrator::pull_request_review_state_from_page( &repository, repository.pull_request.as_ref().expect("pull request should exist"), - ); + ) + .expect("review state should build"); let mut next_page = sample_pull_request_review_state_page( "https://github.com/hack-ink/decodex/pull/174", "x/pubfi-pub-101", diff --git a/apps/decodex/src/orchestrator/tests/review_landing/review_state.rs b/apps/decodex/src/orchestrator/tests/review_landing/review_state.rs index e0e1b6c..4eba9a9 100644 --- a/apps/decodex/src/orchestrator/tests/review_landing/review_state.rs +++ b/apps/decodex/src/orchestrator/tests/review_landing/review_state.rs @@ -50,7 +50,8 @@ fn pull_request_review_state_from_page_scopes_signals_to_external_review_actor() let review_state = orchestrator::pull_request_review_state_from_page( &repository, repository.pull_request.as_ref().expect("pull request should exist"), - ); + ) + .expect("review state should build"); assert_eq!(review_state.issue_description_external_review_thumbs_up_count, 1); assert_eq!(review_state.issue_comments.len(), 1); @@ -85,7 +86,8 @@ fn pull_request_review_state_from_page_skips_pending_reviews_without_submitted_t let review_state = orchestrator::pull_request_review_state_from_page( &repository, repository.pull_request.as_ref().expect("pull request should exist"), - ); + ) + .expect("review state should build"); assert!(review_state.reviews.is_empty()); } diff --git a/apps/decodex/src/orchestrator/types.rs b/apps/decodex/src/orchestrator/types.rs index eb9c302..fe20e20 100644 --- a/apps/decodex/src/orchestrator/types.rs +++ b/apps/decodex/src/orchestrator/types.rs @@ -971,7 +971,7 @@ impl PullRequestReviewStateInspector for GhPullRequestReviewStateInspector { comments_after = next_pull_request_issue_comments_cursor(&pull_request.comments, pr_url)?; - review_state = Some(pull_request_review_state_from_page(&repository, pull_request)); + review_state = Some(pull_request_review_state_from_page(&repository, pull_request)?); next_cursor }, diff --git a/apps/decodex/src/state/internal.rs b/apps/decodex/src/state/internal.rs index db52a4c..136eca0 100644 --- a/apps/decodex/src/state/internal.rs +++ b/apps/decodex/src/state/internal.rs @@ -133,12 +133,14 @@ impl StateData { thread_id: attempt.thread_id.clone(), turn_id: attempt.turn_id.clone(), updated_at: attempt.updated_at.clone(), + updated_at_unix: attempt.updated_at_unix, branch_name: worktree.map(|mapping| mapping.branch_name.clone()), worktree_path: worktree.map(|mapping| mapping.worktree_path.clone()), active_lease, event_count: event_summary.event_count, last_event_type: event_summary.last_event_type, last_event_at: event_summary.last_event_at, + last_event_at_unix: event_summary.last_event_at_unix, }) } @@ -369,14 +371,13 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value; Ok(changed == 1) } - fn upsert_linear_execution_event( + fn insert_linear_execution_event_if_absent( &self, record: &LinearExecutionEventRuntimeRecord, - ) -> Result<()> { + ) -> Result { let payload_json = serde_json::to_string(&record.record)?; - - self.connection.execute( - "INSERT OR REPLACE INTO linear_execution_events ( + let changed = self.connection.execute( + "INSERT OR IGNORE INTO linear_execution_events ( idempotency_key, service_id, issue_id, event_type, event_timestamp, event_unix, payload_json, recorded_at, recorded_at_unix ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", @@ -393,6 +394,15 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value; ], )?; + Ok(changed == 1) + } + + fn delete_linear_execution_event(&self, idempotency_key: &str) -> Result<()> { + self.connection.execute( + "DELETE FROM linear_execution_events WHERE idempotency_key = ?1", + params![idempotency_key], + )?; + Ok(()) } diff --git a/apps/decodex/src/state/models.rs b/apps/decodex/src/state/models.rs index 774acb9..ce6385b 100644 --- a/apps/decodex/src/state/models.rs +++ b/apps/decodex/src/state/models.rs @@ -80,12 +80,14 @@ pub struct ProjectRunStatus { thread_id: Option, turn_id: Option, updated_at: String, + updated_at_unix: i64, branch_name: Option, worktree_path: Option, active_lease: bool, event_count: i64, last_event_type: Option, last_event_at: Option, + last_event_at_unix: Option, } impl ProjectRunStatus { /// Stable run identifier. @@ -152,6 +154,18 @@ impl ProjectRunStatus { pub fn last_event_at(&self) -> Option<&str> { self.last_event_at.as_deref() } + + /// Unix timestamp of the latest recorded protocol event, when one exists. + pub(crate) fn last_event_at_unix(&self) -> Option { + self.last_event_at_unix + } + + pub(crate) fn last_run_activity_unix_epoch(&self) -> i64 { + match self.last_event_at_unix { + Some(last_event_at_unix) => self.updated_at_unix.max(last_event_at_unix), + None => self.updated_at_unix, + } + } } /// Worktree mapping for one issue lane. diff --git a/apps/decodex/src/state/store.rs b/apps/decodex/src/state/store.rs index f4d20b7..db89557 100644 --- a/apps/decodex/src/state/store.rs +++ b/apps/decodex/src/state/store.rs @@ -753,6 +753,34 @@ impl StateStore { Ok(runs) } + /// List active and recent run attempts for one project from one durable snapshot. + pub(crate) fn list_project_runs( + &self, + project_id: &str, + base_recent_limit: usize, + ) -> Result<(Vec, Vec)> { + let state = self.lock()?; + let mut runs = state + .run_attempts + .values() + .filter_map(|attempt| state.project_run_status(project_id, attempt)) + .collect::>(); + + runs.sort_by(compare_project_run_status); + + let active_runs = runs + .iter() + .filter(|status| status.active_lease()) + .cloned() + .collect::>(); + let recent_limit = base_recent_limit.saturating_add(active_runs.len()); + let mut recent_runs = runs; + + recent_runs.truncate(recent_limit); + + Ok((active_runs, recent_runs)) + } + /// List all active leased runs for one project without applying the recent-run limit. pub fn list_active_runs(&self, project_id: &str) -> Result> { let state = self.lock()?; @@ -780,17 +808,18 @@ impl StateStore { _payload: &str, ) -> Result<()> { let mut state = self.lock_without_refresh()?; - - if state - .events - .get(run_id) - .is_some_and(|events| events.iter().any(|event| event.sequence_number == sequence_number)) - { - eyre::bail!( - "Protocol event `{run_id}` sequence `{sequence_number}` already exists in the runtime journal." - ); - } - + let insert_index = { + let events = state.events.entry(run_id.to_owned()).or_default(); + + match events.binary_search_by_key(&sequence_number, |event| event.sequence_number) { + Ok(_index) => { + eyre::bail!( + "Protocol event `{run_id}` sequence `{sequence_number}` already exists in the runtime journal." + ); + }, + Err(index) => index, + } + }; let now = timestamp_parts(); let event = ProtocolEventRecord { sequence_number, @@ -810,11 +839,7 @@ impl StateStore { .entry(run_id.to_owned()) .or_default() .record_event(&event); - - let events = state.events.entry(run_id.to_owned()).or_default(); - - events.push(event); - events.sort_by_key(|event| event.sequence_number); + state.events.entry(run_id.to_owned()).or_default().insert(insert_index, event); Ok(()) } @@ -827,22 +852,36 @@ impl StateStore { records::validate_linear_execution_event_record(record).map_err(|error| eyre::eyre!(error))?; let now = timestamp_parts(); - let mut state = self.lock_without_refresh()?; let idempotency_key = record.idempotency_key.clone(); - let is_new = !state.linear_execution_events.contains_key(&idempotency_key); + let mut state = self.lock_without_refresh()?; + + if state.linear_execution_events.contains_key(&idempotency_key) { + return Ok(false); + } + let runtime_record = LinearExecutionEventRuntimeRecord { record: record.clone(), event_unix: parse_linear_execution_event_unix(record), recorded_at: now.text, recorded_at_unix: now.unix, }; + let is_new = self.insert_linear_execution_event_if_absent_locked(&runtime_record)?; - state.linear_execution_events.insert(idempotency_key, runtime_record.clone()); - self.upsert_linear_execution_event_locked(&runtime_record)?; + if is_new { + state.linear_execution_events.insert(idempotency_key, runtime_record); + } Ok(is_new) } + pub(crate) fn forget_linear_execution_event(&self, idempotency_key: &str) -> Result<()> { + let mut state = self.lock_without_refresh()?; + + state.linear_execution_events.remove(idempotency_key); + + self.delete_linear_execution_event_locked(idempotency_key) + } + /// List locally cached Linear execution events for one issue lane. pub(crate) fn list_linear_execution_events( &self, @@ -1151,10 +1190,21 @@ impl StateStore { sqlite.append_protocol_event(run_id, event) } - fn upsert_linear_execution_event_locked( + fn insert_linear_execution_event_if_absent_locked( &self, record: &LinearExecutionEventRuntimeRecord, - ) -> Result<()> { + ) -> Result { + let Some(sqlite) = self.sqlite.as_ref() else { + return Ok(true); + }; + let sqlite = sqlite + .lock() + .map_err(|_| eyre::eyre!("StateStore SQLite mutex is poisoned."))?; + + sqlite.insert_linear_execution_event_if_absent(record) + } + + fn delete_linear_execution_event_locked(&self, idempotency_key: &str) -> Result<()> { let Some(sqlite) = self.sqlite.as_ref() else { return Ok(()); }; @@ -1162,7 +1212,7 @@ impl StateStore { .lock() .map_err(|_| eyre::eyre!("StateStore SQLite mutex is poisoned."))?; - sqlite.upsert_linear_execution_event(record) + sqlite.delete_linear_execution_event(idempotency_key) } fn delete_lease_locked(&self, issue_id: &str) -> Result<()> { diff --git a/apps/decodex/src/tracker.rs b/apps/decodex/src/tracker.rs index eb0e978..b12f863 100644 --- a/apps/decodex/src/tracker.rs +++ b/apps/decodex/src/tracker.rs @@ -246,3 +246,19 @@ where Ok(true) } + +pub(crate) fn create_linear_execution_event_comment_without_remote_scan( + tracker: &T, + issue_id: &str, + body: &str, + record: &LinearExecutionEventRecord, +) -> Result<()> +where + T: IssueTracker + ?Sized, +{ + records::validate_linear_execution_event_record(record).map_err(|error| eyre::eyre!(error))?; + + let comment_body = records::append_structured_comment_record(body, record)?; + + tracker.create_comment(issue_id, &comment_body) +} diff --git a/apps/decodex/src/tracker/linear.rs b/apps/decodex/src/tracker/linear.rs index 066c882..97e08b0 100644 --- a/apps/decodex/src/tracker/linear.rs +++ b/apps/decodex/src/tracker/linear.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use reqwest::blocking::Client; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -275,6 +277,8 @@ query TeamLabelByName($teamId: ID!, $labelName: String!) { } } "#; +const LINEAR_HTTP_TIMEOUT: Duration = Duration::from_secs(30); +const LINEAR_HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); pub(crate) struct LinearClient { api_token: String, @@ -282,7 +286,12 @@ pub(crate) struct LinearClient { } impl LinearClient { pub(crate) fn new(api_token: String) -> Result { - Ok(Self { api_token, http: Client::builder().build()? }) + let http = Client::builder() + .connect_timeout(LINEAR_HTTP_CONNECT_TIMEOUT) + .timeout(LINEAR_HTTP_TIMEOUT) + .build()?; + + Ok(Self { api_token, http }) } pub(crate) fn archive_issue(&self, issue_id: &str) -> Result<()> { diff --git a/apps/decodex/src/worktree.rs b/apps/decodex/src/worktree.rs index a798a37..fef74c0 100644 --- a/apps/decodex/src/worktree.rs +++ b/apps/decodex/src/worktree.rs @@ -19,6 +19,8 @@ use crate::{ }; const AFTER_CREATE_PENDING_MARKER: &str = ".decodex-after-create.pending"; +const WORKSPACE_HOOK_CAPTURE_LIMIT: usize = 1_024 * 1_024; +const WORKSPACE_HOOK_TRUNCATED_MARKER: &[u8] = b"\n[decodex truncated workspace hook output]\n"; #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct WorktreeSpec { @@ -989,7 +991,7 @@ where match reader.read(&mut chunk) { Ok(0) => return Ok(()), - Ok(read) => buffer.extend_from_slice(&chunk[..read]), + Ok(read) => append_capped_workspace_hook_output(buffer, &chunk[..read]), Err(error) if error.kind() == ErrorKind::WouldBlock => return Ok(()), Err(error) if error.kind() == ErrorKind::Interrupted => continue, Err(error) => { @@ -999,6 +1001,26 @@ where } } +fn append_capped_workspace_hook_output(buffer: &mut Vec, chunk: &[u8]) { + if buffer.len() >= WORKSPACE_HOOK_CAPTURE_LIMIT { + return; + } + + let remaining = WORKSPACE_HOOK_CAPTURE_LIMIT - buffer.len(); + + if chunk.len() <= remaining { + buffer.extend_from_slice(chunk); + + return; + } + + let marker_len = remaining.min(WORKSPACE_HOOK_TRUNCATED_MARKER.len()); + let chunk_len = remaining.saturating_sub(marker_len); + + buffer.extend_from_slice(&chunk[..chunk_len]); + buffer.extend_from_slice(&WORKSPACE_HOOK_TRUNCATED_MARKER[..marker_len]); +} + fn append_output_details(buffer: &mut String, output: &Output) { let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned(); let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); @@ -1196,7 +1218,7 @@ fn sanitize_branch_component(value: &str) -> String { #[cfg(test)] mod tests { use std::{ - env, fs, + fs, path::{Path, PathBuf}, process::Command, thread, @@ -1205,7 +1227,7 @@ mod tests { use tempfile::TempDir; - use crate::{workflow::WorkflowDocument, worktree::WorktreeManager}; + use crate::{git_credentials, workflow::WorkflowDocument, worktree::WorktreeManager}; fn workspace_hooks( workspace_hooks_frontmatter: &str, @@ -1257,26 +1279,11 @@ read_first = [] fn test_git_command() -> Command { let mut command = Command::new("git"); - clear_injected_git_config(&mut command); + git_credentials::clear_injected_git_config(&mut command); command } - fn clear_injected_git_config(command: &mut Command) { - let config_count = env::var("GIT_CONFIG_COUNT") - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(0); - - command.env_remove("GIT_CONFIG_COUNT"); - command.env_remove("GIT_CONFIG_PARAMETERS"); - - for index in 0..config_count { - command.env_remove(format!("GIT_CONFIG_KEY_{index}")); - command.env_remove(format!("GIT_CONFIG_VALUE_{index}")); - } - } - fn run_git(repo_root: &Path, args: &[&str]) { let output = test_git_command() .args(["-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false"]) diff --git a/scripts/github/backfill_release_range.py b/scripts/github/backfill_release_range.py index bff7ba1..d37e572 100644 --- a/scripts/github/backfill_release_range.py +++ b/scripts/github/backfill_release_range.py @@ -16,7 +16,7 @@ if str(SCRIPT_HOME) not in sys.path: sys.path.insert(0, str(SCRIPT_HOME)) -from build_change_bundle import build_pr_bundle, routed_token_env # noqa: E402 +from build_change_bundle import build_pr_bundle, github_request, routed_token_env # noqa: E402 from contracts import dump_json, load_json, validate_release_delta, validate_signal # noqa: E402 from sync_latest_signals import run_script # noqa: E402 @@ -74,25 +74,12 @@ def load_selected_comparison(path: Path, stable_tag: str, preview_tag: str | Non def pr_lookup(repo: str, pr_number: int, token: str | None) -> dict[str, Any]: - import urllib.request - import urllib.error - - request = urllib.request.Request( - f"https://api.github.com/repos/{repo}/pulls/{pr_number}", - headers={ - "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {token}" if token else "", - "User-Agent": "decodex-release-range-backfill", - }, - ) - if not token: - request.headers.pop("Authorization") - try: - with urllib.request.urlopen(request) as response: - return json.load(response) - except urllib.error.HTTPError as exc: - details = exc.read().decode("utf-8", errors="replace") - raise SystemExit(f"GitHub API request failed for PR #{pr_number}: {exc.code} {details}") from exc + payload, _headers = github_request(f"https://api.github.com/repos/{repo}/pulls/{pr_number}", token) + + if not isinstance(payload, dict): + raise SystemExit(f"Expected pull request object from GitHub for PR #{pr_number}") + + return payload def signal_paths(pr_number: int, args: argparse.Namespace) -> tuple[Path, Path, Path]: diff --git a/scripts/github/build_change_bundle.py b/scripts/github/build_change_bundle.py index c4906b0..80f5ec2 100644 --- a/scripts/github/build_change_bundle.py +++ b/scripts/github/build_change_bundle.py @@ -6,8 +6,11 @@ import argparse import json import os +import socket +import ssl import subprocess import sys +import time import urllib.error import urllib.parse import urllib.request @@ -28,6 +31,11 @@ validate_bundle, ) +RETRYABLE_HTTP_STATUS_CODES = {429, 500, 502, 503, 504} +GITHUB_REQUEST_ATTEMPTS = 4 +GITHUB_REQUEST_BACKOFF_SECONDS = 1.0 +GITHUB_REQUEST_TIMEOUT_SECONDS = 30.0 + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) @@ -60,6 +68,14 @@ def routed_token_env() -> str | None: return {"x": "GITHUB_PAT_X", "y": "GITHUB_PAT_Y"}.get(identity, "GITHUB_TOKEN") +def is_retryable_github_error(exc: urllib.error.HTTPError | urllib.error.URLError) -> bool: + if isinstance(exc, urllib.error.HTTPError): + return exc.code in RETRYABLE_HTTP_STATUS_CODES + + reason = exc.reason + return isinstance(reason, (ConnectionResetError, TimeoutError, socket.timeout, ssl.SSLError)) + + def github_request(url: str, token: str | None) -> tuple[Any, dict[str, str]]: request = urllib.request.Request( url, @@ -71,12 +87,21 @@ def github_request(url: str, token: str | None) -> tuple[Any, dict[str, str]]: ) if not token: request.headers.pop("Authorization") - try: - with urllib.request.urlopen(request) as response: - return json.load(response), dict(response.headers) - except urllib.error.HTTPError as exc: - details = exc.read().decode("utf-8", errors="replace") - raise SystemExit(f"GitHub API request failed for {url}: {exc.code} {details}") from exc + + for attempt in range(1, GITHUB_REQUEST_ATTEMPTS + 1): + try: + with urllib.request.urlopen(request, timeout=GITHUB_REQUEST_TIMEOUT_SECONDS) as response: + return json.load(response), dict(response.headers) + except urllib.error.HTTPError as exc: + details = exc.read().decode("utf-8", errors="replace") + if not is_retryable_github_error(exc) or attempt == GITHUB_REQUEST_ATTEMPTS: + raise SystemExit(f"GitHub API request failed for {url}: {exc.code} {details}") from exc + except urllib.error.URLError as exc: + if not is_retryable_github_error(exc) or attempt == GITHUB_REQUEST_ATTEMPTS: + raise SystemExit(f"GitHub API request failed for {url}: {exc.reason}") from exc + time.sleep(GITHUB_REQUEST_BACKOFF_SECONDS * attempt) + + raise SystemExit(f"GitHub API request failed for {url}: exhausted retry loop") def github_paginated(url: str, token: str | None) -> list[Any]: diff --git a/scripts/github/build_release_delta.py b/scripts/github/build_release_delta.py index 4be61c1..7c3d196 100644 --- a/scripts/github/build_release_delta.py +++ b/scripts/github/build_release_delta.py @@ -36,6 +36,10 @@ RETRYABLE_HTTP_STATUS_CODES = {429, 500, 502, 503, 504} GITHUB_REQUEST_ATTEMPTS = 4 GITHUB_REQUEST_BACKOFF_SECONDS = 1.0 +GITHUB_REQUEST_TIMEOUT_SECONDS = 30.0 +DEFAULT_STABLE_LIMIT = 0 +DEFAULT_PREVIEW_LIMIT = 0 +DEFAULT_PAIR_LIMIT = 24 def parse_args() -> argparse.Namespace: @@ -48,20 +52,20 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--stable-limit", type=int, - default=0, + default=DEFAULT_STABLE_LIMIT, help="Maximum number of recent stable releases to include. Use 0 for all releases at or above the floor.", ) parser.add_argument( "--preview-limit", type=int, - default=0, + default=DEFAULT_PREVIEW_LIMIT, help="Maximum number of recent prereleases to include. Use 0 for all supported prereleases.", ) parser.add_argument( "--pair-limit", type=int, - default=0, - help="Maximum number of precomputed stable->preview compare entries. Use 0 for all valid pairs.", + default=DEFAULT_PAIR_LIMIT, + help="Maximum number of signal-bearing stable->preview compare entries. Use 0 for all valid pairs.", ) parser.add_argument( "--min-stable-tag", @@ -109,7 +113,7 @@ def github_request(url: str, token: str | None) -> Any: for attempt in range(1, GITHUB_REQUEST_ATTEMPTS + 1): try: - with urllib.request.urlopen(request) as response: + with urllib.request.urlopen(request, timeout=GITHUB_REQUEST_TIMEOUT_SECONDS) as response: return json.load(response) except urllib.error.HTTPError as exc: details = exc.read().decode("utf-8", errors="replace") @@ -123,6 +127,20 @@ def github_request(url: str, token: str | None) -> Any: raise SystemExit(f"GitHub API request failed for {url}: exhausted retry loop") +def github_releases(repo: str, token: str | None) -> list[dict[str, Any]]: + releases: list[dict[str, Any]] = [] + + for page in range(1, 6): + payload = github_request(f"https://api.github.com/repos/{repo}/releases?per_page=100&page={page}", token) + if not isinstance(payload, list): + raise SystemExit("Expected releases list payload from GitHub API") + releases.extend(payload) + if len(payload) < 100: + break + + return releases + + def select_release_pair(releases: list[dict[str, Any]], tag_prefix: str) -> tuple[dict[str, Any], dict[str, Any]]: relevant = [ release @@ -206,7 +224,6 @@ def release_sort_key(release: dict[str, Any]) -> str: def compare_candidates( stable_releases: list[dict[str, Any]], preview_releases: list[dict[str, Any]], - pair_limit: int, ) -> list[tuple[dict[str, Any], dict[str, Any]]]: candidates: list[tuple[dict[str, Any], dict[str, Any]]] = [] for stable in stable_releases: @@ -223,7 +240,7 @@ def compare_candidates( ), reverse=True, ) - return candidates[:pair_limit] if pair_limit > 0 else candidates + return candidates def extract_signal_commit_shas(signal: dict[str, Any]) -> set[str]: @@ -259,14 +276,52 @@ def load_signals(signals_dir: str | Path, repo: str) -> list[dict[str, Any]]: return entries +def previous_signal_pair_keys(path: Path) -> list[tuple[str, str]]: + if not path.exists(): + return [] + + try: + previous = load_json(path) + except (OSError, json.JSONDecodeError): + return [] + + keys: list[tuple[str, str]] = [] + seen: set[tuple[str, str]] = set() + for comparison in previous.get("comparisons", []): + if not comparison.get("tracked_signal_slugs"): + continue + key = (comparison.get("stable_tag_name"), comparison.get("prerelease_tag_name")) + if not all(isinstance(value, str) and value for value in key): + continue + if key in seen: + continue + seen.add(key) + keys.append(key) + return keys + + +def unique_release_pairs( + pairs: list[tuple[dict[str, Any], dict[str, Any]]], +) -> list[tuple[dict[str, Any], dict[str, Any]]]: + unique: list[tuple[dict[str, Any], dict[str, Any]]] = [] + seen: set[tuple[str, str]] = set() + + for stable, preview in pairs: + key = (stable["tag_name"], preview["tag_name"]) + if key in seen: + continue + seen.add(key) + unique.append((stable, preview)) + + return unique + + def main() -> None: args = parse_args() token_env = args.token_env or routed_token_env() or "GITHUB_TOKEN" token = os.environ.get(token_env) - releases = github_request(f"https://api.github.com/repos/{args.repo}/releases?per_page=100", token) - if not isinstance(releases, list): - raise SystemExit("Expected releases list payload from GitHub API") + releases = github_releases(args.repo, token) stable_release, prerelease = select_release_pair(releases, args.tag_prefix) stable_releases, preview_releases = select_release_options( releases, @@ -275,18 +330,20 @@ def main() -> None: args.preview_limit, args.min_stable_tag, ) - release_pairs = compare_candidates(stable_releases, preview_releases, args.pair_limit) + release_pairs = compare_candidates(stable_releases, preview_releases) default_pair = (stable_release, prerelease) - if not any( - pair[0]["tag_name"] == default_pair[0]["tag_name"] and pair[1]["tag_name"] == default_pair[1]["tag_name"] - for pair in release_pairs - ): - release_pairs = [default_pair, *release_pairs[: max(args.pair_limit - 1, 0)]] - - allowed_stable_tags = {stable["tag_name"] for stable, _ in release_pairs} - allowed_preview_tags = {preview["tag_name"] for _, preview in release_pairs} - stable_releases = [release for release in stable_releases if release["tag_name"] in allowed_stable_tags] - preview_releases = [release for release in preview_releases if release["tag_name"] in allowed_preview_tags] + releases_by_tag = {release["tag_name"]: release for release in [*stable_releases, *preview_releases]} + previous_pairs = [ + (releases_by_tag[stable_tag], releases_by_tag[preview_tag]) + for stable_tag, preview_tag in previous_signal_pair_keys(Path(args.out)) + if stable_tag in releases_by_tag and preview_tag in releases_by_tag + ] + if previous_pairs: + release_pairs = unique_release_pairs([default_pair, *previous_pairs]) + else: + release_pairs = unique_release_pairs([default_pair, *release_pairs]) + if args.pair_limit > 0: + release_pairs = release_pairs[: args.pair_limit] signal_entries = load_signals(args.signals_dir, args.repo) comparison_entries: list[dict[str, Any]] = [] @@ -294,6 +351,10 @@ def main() -> None: default_compare_payload: dict[str, Any] | None = None for stable_candidate, preview_candidate in release_pairs: + is_default_pair = ( + stable_candidate["tag_name"] == stable_release["tag_name"] + and preview_candidate["tag_name"] == prerelease["tag_name"] + ) compare = github_request( f"https://api.github.com/repos/{args.repo}/compare/{stable_candidate['tag_name']}...{preview_candidate['tag_name']}", token, @@ -338,16 +399,21 @@ def main() -> None: } ) - if ( - stable_candidate["tag_name"] == stable_release["tag_name"] - and preview_candidate["tag_name"] == prerelease["tag_name"] - ): + if is_default_pair: default_compare_payload = compare_payload default_tracked_signal_slugs = tracked_signal_slugs + if args.pair_limit > 0 and len(comparison_entries) >= args.pair_limit and default_compare_payload is not None: + break + if default_compare_payload is None: raise SystemExit("Default stable/prerelease pair was not included in comparison entries") + allowed_stable_tags = {entry["stable_tag_name"] for entry in comparison_entries} + allowed_preview_tags = {entry["prerelease_tag_name"] for entry in comparison_entries} + stable_releases = [release for release in stable_releases if release["tag_name"] in allowed_stable_tags] + preview_releases = [release for release in preview_releases if release["tag_name"] in allowed_preview_tags] + payload = { "schema": RELEASE_DELTA_SCHEMA, "repo": args.repo, diff --git a/scripts/github/contracts.py b/scripts/github/contracts.py index 910ed62..a925b27 100644 --- a/scripts/github/contracts.py +++ b/scripts/github/contracts.py @@ -202,6 +202,16 @@ def validate_signal(entry: dict[str, Any]) -> ValidationResult: if entry.get("how_to_try") and not entry.get("expected_effect"): errors.append("expected_effect is required when how_to_try is present") + caveats = entry.get("caveats", []) + if caveats is None: + caveats = [] + if not isinstance(caveats, list) or not all(isinstance(item, str) and item for item in caveats): + errors.append("caveats must be a list of non-empty strings when present") + + watch_state = entry.get("watch_state") + if watch_state is not None and (not isinstance(watch_state, str) or not watch_state): + errors.append("watch_state must be a non-empty string when present") + refs = entry.get("source_refs") if not isinstance(refs, dict): errors.append("source_refs must be an object") diff --git a/scripts/github/run_codex_analysis.py b/scripts/github/run_codex_analysis.py index 3f5ce41..e5298c3 100644 --- a/scripts/github/run_codex_analysis.py +++ b/scripts/github/run_codex_analysis.py @@ -112,19 +112,22 @@ def main() -> None: if args.model: cmd[2:2] = ["--model", args.model] - completed = subprocess.run(cmd, check=False, capture_output=True, text=True) - if completed.returncode != 0: - stderr = completed.stderr.strip() - stdout = completed.stdout.strip() - details = stderr or stdout or "unknown error" - raise SystemExit(f"codex exec failed: {details}") - - payload = extract_json_payload(tmp_output.read_text(encoding="utf-8")) - validation = validate_analysis_draft(payload) - if not validation.ok: - raise SystemExit("Analysis draft validation failed:\n- " + "\n- ".join(validation.errors)) - - dump_json(args.out, payload) + try: + completed = subprocess.run(cmd, check=False, capture_output=True, text=True) + if completed.returncode != 0: + stderr = completed.stderr.strip() + stdout = completed.stdout.strip() + details = stderr or stdout or "unknown error" + raise SystemExit(f"codex exec failed: {details}") + + payload = extract_json_payload(tmp_output.read_text(encoding="utf-8")) + validation = validate_analysis_draft(payload) + if not validation.ok: + raise SystemExit("Analysis draft validation failed:\n- " + "\n- ".join(validation.errors)) + + dump_json(args.out, payload) + finally: + tmp_output.unlink(missing_ok=True) print(args.out) diff --git a/scripts/github/sync_latest_signals.py b/scripts/github/sync_latest_signals.py index e47461f..6f37cf2 100644 --- a/scripts/github/sync_latest_signals.py +++ b/scripts/github/sync_latest_signals.py @@ -77,6 +77,11 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--token-env", help="Environment variable containing a GitHub token.") parser.add_argument("--codex-bin", default="codex", help="Codex executable to invoke.") parser.add_argument("--model", help="Optional Codex model override.") + parser.add_argument( + "--refresh-release-delta", + action="store_true", + help="Refresh the release-delta artifact even when no new signal entry was created.", + ) return parser.parse_args() @@ -213,7 +218,11 @@ def main() -> None: created += 1 run_script("validate_signal_entry.py", str(root / args.signals_dir)) - refresh_release_delta(args) + release_delta_refreshed = ( + created > 0 or args.refresh_release_delta or not (root / args.release_delta_out).exists() + ) + if release_delta_refreshed: + refresh_release_delta(args) print( json.dumps( { @@ -221,7 +230,7 @@ def main() -> None: "published_prs_seen": len(published), "recent_prs_scanned": len(candidates), "new_signals_created": created, - "release_delta_refreshed": True, + "release_delta_refreshed": release_delta_refreshed, }, sort_keys=True, ) diff --git a/scripts/github/test_build_release_delta.py b/scripts/github/test_build_release_delta.py index 2035bb0..2fd1eba 100644 --- a/scripts/github/test_build_release_delta.py +++ b/scripts/github/test_build_release_delta.py @@ -36,7 +36,8 @@ def test_retries_transient_ssl_eof(self) -> None: FakeResponse('{"ok": true}'), ] - def fake_urlopen(_request): + def fake_urlopen(_request, timeout=None): + self.assertEqual(timeout, build_release_delta.GITHUB_REQUEST_TIMEOUT_SECONDS) result = attempts.pop(0) if isinstance(result, Exception): raise result diff --git a/site/.astro/collections/signals.schema.json b/site/.astro/collections/signals.schema.json index 9eb65ad..29b191a 100644 --- a/site/.astro/collections/signals.schema.json +++ b/site/.astro/collections/signals.schema.json @@ -69,6 +69,18 @@ "type": "string", "minLength": 1 }, + "caveats": { + "default": [], + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "watch_state": { + "type": "string", + "minLength": 1 + }, "proof_points": { "minItems": 1, "type": "array", diff --git a/site/src/components/ReleaseDeltaPanel.astro b/site/src/components/ReleaseDeltaPanel.astro index 103a159..6c7dde1 100644 --- a/site/src/components/ReleaseDeltaPanel.astro +++ b/site/src/components/ReleaseDeltaPanel.astro @@ -157,6 +157,40 @@ const comparisonKeysByStable = Object.fromEntries( data-release-comparator data-compare-map={JSON.stringify(comparisonKeysByStable)} > +
+ + + From + + to + + + +
{comparisonEntries.map((entry) => (
- - - From - - to - - - {entry.compare.total_commits} commits ahead @@ -219,129 +221,6 @@ const comparisonKeysByStable = Object.fromEntries(
- { - manualRecommendedConfig.toggles.length > 0 ? ( -
-
-

Pre-release config

-
- -

- - {`${manualRecommendedConfig.curatorName}'s`} - - {" recommended pre-release config. "} - {manualRecommendedConfig.audienceNote} {manualRecommendedConfig.warning}{" "} - {manualRecommendedConfig.toggles.length} toggles for $CODEX_HOME/config.toml. -

- -
-

- Refs: - {" "} - - config.schema.json - - {" · "} - - config reference - -

-
- -
-
- - - -
- - - - - - - - - - {manualRecommendedConfig.toggles.map((toggle) => ( - - - - - - ))} - -
- Feature - Why this is onRef
-

- CONF - {toggle.display} -

-

- CLI - - {toggle.cliEnableFlag ?? `--enable ${toggle.name}`} - -

-
-

-

- {toggle.githubSearchUrl ? ( - - ) : null} -
-
-
- ) : null - } -
@@ -402,6 +281,128 @@ const comparisonKeysByStable = Object.fromEntries(
))} + { + manualRecommendedConfig.toggles.length > 0 ? ( +
+
+

Pre-release config

+
+ +

+ + {`${manualRecommendedConfig.curatorName}'s`} + + {" recommended pre-release config. "} + {manualRecommendedConfig.audienceNote} {manualRecommendedConfig.warning}{" "} + {manualRecommendedConfig.toggles.length} toggles for $CODEX_HOME/config.toml. +

+ +
+

+ Refs: + {" "} + + config.schema.json + + {" · "} + + config reference + +

+
+ +
+
+ + + +
+ + + + + + + + + + {manualRecommendedConfig.toggles.map((toggle) => ( + + + + + + ))} + +
+ Feature + Why this is onRef
+

+ CONF + {toggle.display} +

+

+ CLI + + {toggle.cliEnableFlag ?? `--enable ${toggle.name}`} + +

+
+

+

+ {toggle.githubSearchUrl ? ( + + ) : null} +
+
+
+ ) : null + }