1use 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
43pub const WAKE_LABEL: &str = "codex-adapter";
45const ANNOUNCE_TTL_SECS: u64 = 300;
47const ANNOUNCE_EVERY: Duration = Duration::from_secs(120);
48const OWNER_STALE_SECS: i64 = 90;
51const WAIT_SECS: u64 = 50;
53const FAST_RETURN_PAUSE: Duration = Duration::from_secs(5);
56const RETRY_PAUSE: Duration = Duration::from_secs(30);
58const SETTLE: Duration = Duration::from_secs(2);
60const DELIVERY_BACKOFF: &[Duration] = &[
62 Duration::from_secs(2),
63 Duration::from_secs(5),
64 Duration::from_secs(15),
65];
66const QUEUE_MAX: Duration = Duration::from_secs(30 * 60);
69const CONFIRM_WITHIN: Duration = Duration::from_secs(30);
71const POLL_EVERY: Duration = Duration::from_secs(2);
74
75#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
78struct AdapterState {
79 token: String,
81 pid: u32,
82 alive_at: i64,
84 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#[derive(Debug, PartialEq, Eq)]
102pub enum Ended {
103 Deadline,
105 WindowClosed,
107 Replaced,
109 Stopped,
112}
113
114async 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 pub thread: String,
134 pub socket: std::path::PathBuf,
136 pub max_secs: Option<u64>,
138}
139
140fn 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
164pub 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 {
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 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
227async 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
249fn 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
261async 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
269const 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 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 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 save_mark(config_dir, path, token, &mark)?;
342 if mark.direct == 0 && mark.inbox == 0 {
343 continue;
345 }
346 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 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
427async 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#[derive(Debug, PartialEq, Eq)]
448pub enum Delivery {
449 Confirmed,
451 Requested,
453 Failed(String),
455}
456
457async 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
478async 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
504async 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 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 "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
562async 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
603async 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}