Skip to main content

ai_crew_sync/
codex_wake.rs

1//! `ai-crew-sync context codex-wake`: wake an idle Codex thread when the
2//! bus has something new for its window (#222).
3//!
4//! A Codex thread whose turn has ended calls no tools, so a message for its
5//! window waits until its human types. Codex has no background hook that
6//! can start a turn; its app-server can (`turn/start`). This adapter is the
7//! bridge, and it is explicit at every step:
8//!
9//! - It runs only when started, for one thread, named by the thread id.
10//! - The thread id must be one the window's own proxy recorded from Codex:
11//!   the binding's source is `request-meta` (`_meta.threadId` on the host's
12//!   own tool calls). Never a guess from a directory, a role or a name.
13//! - It acts as that window with the window's credential, read from the
14//!   private binding; the credential never reaches argv, a log, the model or
15//!   the app-server.
16//! - One adapter per thread. A second one is refused while the first is
17//!   alive.
18//! - It wakes only for news: a direct message newer than the last it woke
19//!   for, or more undelivered conversation messages. What was waiting when
20//!   it started does not wake the thread. The mark is written before the
21//!   turn is requested, so a crash costs at most one missed wake (the
22//!   messages stay unread on the bus), never a duplicate.
23//! - The turn it starts carries counts and where to read them, never a
24//!   message body: the model reads with its own ACS tools.
25//! - It never starts a second turn: an active thread is waited on until it
26//!   is idle. A thread that is not loaded is resumed first.
27//! - What it did is observable as presence, `wake_result` on the window:
28//!   `requested` once Codex accepted the turn, `confirmed` once Codex said
29//!   the turn started, `failed` when it gave up. None of these is a receipt:
30//!   a started turn is not a read message, let alone an answer.
31
32use std::{path::Path, time::Duration};
33
34use anyhow::{Context, bail};
35use serde_json::json;
36
37use crate::{
38    codex::{AppServer, thread_status},
39    context::{self, Binding},
40    hook::{self, Reading, WatchState},
41};
42
43/// The label the adapter announces as the window's wake mechanism.
44pub const WAKE_LABEL: &str = "codex-adapter";
45/// How long an announcement stands; renewed well before it lapses.
46const ANNOUNCE_TTL_SECS: u64 = 300;
47const ANNOUNCE_EVERY: Duration = Duration::from_secs(120);
48/// An adapter record not refreshed for this long belongs to a process that
49/// is gone, and may be taken over.
50const OWNER_STALE_SECS: i64 = 90;
51/// Longest single wait on the bus between two readings.
52const WAIT_SECS: u64 = 50;
53/// Pause after a wait that came back at once, so an answer that keeps
54/// returning immediately cannot become a busy loop.
55const FAST_RETURN_PAUSE: Duration = Duration::from_secs(5);
56/// Back-off when the bus cannot be reached.
57const RETRY_PAUSE: Duration = Duration::from_secs(30);
58/// Messages that arrive together wake the thread once.
59const SETTLE: Duration = Duration::from_secs(2);
60/// Attempts to deliver one wake, and the pause before each retry.
61const DELIVERY_BACKOFF: &[Duration] = &[
62    Duration::from_secs(2),
63    Duration::from_secs(5),
64    Duration::from_secs(15),
65];
66/// Longest wait for an active thread to become idle before a wake is
67/// given up as failed.
68const QUEUE_MAX: Duration = Duration::from_secs(30 * 60);
69/// How long to wait for Codex to say the requested turn started.
70const CONFIRM_WITHIN: Duration = Duration::from_secs(30);
71/// How often the thread is re-read while waiting on it: notifications only
72/// reach the connection that started or resumed a thread.
73const POLL_EVERY: Duration = Duration::from_secs(2);
74
75/// The adapter's record next to the binding (0600, in the 0700 directory).
76/// Holds no credential.
77#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
78struct AdapterState {
79    /// The adapter process that owns this thread.
80    token: String,
81    pid: u32,
82    /// Unix seconds of its last sign of life.
83    alive_at: i64,
84    /// What it has already woken the thread for.
85    mark: WatchState,
86}
87
88fn state_path(config_dir: &Path, thread: &str) -> std::path::PathBuf {
89    context::binding_path(config_dir, thread).with_extension("codex-wake")
90}
91
92fn read_state(path: &Path) -> Option<AdapterState> {
93    serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
94}
95
96fn now_secs() -> i64 {
97    chrono::Utc::now().timestamp()
98}
99
100/// How a run ended, for the command's exit message.
101#[derive(Debug, PartialEq, Eq)]
102pub enum Ended {
103    /// `--max-secs` passed.
104    Deadline,
105    /// The window closed: its binding no longer carries a credential.
106    WindowClosed,
107    /// Another adapter took the thread over after this one went quiet.
108    Replaced,
109    /// SIGINT or SIGTERM: the thread is released at once, so a new adapter
110    /// can start without waiting for this one's record to go stale.
111    Stopped,
112}
113
114/// Resolves on SIGINT, or SIGTERM where there is one.
115async fn stop_signal() {
116    #[cfg(unix)]
117    {
118        if let Ok(mut term) =
119            tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
120        {
121            tokio::select! {
122                _ = tokio::signal::ctrl_c() => {}
123                _ = term.recv() => {}
124            }
125            return;
126        }
127    }
128    let _ = tokio::signal::ctrl_c().await;
129}
130
131pub struct Options {
132    /// The Codex thread id, which is also the binding key.
133    pub thread: String,
134    /// The app-server's Unix socket.
135    pub socket: std::path::PathBuf,
136    /// Stop after this long; `None` runs until the window closes.
137    pub max_secs: Option<u64>,
138}
139
140/// The binding of `thread`, refused unless Codex itself named the thread.
141fn codex_binding(config_dir: &Path, thread: &str) -> anyhow::Result<Option<Binding>> {
142    match hook::resolve_binding(config_dir, thread) {
143        hook::Resolution::Authenticated(b) => {
144            if b.binding.as_deref() != Some(context::CODEX_THREAD_BINDING) {
145                bail!(
146                    "the window bound to '{thread}' was not bound by Codex (its source is {}): \
147                     the adapter only wakes a thread whose own tool calls named it. Start a \
148                     turn in that Codex thread that calls an ai-crew-sync tool (session_status \
149                     is enough), then start the adapter again.",
150                    b.binding.as_deref().unwrap_or("unrecorded")
151                );
152            }
153            Ok(Some(*b))
154        }
155        hook::Resolution::Unauthenticated(_) => Ok(None),
156        hook::Resolution::Missing => bail!(
157            "no ai-crew-sync window is bound to Codex thread '{thread}'. In that thread, call \
158             any ai-crew-sync tool once (session_status is enough) so its proxy records the \
159             binding, then start the adapter again."
160        ),
161    }
162}
163
164/// Run the adapter for one thread until `max_secs`, or until the window
165/// closes. Whatever ends the run, an error included, the thread is released
166/// and the wake announcement withdrawn on the way out.
167pub async fn run(config_dir: &Path, opts: &Options) -> anyhow::Result<Ended> {
168    if codex_binding(config_dir, &opts.thread)?.is_none() {
169        bail!(
170            "the window bound to Codex thread '{}' is closed; open the thread again so its \
171             proxy resumes the window",
172            opts.thread
173        );
174    }
175    // The thread must exist where we are going to wake it. Checked before
176    // taking ownership, so a failed start leaves nothing to wait out.
177    {
178        let mut app = AppServer::connect(&opts.socket, "ai-crew-sync-wake").await?;
179        let thread = app
180            .request("thread/read", json!({"threadId": opts.thread}))
181            .await
182            .with_context(|| {
183                format!(
184                    "the Codex app-server at {} does not know thread '{}'",
185                    opts.socket.display(),
186                    opts.thread
187                )
188            })?;
189        tracing::info!(
190            thread = %opts.thread,
191            status = thread_status(&thread["thread"]).unwrap_or("unknown"),
192            "codex wake adapter attached"
193        );
194    }
195    let path = state_path(config_dir, &opts.thread);
196    let token = uuid::Uuid::new_v4().to_string();
197    // Take the thread, unless a live adapter already has it.
198    let mark = context::with_config_lock(config_dir, || {
199        let prior = read_state(&path);
200        if let Some(p) = &prior
201            && now_secs() - p.alive_at < OWNER_STALE_SECS
202        {
203            bail!(
204                "another ai-crew-sync adapter (pid {}) is already waking this Codex thread; \
205                 stop it first, or wait {OWNER_STALE_SECS}s if it has died",
206                p.pid
207            );
208        }
209        let mark = prior.map(|p| p.mark).unwrap_or_default();
210        write_state(
211            &path,
212            &AdapterState {
213                token: token.clone(),
214                pid: std::process::id(),
215                alive_at: now_secs(),
216                mark: mark.clone(),
217            },
218        )?;
219        Ok(mark)
220    })?;
221
222    let outcome = watch(config_dir, opts, &path, &token, mark).await;
223    release(config_dir, opts, &path, &token).await;
224    outcome
225}
226
227/// Give the thread up: withdraw the announcement and mark the record free,
228/// keeping the mark for the next adapter. Only while still the owner.
229async fn release(config_dir: &Path, opts: &Options, path: &Path, token: &str) {
230    let owner = context::with_config_lock(config_dir, || {
231        Ok(read_state(path).is_some_and(|s| s.token == token))
232    })
233    .unwrap_or(false);
234    if !owner {
235        return;
236    }
237    if let Ok(Some(binding)) = codex_binding(config_dir, &opts.thread) {
238        announce(&binding, None, token).await;
239    }
240    let _ = context::with_config_lock(config_dir, || {
241        if let Some(mut s) = read_state(path).filter(|s| s.token == token) {
242            s.alive_at = 0;
243            write_state(path, &s)?;
244        }
245        Ok(())
246    });
247}
248
249/// Refresh the ownership record. False when another adapter has it now.
250fn refresh_owner(config_dir: &Path, path: &Path, token: &str) -> anyhow::Result<bool> {
251    context::with_config_lock(config_dir, || match read_state(path) {
252        Some(mut s) if s.token == token => {
253            s.alive_at = now_secs();
254            write_state(path, &s)?;
255            Ok(true)
256        }
257        _ => Ok(false),
258    })
259}
260
261/// Resolves at `deadline`, or never without one.
262async fn until(deadline: Option<tokio::time::Instant>) {
263    match deadline {
264        Some(d) => tokio::time::sleep_until(d).await,
265        None => std::future::pending().await,
266    }
267}
268
269/// How often the owner record and the announcement are kept alive while a
270/// delivery is in progress (it may wait on a busy thread for a long time).
271const KEEPALIVE_DURING_DELIVERY: Duration = Duration::from_secs(30);
272
273async fn watch(
274    config_dir: &Path,
275    opts: &Options,
276    path: &Path,
277    token: &str,
278    mut mark: WatchState,
279) -> anyhow::Result<Ended> {
280    let deadline = opts
281        .max_secs
282        .map(|s| tokio::time::Instant::now() + Duration::from_secs(s));
283    let mut announced: Option<tokio::time::Instant> = None;
284    let stop = stop_signal();
285    tokio::pin!(stop);
286    loop {
287        if deadline.is_some_and(|d| tokio::time::Instant::now() >= d) {
288            return Ok(Ended::Deadline);
289        }
290        if !refresh_owner(config_dir, path, token)? {
291            return Ok(Ended::Replaced);
292        }
293        let binding = match codex_binding(config_dir, &opts.thread)? {
294            Some(b) => b,
295            None => return Ok(Ended::WindowClosed),
296        };
297        if announced.is_none_or(|at| at.elapsed() >= ANNOUNCE_EVERY) {
298            announce(&binding, Some(ANNOUNCE_TTL_SECS), token).await;
299            announced = Some(tokio::time::Instant::now());
300        }
301
302        let reading = match hook::pending_for_window(&binding).await {
303            Ok(r) => r,
304            Err(e) => {
305                tracing::warn!(error = %e, "could not read the window's inbox; retrying");
306                tokio::select! {
307                    _ = &mut stop => return Ok(Ended::Stopped),
308                    _ = tokio::time::sleep(RETRY_PAUSE) => {}
309                }
310                continue;
311            }
312        };
313        if !mark.primed {
314            // What was waiting before the adapter started is not news: the
315            // thread's own next turn reports it. An inbox count that could
316            // not be read is read once more, so a passing failure is not
317            // taken for an empty inbox (and its backlog for news later).
318            let reading = match reading.inbox {
319                Some(_) => reading,
320                None => {
321                    tokio::time::sleep(SETTLE).await;
322                    hook::pending_for_window(&binding).await.unwrap_or(reading)
323                }
324            };
325            mark = primed(token, reading);
326            save_mark(config_dir, path, token, &mark)?;
327        } else {
328            let (wake, next) = hook::watch_step(&mark, reading);
329            mark = next;
330            if wake {
331                // Let a burst land, then wake once for all of it.
332                tokio::select! {
333                    _ = &mut stop => return Ok(Ended::Stopped),
334                    _ = tokio::time::sleep(SETTLE) => {}
335                }
336                if let Ok(later) = hook::pending_for_window(&binding).await {
337                    mark = hook::watch_step(&mark, later).1;
338                }
339                // Marked before the turn is requested: a crash between the
340                // two costs one missed wake, never a duplicate.
341                save_mark(config_dir, path, token, &mark)?;
342                if mark.direct == 0 && mark.inbox == 0 {
343                    // Read in the meantime: nothing left to wake it for.
344                    continue;
345                }
346                // The delivery can wait a long time on a busy thread: keep
347                // the record and the announcement alive, and honour a stop
348                // or the deadline meanwhile.
349                let delivery = deliver(&opts.socket, &opts.thread, &binding, &mark);
350                tokio::pin!(delivery);
351                let mut tick = tokio::time::interval(KEEPALIVE_DURING_DELIVERY);
352                tick.tick().await;
353                let result = loop {
354                    tokio::select! {
355                        r = &mut delivery => break r,
356                        _ = &mut stop => return Ok(Ended::Stopped),
357                        _ = until(deadline) => return Ok(Ended::Deadline),
358                        _ = tick.tick() => {
359                            if !refresh_owner(config_dir, path, token)? {
360                                return Ok(Ended::Replaced);
361                            }
362                            if announced.is_none_or(|at| at.elapsed() >= ANNOUNCE_EVERY) {
363                                announce(&binding, Some(ANNOUNCE_TTL_SECS), token).await;
364                                announced = Some(tokio::time::Instant::now());
365                            }
366                        }
367                    }
368                };
369                if let Some(result) = result {
370                    report(&binding, result).await;
371                }
372                continue;
373            }
374            save_mark(config_dir, path, token, &mark)?;
375        }
376
377        // Wait on the bus for the next message addressed to this window.
378        let started = tokio::time::Instant::now();
379        let waited = tokio::select! {
380            _ = &mut stop => return Ok(Ended::Stopped),
381            _ = until(deadline) => return Ok(Ended::Deadline),
382            w = hook::call_as_window(
383                &binding,
384                "wait_for_updates",
385                json!({"timeout_seconds": WAIT_SECS, "kinds": ["message"], "scope": "inbox"}),
386            ) => w,
387        };
388        if started.elapsed() < Duration::from_secs(2) {
389            let pause = if waited.is_err() {
390                RETRY_PAUSE
391            } else {
392                FAST_RETURN_PAUSE
393            };
394            tokio::select! {
395                _ = &mut stop => return Ok(Ended::Stopped),
396                _ = tokio::time::sleep(pause) => {}
397            }
398        }
399    }
400}
401
402fn write_state(path: &Path, state: &AdapterState) -> anyhow::Result<()> {
403    context::write_binding_file(path, &serde_json::to_string(state)?)
404}
405
406fn primed(token: &str, reading: Reading) -> WatchState {
407    WatchState {
408        token: token.to_owned(),
409        direct: reading.direct,
410        newest_direct: reading.newest_direct,
411        inbox: reading.inbox.unwrap_or(0),
412        primed: true,
413    }
414}
415
416fn save_mark(config_dir: &Path, path: &Path, token: &str, mark: &WatchState) -> anyhow::Result<()> {
417    context::with_config_lock(config_dir, || {
418        if let Some(mut s) = read_state(path).filter(|s| s.token == token) {
419            s.mark = mark.clone();
420            s.alive_at = now_secs();
421            write_state(path, &s)?;
422        }
423        Ok(())
424    })
425}
426
427/// Announce (`Some(ttl)`) or withdraw (`None`) the adapter as the window's
428/// wake mechanism. Descriptive, best effort.
429/// `token` is this adapter's: the bus clears an announcement only for the
430/// token that made it, so a stopping adapter never erases a successor's.
431async fn announce(binding: &Binding, ttl: Option<u64>, token: &str) {
432    let args = match ttl {
433        Some(secs) => json!({
434            "refresh_only": true,
435            "wake": WAKE_LABEL,
436            "wake_ttl_seconds": secs,
437            "wake_token": token,
438        }),
439        None => json!({"refresh_only": true, "wake": "", "wake_token": token}),
440    };
441    if let Err(e) = hook::call_as_window(binding, "heartbeat", args).await {
442        tracing::warn!(error = %e, "could not announce the wake mechanism");
443    }
444}
445
446/// What one wake came to.
447#[derive(Debug, PartialEq, Eq)]
448pub enum Delivery {
449    /// Codex accepted the turn and said it started.
450    Confirmed,
451    /// Codex accepted the turn; no start was seen in time.
452    Requested,
453    /// Every attempt failed.
454    Failed(String),
455}
456
457/// Record the outcome on the window's presence.
458async fn report(binding: &Binding, result: Delivery) {
459    let value = match &result {
460        Delivery::Confirmed => "confirmed",
461        Delivery::Requested => "requested",
462        Delivery::Failed(why) => {
463            tracing::warn!(reason = %why, "codex wake failed");
464            "failed"
465        }
466    };
467    if let Err(e) = hook::call_as_window(
468        binding,
469        "heartbeat",
470        json!({"refresh_only": true, "wake_result": value}),
471    )
472    .await
473    {
474        tracing::warn!(error = %e, "could not record the wake result");
475    }
476}
477
478/// Start one turn on the thread, with bounded retries, then confirm it.
479/// `None` when, after waiting for a busy thread, nothing is left unread: the
480/// thread's own turn took care of it. Retries cover only the steps before
481/// Codex accepted a turn; once it has, no second turn is ever requested for
482/// the same news.
483async fn deliver(
484    socket: &Path,
485    thread: &str,
486    binding: &Binding,
487    mark: &WatchState,
488) -> Option<Delivery> {
489    let mut last = String::new();
490    for (attempt, pause) in DELIVERY_BACKOFF.iter().enumerate() {
491        match start_turn(socket, thread, binding, mark).await {
492            Ok(Some((mut app, turn))) => return Some(confirm(&mut app, thread, &turn).await),
493            Ok(None) => return None,
494            Err(e) => {
495                last = format!("{e:#}");
496                tracing::warn!(attempt = attempt + 1, error = %last, "codex wake attempt failed");
497                tokio::time::sleep(*pause).await;
498            }
499        }
500    }
501    Some(Delivery::Failed(last))
502}
503
504/// Get the thread to idle and start the turn. The connection and the turn
505/// id on success.
506async fn start_turn(
507    socket: &Path,
508    thread: &str,
509    binding: &Binding,
510    mark: &WatchState,
511) -> anyhow::Result<Option<(AppServer, String)>> {
512    let mut app = AppServer::connect(socket, "ai-crew-sync-wake").await?;
513    let read = app
514        .request("thread/read", json!({"threadId": thread}))
515        .await?;
516    let mut status = thread_status(&read["thread"])
517        .unwrap_or("unknown")
518        .to_owned();
519    if status == "notLoaded" {
520        app.request(
521            "thread/resume",
522            json!({"threadId": thread, "excludeTurns": true}),
523        )
524        .await?;
525        status = "idle".into();
526    }
527    if status == "systemError" {
528        bail!("the Codex thread is in an error state");
529    }
530    let (mut direct, mut inbox) = (mark.direct, mark.inbox);
531    if status == "active" {
532        // Never a second turn: wait until the human's (or anyone's) turn is
533        // over, then look again: that turn may have read the news itself.
534        wait_until_idle(&mut app, thread).await?;
535        let now = hook::pending_for_window(binding).await?;
536        direct = now.direct;
537        inbox = now.inbox.unwrap_or(inbox);
538        if direct == 0 && inbox == 0 {
539            return Ok(None);
540        }
541    }
542    let started = app
543        .request(
544            "turn/start",
545            json!({
546                "threadId": thread,
547                "input": [{"type": "text", "text": hook::wake_text(direct, inbox)}],
548                // Names the news this turn is for, so it can be told apart
549                // in the thread's history.
550                "clientUserMessageId": format!(
551                    "acs-wake-{}-{}", mark.newest_direct, mark.inbox
552                ),
553            }),
554        )
555        .await?;
556    let Some(turn) = started["turn"]["id"].as_str().map(str::to_owned) else {
557        bail!("Codex accepted turn/start without a turn id");
558    };
559    Ok(Some((app, turn)))
560}
561
562/// Confirmed once Codex says the turn started. A connection that did not
563/// start or resume a thread is not sent its notifications (verified against
564/// codex-cli 0.163.0), so the turn list is read too. Never an error: the
565/// turn was accepted, and failing here must not lead to another one.
566async fn confirm(app: &mut AppServer, thread: &str, turn: &str) -> Delivery {
567    let until = tokio::time::Instant::now() + CONFIRM_WITHIN;
568    loop {
569        let left = until.saturating_duration_since(tokio::time::Instant::now());
570        if left.is_zero() {
571            return Delivery::Requested;
572        }
573        match app.next_notification(left.min(POLL_EVERY)).await {
574            Ok(Some(n))
575                if n["method"] == "turn/started"
576                    && n["params"]["threadId"] == thread
577                    && n["params"]["turn"]["id"] == turn =>
578            {
579                return Delivery::Confirmed;
580            }
581            Ok(_) => {}
582            Err(_) => return Delivery::Requested,
583        }
584        let Ok(turns) = app
585            .request(
586                "thread/turns/list",
587                json!({"threadId": thread, "limit": 10}),
588            )
589            .await
590        else {
591            return Delivery::Requested;
592        };
593        let started = turns["data"].as_array().is_some_and(|ts| {
594            ts.iter()
595                .any(|t| t["id"] == turn && !t["startedAt"].is_null())
596        });
597        if started {
598            return Delivery::Confirmed;
599        }
600    }
601}
602
603/// Wait for `thread` to become idle, re-reading its status now and then in
604/// case a notification was missed.
605async fn wait_until_idle(app: &mut AppServer, thread: &str) -> anyhow::Result<()> {
606    let until = tokio::time::Instant::now() + QUEUE_MAX;
607    loop {
608        let left = until.saturating_duration_since(tokio::time::Instant::now());
609        if left.is_zero() {
610            bail!("the Codex thread stayed busy for {}s", QUEUE_MAX.as_secs());
611        }
612        match app.next_notification(left.min(POLL_EVERY)).await? {
613            Some(n)
614                if n["method"] == "thread/status/changed" && n["params"]["threadId"] == thread =>
615            {
616                if thread_status(&n["params"]) == Some("idle") {
617                    return Ok(());
618                }
619            }
620            Some(_) => {}
621            None => {
622                let read = app
623                    .request("thread/read", json!({"threadId": thread}))
624                    .await?;
625                if thread_status(&read["thread"]) == Some("idle") {
626                    return Ok(());
627                }
628            }
629        }
630    }
631}