diff options
| author | Eric Yue <jiacheng.yue@foxmail.com> | 2026-09-14 22:33:30 +0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-14 17:33:30 +0300 |
| commit | 7e51b2832d99c56dc23a3915fe51fabe55754221 (patch) | |
| tree | 96dc515b4d5029f4c86f170aeba4aa15f9f06259 /src | |
| parent | 970b92682c1f1b0db0af46064ff0c9b8afcdc45b (diff) | |
fix: detect codex activity with static titles and queued inputs (#4099)
* fix: detect codex activity with static titles and queued inputs
refs #4092
Codex 0.154.0 can omit the activity bullet when tui.animations is false
and replace the literal Working label with a task-specific label. Its
terminal title can also remain static during an active turn, so the OSC
spinner rule is not sufficient to identify work.
The screen fallback required a bullet followed by Working in the last
three non-empty lines. It therefore missed both the animation-disabled
status line and dynamic labels. Queued follow-ups add a heading, input
text, and an edit hint below the activity line, moving that line outside
the three-line region. The lower-priority non-empty OSC title rule then
reported idle while Codex still showed an active timer and interrupt
control.
Recognize the elapsed-time and esc-to-interrupt controls in the detection
snapshot before the current composer. Allow an optional bullet, a dynamic
activity label, and queued follow-ups below that line. Require that no
later response, completion, or interruption marker supersedes the live
activity. This excludes composer text and stale output without letting an
earlier interruption suppress a subsequent active turn.
Keep OSC state precedence and transcript-viewer skip behavior unchanged.
Update the bundled and distributed manifests together to 2026.09.14.1.
Add regression coverage for optional bullets, dynamic labels, queued
inputs, resumed work after interruption, and stale or quoted controls.
Avoid discarding Regex search caches on every detection poll. The loader
previously cloned the entire compiled rule vector, and each Regex clone
created a fresh search cache pool despite sharing compiled read-only
state. Share the immutable rules through Arc instead. Reload publishes a
new array while in-flight readers retain a valid old generation, without
holding the global cache lock during matching. Add a cheap contains gate
for the required interrupt text before running the multiline expression.
Add a deterministic regression for compiled-rule sharing, reload
replacement, retained old readers, and concurrent detection. Measure the
baseline and optimized detector kernels in three alternating rounds on
CPU 24 with NUMA node 0 and CPU 25 with NUMA node 1. Real queued detection
falls from about 116 to 22 microseconds, and two evaluations per pane over
15 panes fall from about 3.45 to 0.65 milliseconds on both sockets, about
81 percent less time. No benchmark match results change. The cache
improvement requires the updated binary, not just a manifest reload.
Validate on the official Herdr 0.9.0 release with Codex 0.154.0 by toggling
the old and new manifests during the same active queued task. Confirm
working for active turns and idle after completion or interruption.
Pass 86 captured-screen and semantic checks through the optimized source
binary, 69 manifest/cache tests, CPU-pinned render scaling, and the full
native CI suite: 3535 Rust tests passed, with six default skips. Run CI
serially with inherited Herdr session context removed; no tests were
excluded or expectations relaxed to make CI pass.
* fix: detect codex pending follow-ups as working
refs #4092
* fix: handle wrapped codex follow-up hints
refs #4092
---------
Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/detect/manifest.rs | 14 | ||||
| -rw-r--r-- | src/detect/manifest/tests.rs | 125 | ||||
| -rw-r--r-- | src/detect/manifests/codex.toml | 13 |
3 files changed, 143 insertions, 9 deletions
diff --git a/src/detect/manifest.rs b/src/detect/manifest.rs index 1ca53228..acd6b8f9 100644 --- a/src/detect/manifest.rs +++ b/src/detect/manifest.rs @@ -1,6 +1,6 @@ use std::{ path::{Path, PathBuf}, - sync::{Mutex, OnceLock, RwLock}, + sync::{Arc, Mutex, OnceLock, RwLock}, }; use regex::Regex; @@ -123,7 +123,8 @@ pub struct RuleEvidence { #[derive(Debug, Clone)] struct LoadedManifest { manifest: AgentManifest, - compiled_rules: Vec<CompiledRule>, + // Keep Regex search caches warm across manifest loads and pane polling. + compiled_rules: Arc<[CompiledRule]>, source: ManifestSource, warning: Option<String>, cached_remote_version: Option<String>, @@ -453,7 +454,12 @@ fn evaluate_loaded_manifest( let mut matched: Option<(&ManifestRule, String)> = None; let mut evaluated_rules = Vec::new(); - for (rule, compiled_rule) in loaded.manifest.rules.iter().zip(&loaded.compiled_rules) { + for (rule, compiled_rule) in loaded + .manifest + .rules + .iter() + .zip(loaded.compiled_rules.iter()) + { let region_text = region(input, &rule.region); let matched_rule = compiled_rule_matches(compiled_rule, region_text); evaluated_rules.push(EvaluatedRule { @@ -703,7 +709,7 @@ fn loaded_manifest( cached_remote_version: Option<String>, local_override_shadowing_remote: bool, ) -> Result<LoadedManifest, String> { - let compiled_rules = compile_manifest(&manifest)?; + let compiled_rules = compile_manifest(&manifest)?.into(); Ok(LoadedManifest { manifest, compiled_rules, diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs index c5522952..bdb0789d 100644 --- a/src/detect/manifest/tests.rs +++ b/src/detect/manifest/tests.rs @@ -290,6 +290,74 @@ fn detection_uses_cached_manifest_until_explicit_reload() { } #[test] +fn compiled_rules_are_shared_until_manifest_reload() { + with_manifest_dirs("shared-compiled-rules", || { + write_remote_codex(&format!( + "{}\nregex = ['^cached-[a-z]+$']\n", + remote_manifest("9999.01.01.1", "blocked", "cached-ready") + )); + let first = load_manifest(Agent::Codex).unwrap(); + let second = load_manifest(Agent::Codex).unwrap(); + assert!(!first.compiled_rules.is_empty()); + assert_eq!( + first.compiled_rules.as_ptr(), + second.compiled_rules.as_ptr(), + "cached loads must retain the same compiled rules and regex search caches" + ); + + write_remote_codex_without_reload(&format!( + "{}\nregex = ['^new-[a-z]+$']\n", + remote_manifest("9999.01.01.2", "working", "new-ready") + )); + let unchanged = load_manifest(Agent::Codex).unwrap(); + assert_eq!( + first.compiled_rules.as_ptr(), + unchanged.compiled_rules.as_ptr() + ); + + reload_manifests_for_agents(&[Agent::Codex]); + let reloaded = load_manifest(Agent::Codex).unwrap(); + let shared_reload = load_manifest(Agent::Codex).unwrap(); + assert_ne!( + first.compiled_rules.as_ptr(), + reloaded.compiled_rules.as_ptr() + ); + assert_eq!( + reloaded.compiled_rules.as_ptr(), + shared_reload.compiled_rules.as_ptr() + ); + assert!(compiled_rule_matches( + &first.compiled_rules[0], + "cached-ready" + )); + assert!(!compiled_rule_matches( + &first.compiled_rules[0], + "new-ready" + )); + assert_eq!( + explain(Agent::Codex, "new-ready").state, + AgentState::Working + ); + + std::thread::scope(|scope| { + for _ in 0..4 { + let reloaded = &reloaded; + scope.spawn(move || { + let loaded = load_manifest(Agent::Codex).unwrap(); + assert_eq!( + loaded.compiled_rules.as_ptr(), + reloaded.compiled_rules.as_ptr() + ); + for _ in 0..8 { + assert_eq!(detect(Agent::Codex, "new-ready").state, AgentState::Working); + } + }); + } + }); + }); +} + +#[test] fn all_bundled_manifests_parse_and_validate() { for agent in Agent::SCREEN_MANIFEST_AGENTS { assert!( @@ -1105,6 +1173,50 @@ fn codex_screen_working_fallback_handles_static_osc_title() { } #[test] +fn codex_screen_working_fallback_handles_activity_labels_and_queued_inputs() { + for prefix in ["", "• ", "◦ "] { + for label in ["Working", "Fixing bug in queue region"] { + for queue in [ + "", + "\n• Queued follow-up inputs\n ↳ Follow up after this turn\n alt + ↑ edit last queued message\n", + "\n• Messages to be submitted after next tool call\n (press esc to interrupt and send immediately)\n ↳ Keep waiting until the sleep finishes.\n", + "\n• Messages to be submitted after next tool call (press esc to interrupt and send immediately)\n ↳ Keep waiting until the sleep finishes.\n", + "\n• Messages to be submitted after next tool call (press esc to interrupt\n and send immediately)\n ↳ Keep waiting until the sleep finishes.\n", + "\n• Messages to be submitted after next\n tool call (press esc to interrupt and\n send immediately)\n ↳ Keep waiting until the sleep finishes.\n", + "\n• Messages to be submitted at end of turn\n ↳ Follow up after this turn\n", + "\n• Messages to be submitted after next tool call\n (press esc to interrupt and send immediately)\n ↳ Keep waiting until the sleep finishes.\n\n• Queued follow-up inputs\n ↳ After this turn reply ok.\n alt + ↑ edit last queued message\n", + ] { + let screen = format!( + "{prefix}{label} (1m 16s • esc to interrupt) · 1 background terminal running · /ps to view · /stop to close\n\ + {queue}\n› Ask Codex to do anything\n\n model · /work\n" + ); + let result = osc_explain(Agent::Codex, &screen, "project", ""); + + assert_eq!(result.state, AgentState::Working, "{screen}"); + assert_eq!( + result.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("screen_working_fallback"), + "{screen}" + ); + assert!(result.visible_working); + } + } + } +} + +#[test] +fn codex_screen_working_fallback_uses_latest_activity_after_interruption() { + let screen = "■ Conversation interrupted\n\n\ + › Try again\n\n\ + Working (4s • esc to interrupt)\n\n\ + › Ask Codex to do anything\n\n model · /work\n"; + let result = osc_explain(Agent::Codex, screen, "project", ""); + + assert_eq!(result.state, AgentState::Working); + assert!(result.visible_working); +} + +#[test] fn codex_osc_working_remains_preferred_over_screen_fallback() { let screen = "• Working (4s • esc to interrupt)\n\n\ › Use /skills to list available skills\n\n\ @@ -1262,6 +1374,19 @@ fn codex_screen_working_fallback_ignores_stale_and_prompt_text() { " ◦ Working (1m 16s • esc to interrupt)\n\ › Use /skills to list available skills\n\ gpt-5.6-sol default · /work\n", + "Working (1m 16s • esc to interrupt)\n\ + • Finished the task\n\ + › Ask Codex to do anything\n", + "Working (1m 16s • esc to interrupt)\n\ + ■ Conversation interrupted\n\ + › Ask Codex to do anything\n", + "Working (1m 16s • esc to interrupt)\n\ + ─ Worked for 1m 16s ─\n\ + › Ask Codex to do anything\n", + "Working (1m 16s • esc to interrupt)\n•\nMessages to be submitted after next tool call\n› Ask Codex to do anything\n", + "› Explain this status:\n Working (1m 16s • esc to interrupt)\n", + "• Example (press esc to interrupt)\n\ + › Ask Codex to do anything\n", ]; for screen in screens { diff --git a/src/detect/manifests/codex.toml b/src/detect/manifests/codex.toml index 7840ca6f..5021567a 100644 --- a/src/detect/manifests/codex.toml +++ b/src/detect/manifests/codex.toml @@ -1,7 +1,7 @@ id = "codex" -version = "2026.09.12.1" +version = "2026.09.14.1" min_engine_version = 3 -updated_at = "2026-09-12T00:00:00Z" +updated_at = "2026-09-14T00:00:00Z" [[rules]] id = "osc_title_blocked" @@ -82,10 +82,13 @@ any = [ id = "screen_working_fallback" state = "working" priority = 500 -region = "bottom_non_empty_lines(3)" +region = "before_current_prompt_marker" visible_working = true -line_regex = ['^[•◦]\s+Working \([^)]*esc to interrupt\)(?: · .*)?$'] -not = [{ contains = ["■ Conversation interrupted"] }] +# Cheap negative filter before the multiline search. +contains = ["esc to interrupt"] +# Animations can hide the bullet, and queued inputs can sit below a dynamic activity label. +# Require the live timer/control suffix with no later response or interruption marker. +regex = ['(?m)^(?:[•◦][ \t]+)?[^\s›•◦■✗✓─][^\r\n]* \((?:[0-9]+[hm] )*[0-9]+s • esc to interrupt\)(?: · [^\r\n]*)?(?:\r?\n(?:[^•◦›■✗✓─\r\n][^\r\n]*|•[ \t]+(?:Queued\s+follow-up\s+inputs|Messages\s+to\s+be\s+submitted\s+after\s+next\s+tool\s+call(?:\s+\(press\s+esc\s+to\s+interrupt\s+and\s+send\s+immediately\))?|Messages\s+to\s+be\s+submitted\s+at\s+end\s+of\s+turn)|›[⠁⠂⠄⠈⠐⠠⡀⢀][^\r\n]*)?)*\s*\z'] [[rules]] id = "osc_title_idle" |
