diff --git a/crates/mcp-config/src/codex/limit.rs b/crates/mcp-config/src/codex/limit.rs index b6930e4..3269113 100644 --- a/crates/mcp-config/src/codex/limit.rs +++ b/crates/mcp-config/src/codex/limit.rs @@ -1,4 +1,4 @@ -//! A Codex CLI seat stopped on its usage limit, held, and handed back (#294). +//! A Codex CLI seat stopped on its usage limit, and resumed (#294, #403). //! See docs/3-accounts.md, "Codex の席が利用上限で止まったとき". //! //! Three readings and one state machine, all free of the app so that a test @@ -18,17 +18,16 @@ //! and a broken answer are all [`Answer::Unavailable`], which keeps the seat //! limited. //! -//! [`Limit`] is one seat: free, or stopped with the posts the room held for it -//! and when to ask next. Times are Unix seconds, passed in. +//! [`Limit`] is one seat: free, or stopped with when to ask next. Times are +//! Unix seconds, passed in. Nothing is held back from a stopped seat (#403): +//! the room types into it as into any seat, and a post typed there while it is +//! stopped stays in its conversation as a turn the limit refused. //! -//! A stopped seat has a mailbox ([`Mailboxes`], #312): the ids of the posts -//! held for it, kept in a small file beside its topic's record so that they -//! outlive the session and the app. What the seat is handed at the recovery is -//! those posts, read again from the room's record ([`mailbox`]). A seat -//! launched while its mailbox is still there starts stopped -//! ([`Limit::resumed`]). +//! A stopped seat has a mark ([`Mailboxes`], #312): a small file beside its +//! topic's record that outlives the session and the app. A seat launched while +//! its mark is still there starts stopped ([`Limit::resumed`]), so the resume +//! is typed by whichever launch holds the seat when the recovery is confirmed. use serde_json::Value; -use std::collections::HashMap; use std::path::Path; /// How a turn ended, as its `task_complete` line says. @@ -166,36 +165,28 @@ pub const GRACE: i64 = 30; /// still no, or no answer came. The last one repeats. pub const BACKOFF: [i64; 5] = [60, 120, 300, 600, 900]; -/// The least time between two questions when a held post brings the next one -/// forward (#377): posts arriving together ask once. -pub const HELD_GAP: i64 = 60; - -/// One room post the room did not type into a stopped seat. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Held { - pub message_id: String, - pub speaker: String, - /// The label's `at`, when the post's time could be read. - pub at: Option, - pub content: String, - /// Its `to` names this seat. - pub addressed: bool, -} +/// How long the resume input waits for the seat's answer (a turn starting or +/// ending) before the seat counts as stopped on something other than the +/// limit (#403, the same wait as a Claude seat's, #401). +pub const ANSWER_WAIT: i64 = 120; + +/// What is typed into the seat once its recovery is confirmed (#389). +pub const CONTINUE: &str = "利用上限の解除を確認しました。中断していた作業を再開してください。中断していた作業がなければ、そのまま待機してください。"; #[derive(Debug, Clone, PartialEq)] struct Stopped { resets_at: Option, next_query: i64, misses: u32, - /// When the last question was answered, if one has been. - asked: Option, } -/// One Codex seat: free, or stopped with what the room held for it. +/// One Codex seat: free or stopped, and whether the resume typed into it is +/// still waiting for its answer. #[derive(Debug, Default)] pub struct Limit { stopped: Option, - held: Vec, + /// When [`CONTINUE`] was typed, until a turn starts or ends. + typed: Option, } fn first_query(now: i64, resets_at: Option) -> i64 { @@ -206,18 +197,17 @@ fn first_query(now: i64, resets_at: Option) -> i64 { } impl Limit { - /// A seat launched while its mailbox is still there (#312): stopped from - /// the start, with its first question due now. Not at a reset: none is - /// known, and the one the stop was told may long have passed. + /// A seat launched while its mark is still there (#312): stopped from the + /// start, with its first question due now. Not at a reset: none is known, + /// and the one the stop was told may long have passed. pub fn resumed(now: i64) -> Self { Limit { stopped: Some(Stopped { resets_at: None, next_query: now, misses: 0, - asked: None, }), - held: Vec::new(), + typed: None, } } @@ -229,15 +219,13 @@ impl Limit { self.stopped.as_ref().and_then(|s| s.resets_at) } - /// What has been held so far, oldest first. - pub fn held(&self) -> &[Held] { - &self.held - } - /// The rollout said a turn ended on the limit. Whether that stopped a seat /// that was free — the moment the room is told. A seat already stopped - /// stays as it is: its schedule is the app-server's to move. + /// stays as it is: its schedule is the app-server's to move. A resume + /// answered by the limit is answered: the stop starts the cycle again, and + /// the next confirmed recovery types it again. pub fn stop(&mut self, now: i64, resets_at: Option) -> bool { + self.typed = None; if self.stopped.is_some() { return false; } @@ -245,7 +233,6 @@ impl Limit { resets_at, next_query: first_query(now, resets_at), misses: 0, - asked: None, }); true } @@ -263,47 +250,26 @@ impl Limit { } } - /// Keep a post back from a stopped seat. `false`, and nothing kept, when - /// the seat is free: the post is to be typed. - pub fn hold(&mut self, post: impl FnOnce() -> Held) -> bool { - if self.stopped.is_none() { - return false; - } - self.held.push(post()); - true - } - /// Whether a question is due now. pub fn due(&self, now: i64) -> bool { self.stopped.as_ref().is_some_and(|s| now >= s.next_query) } - /// A turn finished without error while stopped (a person typed into the - /// terminal). Not a recovery by itself; it brings the next question to now. + /// A turn finished without error while stopped — a post typed in by the + /// room or a person after the limit was reset by hand (#377). Not a + /// recovery by itself; it brings the next question to now. pub fn turn_completed(&mut self, now: i64) { if let Some(stopped) = self.stopped.as_mut() { stopped.next_query = stopped.next_query.min(now); } } - /// A post has just been held (#377): the room has something for the seat, - /// so whether it is free is worth asking now rather than at the reset — - /// the limit may have been reset by hand. Not sooner than [`HELD_GAP`] - /// after the last question. - pub fn post_held(&mut self, now: i64) { - if let Some(stopped) = self.stopped.as_mut() { - let earliest = stopped.asked.map_or(now, |asked| (asked + HELD_GAP).max(now)); - stopped.next_query = stopped.next_query.min(earliest); - } - } - /// The answer of a due question. Whether it confirmed the recovery; the - /// seat stays stopped, still holding, until [`Limit::release`]. + /// seat stays stopped until [`Limit::free`]. pub fn answer(&mut self, now: i64, answer: Answer) -> bool { let Some(stopped) = self.stopped.as_mut() else { return false; }; - stopped.asked = Some(now); match answer { Answer::Allowed => return true, Answer::Denied { resets_at: Some(reset) } if reset + GRACE > now => { @@ -320,129 +286,43 @@ impl Limit { false } - /// Free the seat and hand over what was held, oldest first. - pub fn release(&mut self) -> Vec { + /// The recovery is confirmed and the resume goes in: the seat is free. + pub fn free(&mut self) { self.stopped = None; - std::mem::take(&mut self.held) } -} -/// The one line handed to a seat on its recovery, or `None` when nothing was -/// held: the count, the way to read them, and in full only the posts whose -/// `to` named the seat. -pub fn digest(held: &[Held]) -> Option { - if held.is_empty() { - return None; - } - let mut text = format!( - "制限中に部屋で {} 件の発言がありました。必要なら read_room_history で読んでください。", - held.len() - ); - let addressed: Vec<&Held> = held.iter().filter(|post| post.addressed).collect(); - if !addressed.is_empty() { - text.push_str(&format!("\n\nそのうちあなた宛ての {} 件:", addressed.len())); - for post in addressed { - let at = post.at.as_deref().map(|at| format!("、{at}")).unwrap_or_default(); - text.push_str(&format!( - "\n\n--- {}{}(message_id {})\n{}", - post.speaker, at, post.message_id, post.content - )); - } + /// [`CONTINUE`] was typed into the seat now: its answer is awaited. + pub fn resume_typed(&mut self, now: i64) { + self.typed = Some(now); } - Some(text) -} - -/// One post of the topic's record, as [`mailbox`] reads it. `at` is the -/// post's time already read the way a label carries it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Recorded { - pub message_id: String, - pub speaker: String, - pub account: Option, - pub content: String, - pub to: Vec, - pub at: Option, -} - -/// The seat a mailbox is read for. -#[derive(Debug, Clone, Copy)] -pub struct Seat<'a> { - /// Its name now. - pub name: &'a str, - pub account_id: &'a str, -} -/// The names the seat is known by in the record: its name now, and every name -/// its account has spoken under there — what a post addressed to the seat -/// before a rename carries. A name some other account has spoken under is not -/// taken from the record: it would read posts to that seat as to this one. -fn names_of(record: &[Recorded], seat: &Seat) -> Vec { - let mut names = vec![seat.name.to_string()]; - for post in record { - if post.account.as_deref() == Some(seat.account_id) && !names.contains(&post.speaker) { - names.push(post.speaker.clone()); - } + /// A turn of the seat started or ended: the resume has its answer. + pub fn answered(&mut self) { + self.typed = None; } - let others: Vec<&str> = record - .iter() - .filter(|post| post.account.as_deref().is_some_and(|id| id != seat.account_id)) - .map(|post| post.speaker.as_str()) - .collect(); - names.retain(|name| name == seat.name || !others.contains(&name.as_str())); - names -} -/// What the seat is handed at its recovery, in the order held (#312): the -/// posts whose ids its mailbox holds, and nothing else. `ids` is the mailbox as -/// its file keeps it; `held` is what this launch's memory held, whose ids the -/// file normally holds too, and any it does not are kept after them. -/// -/// Each post is read from the record, with `addressed` read against every name -/// the seat is known by there. A post the record does not show is taken as -/// memory held it, and one neither shows is left out: there is nothing of it -/// to hand over. Nothing else in the record is looked at — no notice, no range -/// between two ids: a post said while the seat was closed, or while the app was -/// down, was never held, and is not the seat's to be handed. -pub fn mailbox(record: &[Recorded], seat: &Seat, ids: &[String], held: Vec) -> Vec { - let names = names_of(record, seat); - let by_id: HashMap<&str, &Recorded> = record - .iter() - .map(|post| (post.message_id.as_str(), post)) - .collect(); - let mut order: Vec = Vec::new(); - for id in ids.iter().chain(held.iter().map(|post| &post.message_id)) { - if !order.contains(id) { - order.push(id.clone()); + /// Whether the resume drew no answer within [`ANSWER_WAIT`]: the seat is + /// stopped on something else (a selection screen, an expired login, a + /// trust prompt), where a submit key could pick a choice. `true` once; + /// nothing is typed again for it. + pub fn unanswered(&mut self, now: i64) -> bool { + if self.typed.is_some_and(|typed| now >= typed + ANSWER_WAIT) { + self.typed = None; + return true; } + false } - order - .into_iter() - .filter_map(|id| { - let memory = held.iter().find(|post| post.message_id == id); - match by_id.get(id.as_str()) { - Some(post) => Some(Held { - message_id: post.message_id.clone(), - speaker: post.speaker.clone(), - at: post.at.clone(), - content: post.content.clone(), - addressed: memory.is_some_and(|m| m.addressed) - || post.to.iter().any(|to| names.contains(to)), - }), - None => memory.cloned(), - } - }) - .collect() } -/// The mailboxes of one topic's stopped seats, as the small file beside the +/// The marks of one topic's stopped seats, as the small file beside the /// topic's record keeps them across a session's end and the app's (#312): by -/// account, the ids of the posts held for that seat, oldest first. Ids only — -/// what was said is the record's. A seat has a mailbox from its stop until its -/// recovery has been handed over, empty while nothing has been held. The file -/// goes with its topic (`topic_index::delete`). +/// account, from the seat's stop until its resume is typed. The file goes with +/// its topic (`topic_index::delete`). A seat's value is an empty list; a file +/// written before #403 carries the ids of posts held then, and is read as the +/// same mark. /// /// A file that is missing, cannot be read, or is not what this writes is no -/// mailbox at all. +/// mark at all. #[derive(Debug, Default, Clone, PartialEq)] pub struct Mailboxes { seats: serde_json::Map, @@ -462,7 +342,7 @@ impl Mailboxes { } /// Written whole, through a file beside it renamed over it, so a write cut - /// short leaves the last one standing. No mailbox left is no file. + /// short leaves the last one standing. No mark left is no file. pub fn write(&self, path: &Path) -> std::io::Result<()> { if self.seats.is_empty() { return match std::fs::remove_file(path) { @@ -478,44 +358,20 @@ impl Mailboxes { std::fs::rename(&next, path) } - /// The seat's mailbox, or `None` when it has none. - pub fn get(&self, account_id: &str) -> Option> { - let ids = self.seats.get(account_id)?.as_array()?; - Some( - ids.iter() - .filter_map(|id| id.as_str().map(str::to_string)) - .collect(), - ) + /// Whether the seat is marked stopped. + pub fn has(&self, account_id: &str) -> bool { + self.seats.contains_key(account_id) } - fn ids_mut(&mut self, account_id: &str) -> &mut Vec { - let ids = self - .seats + /// The seat has stopped: it is marked from now. A mark already there is + /// kept as it is. + pub fn open(&mut self, account_id: &str) { + self.seats .entry(account_id.to_string()) .or_insert_with(|| Value::Array(Vec::new())); - if !ids.is_array() { - *ids = Value::Array(Vec::new()); - } - match ids { - Value::Array(ids) => ids, - _ => unreachable!("made an array above"), - } - } - - /// The seat has stopped: it has a mailbox from now, empty if it had none. - pub fn open(&mut self, account_id: &str) { - self.ids_mut(account_id); } - /// One more post held for the seat. - pub fn push(&mut self, account_id: &str, message_id: &str) { - let ids = self.ids_mut(account_id); - if !ids.iter().any(|id| id == message_id) { - ids.push(Value::String(message_id.to_string())); - } - } - - /// The seat's recovery is being handed over: its mailbox goes. + /// The seat's resume is being typed: its mark goes. pub fn close(&mut self, account_id: &str) { self.seats.remove(account_id); } @@ -528,20 +384,23 @@ pub fn stop_notice(name: &str, reset: Option<&str>) -> String { Some(reset) => format!("解除予定は {reset} です。"), None => "解除予定は分かっていません。".to_string(), }; - format!( - "{name} は利用上限で止まりました。{when}解除を確かめるまで、{name} への部屋の発言は Pullcept が預かります。" - ) + format!("{name} は利用上限で止まりました。{when}") } -/// What the room is told when the recovery is confirmed. -pub fn recovery_notice(name: &str, held: usize) -> String { - if held == 0 { - format!("{name} の利用上限の解除を確かめました。") - } else { - format!( - "{name} の利用上限の解除を確かめました。預かっていた {held} 件の発言は、一通にまとめて {name} に渡します。" - ) - } +/// What the room is told when the recovery is confirmed and the resume is +/// about to be typed into the seat. +pub fn recovery_notice(name: &str) -> String { + format!("{name} の利用上限の解除を確かめました。{name} の端末へ再開の指示を打ちます。") +} + +/// The resume drew no answer within [`ANSWER_WAIT`] (#403). +pub fn other_cause(name: &str) -> String { + format!("{name} は、再開の指示を打ってから応答がありません。利用上限以外の理由(起動時の選択画面、ログイン切れ、信頼の確認など)で止まっているようです。指示は打ち直しません。端末を見てください。") +} + +/// A seat the room was told of with [`other_cause`] finished a turn again. +pub fn back_after_other_cause(name: &str) -> String { + format!("{name} は再開しました(正常な応答を確認)。先の「端末を見てください」は解消しています。") } #[cfg(test)] @@ -640,23 +499,12 @@ mod tests { assert_eq!(json(&lines[2])["id"], READ_ID); } - fn post(id: &str, addressed: bool) -> Held { - Held { - message_id: id.into(), - speaker: "Master".into(), - at: Some("2026-10-05T22:10+09:00".into()), - content: format!("body {id}"), - addressed, - } - } - #[test] - fn a_free_seat_holds_nothing_and_is_never_due() { + fn a_free_seat_is_never_due() { let mut limit = Limit::default(); - assert!(!limit.hold(|| post("a", false))); + assert!(!limit.is_limited()); assert!(!limit.due(i64::MAX)); assert!(!limit.answer(0, Answer::Allowed)); - assert_eq!(limit.release(), Vec::new()); } #[test] @@ -717,6 +565,7 @@ mod tests { #[test] fn a_turn_that_worked_brings_the_question_forward_but_is_not_a_recovery() { + // A room post typed into a seat whose limit was reset by hand (#377). let mut limit = Limit::default(); limit.stop(0, Some(10_000)); limit.turn_completed(50); @@ -725,92 +574,56 @@ mod tests { } #[test] - fn a_held_post_asks_before_the_reset_but_not_twice_a_minute() { + fn a_confirmed_recovery_frees_the_seat_once() { let mut limit = Limit::default(); - limit.stop(0, Some(10_000)); - assert!(!limit.due(100)); - limit.post_held(100); - assert!(limit.due(100)); - assert!(!limit.answer(100, Answer::Denied { resets_at: Some(10_000) })); - assert!(!limit.due(130)); - limit.post_held(130); - assert!(!limit.due(130)); - assert!(limit.due(160)); - assert!(limit.answer(160, Answer::Allowed)); - } - - #[test] - fn the_recovery_hands_over_what_was_held_in_order() { - let mut limit = Limit::default(); - limit.stop(0, Some(100)); - assert!(limit.hold(|| post("a", false))); - assert!(limit.hold(|| post("b", true))); - assert_eq!(limit.held().len(), 2); - assert!(limit.answer(200, Answer::Allowed)); - // Confirmed, but still holding until released. + limit.stop(100, Some(200)); + assert!(!limit.answer(201, Answer::Unavailable)); + assert!(!limit.answer(202, Answer::Denied { resets_at: Some(200) })); + assert!(limit.answer(204, Answer::Allowed)); + // Confirmed, but stopped until freed. assert!(limit.is_limited()); - assert!(limit.hold(|| post("c", false))); - let held = limit.release(); - assert_eq!( - held.iter().map(|p| p.message_id.as_str()).collect::>(), - ["a", "b", "c"] - ); + limit.free(); assert!(!limit.is_limited()); - assert!(!limit.hold(|| post("d", false))); + assert!(!limit.answer(205, Answer::Allowed)); } #[test] - fn the_digest_counts_all_and_carries_only_the_addressed() { - assert_eq!(digest(&[]), None); - let text = digest(&[post("a", false), post("b", true), post("c", false)]).unwrap(); - assert!(text.starts_with( - "制限中に部屋で 3 件の発言がありました。必要なら read_room_history で読んでください。" - )); - assert!(text.contains("あなた宛ての 1 件")); - assert!(text.contains("body b")); - assert!(text.contains("message_id b")); - assert!(!text.contains("body a")); - assert!(!text.contains("body c")); - let none_addressed = digest(&[post("a", false)]).unwrap(); - assert!(!none_addressed.contains("あなた宛て")); + fn the_resume_is_answered_by_a_turn_or_by_the_limit_or_counts_as_another_cause() { + // A turn starts or ends: answered, nothing more. + let mut limit = Limit::default(); + limit.resume_typed(1000); + limit.answered(); + assert!(!limit.unanswered(1000 + ANSWER_WAIT)); + // The limit again: a new stop, and no other cause. + let mut limit = Limit::default(); + limit.resume_typed(1000); + assert!(limit.stop(1010, Some(5000))); + assert!(!limit.unanswered(1000 + ANSWER_WAIT)); + // Nothing: once, at the wait's end, and not again. + let mut limit = Limit::default(); + limit.resume_typed(1000); + assert!(!limit.unanswered(1000 + ANSWER_WAIT - 1)); + assert!(limit.unanswered(1000 + ANSWER_WAIT)); + assert!(!limit.unanswered(1000 + 2 * ANSWER_WAIT)); + assert!(!limit.is_limited()); } #[test] - fn the_notices_name_the_seat_and_the_reset() { + fn the_notices_name_the_seat_and_the_reset_and_hold_nothing() { assert_eq!( stop_notice("Codex Luna", Some("10月5日 22:45")), - "Codex Luna は利用上限で止まりました。解除予定は 10月5日 22:45 です。解除を確かめるまで、Codex Luna への部屋の発言は Pullcept が預かります。" + "Codex Luna は利用上限で止まりました。解除予定は 10月5日 22:45 です。" ); assert!(stop_notice("L", None).contains("解除予定は分かっていません。")); - assert!(recovery_notice("L", 3).contains("3 件")); - assert!(!recovery_notice("L", 0).contains("件")); - } - const SEAT: Seat = Seat { - name: "Codex Luna", - account_id: "luna", - }; - - fn said(id: &str, speaker: &str, account: Option<&str>, to: &[&str], content: &str) -> Recorded { - Recorded { - message_id: id.into(), - speaker: speaker.into(), - account: account.map(str::to_string), - content: content.into(), - to: to.iter().map(|t| t.to_string()).collect(), - at: Some(format!("at {id}")), + for text in [stop_notice("L", None), recovery_notice("L")] { + assert!(!text.contains("預か"), "{text}"); } - } - - fn by_master(id: &str, to: &[&str]) -> Recorded { - said(id, "Master", None, to, &format!("body {id}")) - } - - fn ids(posts: &[Held]) -> Vec<&str> { - posts.iter().map(|p| p.message_id.as_str()).collect() - } - - fn strings(ids: &[&str]) -> Vec { - ids.iter().map(|id| id.to_string()).collect() + assert!(recovery_notice("L").contains("再開の指示を打ちます")); + assert!(other_cause("L").contains("端末を見てください")); + assert!(back_after_other_cause("L").contains("解消")); + assert!(CONTINUE.contains("中断していた作業を再開してください")); + assert!(CONTINUE.contains("作業がなければ、そのまま待機")); + assert!(!CONTINUE.contains("read_room_history")); } #[test] @@ -819,82 +632,13 @@ mod tests { assert!(limit.is_limited()); assert_eq!(limit.resets_at(), None); assert!(limit.due(1000)); - assert!(limit.hold(|| post("a", false))); assert!(!limit.stop(1001, Some(9000)), "already stopped: no second notice"); - // Still no: held, and asked again on the backoff. + // Still no: asked again on the backoff. assert!(!limit.answer(1000, Answer::Unavailable)); assert!(limit.is_limited()); assert!(!limit.due(1000 + BACKOFF[0] - 1)); assert!(limit.due(1000 + BACKOFF[0])); assert!(limit.answer(1000 + BACKOFF[0], Answer::Allowed)); - assert_eq!(ids(&limit.release()), ["a"]); - } - - #[test] - fn the_mailbox_is_its_ids_and_nothing_between_them() { - // The seat stopped and held m1; the app went down; m2 was said while - // it was down; the relaunched seat, still stopped, held m3. - let record = vec![ - by_master("before", &["Codex Luna"]), - by_master("m1", &["Codex Luna"]), - said("lin", "Claude Lin", Some("lin"), &[], "body lin"), - by_master("m2", &["Codex Luna"]), - by_master("m3", &["Codex Luna", "Claude Lin"]), - ]; - let held = vec![Held { - message_id: "m3".into(), - speaker: "Master".into(), - at: None, - content: "body m3".into(), - addressed: true, - }]; - let posts = mailbox(&record, &SEAT, &strings(&["m1", "lin", "m3"]), held); - assert_eq!(ids(&posts), ["m1", "lin", "m3"]); - assert_eq!( - posts.iter().filter(|p| p.addressed).map(|p| p.message_id.as_str()).collect::>(), - ["m1", "m3"] - ); - // Read from the record: its time, not memory's missing one. - assert_eq!(posts[2].at.as_deref(), Some("at m3")); - let text = digest(&posts).unwrap(); - assert!(text.starts_with("制限中に部屋で 3 件の発言がありました。")); - assert!(text.contains("body m1")); - assert!(!text.contains("body m2")); - assert!(!text.contains("body before")); - // The restart's memory is empty: the file alone carries it. - assert_eq!(ids(&mailbox(&record, &SEAT, &strings(&["m1", "lin"]), Vec::new())), ["m1", "lin"]); - } - - #[test] - fn what_memory_held_outside_the_file_or_the_record_is_kept() { - let record = vec![by_master("m", &[]), by_master("n", &[])]; - // "n" was held but its id did not reach the file; "late" reached the - // record after it was read; "gone" is in neither and is left out. - let held = vec![post("m", false), post("n", false), post("late", true)]; - let posts = mailbox(&record, &SEAT, &strings(&["m", "gone"]), held); - assert_eq!(ids(&posts), ["m", "n", "late"]); - assert!(posts[2].addressed); - assert_eq!(mailbox(&record, &SEAT, &[], Vec::new()), Vec::new()); - } - - #[test] - fn a_renamed_seat_is_followed_by_its_account() { - // Held as "Luna", handed over as "Codex Luna". - let record = vec![ - said("hi", "Luna", Some("luna"), &[], "hello"), - by_master("m1", &["Luna"]), - by_master("m2", &["Codex Luna"]), - ]; - let posts = mailbox(&record, &SEAT, &strings(&["m1", "m2"]), Vec::new()); - assert!(posts.iter().all(|p| p.addressed)); - // A name another account has spoken under is not taken as the seat's. - let record = vec![ - said("hi", "Luna", Some("luna"), &[], "hello"), - said("sol", "Luna", Some("sol"), &[], "I am Luna now"), - by_master("m1", &["Luna"]), - ]; - let posts = mailbox(&record, &SEAT, &strings(&["m1"]), Vec::new()); - assert!(!posts[0].addressed); } struct Scratch(std::path::PathBuf); @@ -913,41 +657,32 @@ mod tests { } #[test] - fn the_mailboxes_outlive_a_restart_and_go_at_the_recovery() { + fn the_marks_outlive_a_restart_and_go_at_the_resume() { let scratch = Scratch::new(); let path = scratch.0.join("mailboxes").join("topic.json"); assert_eq!(Mailboxes::read(&path), Mailboxes::default()); let mut boxes = Mailboxes::default(); boxes.open("luna"); - boxes.write(&path).unwrap(); - // Stopped with nothing held is still a mailbox. - assert_eq!(Mailboxes::read(&path).get("luna"), Some(Vec::new())); - boxes.push("luna", "a"); - boxes.push("luna", "b"); - boxes.push("luna", "a"); - boxes.push("sol", "c"); + boxes.open("sol"); boxes.write(&path).unwrap(); let mut read = Mailboxes::read(&path); - assert_eq!(read.get("luna"), Some(strings(&["a", "b"]))); - assert_eq!(read.get("sol"), Some(strings(&["c"]))); - assert_eq!(read.get("other"), None); - // Opening again keeps what is there. - read.open("luna"); - assert_eq!(read.get("luna"), Some(strings(&["a", "b"]))); + assert!(read.has("luna") && read.has("sol")); + assert!(!read.has("other")); read.close("luna"); read.write(&path).unwrap(); - assert_eq!(Mailboxes::read(&path).get("luna"), None); + assert!(!Mailboxes::read(&path).has("luna")); read.close("sol"); read.write(&path).unwrap(); - assert!(!path.exists(), "no mailbox left is no file"); + assert!(!path.exists(), "no mark left is no file"); read.write(&path).unwrap(); - // A file that is not what this writes is no mailbox. + // A file written before #403, with held ids, is the same mark. + std::fs::write(&path, r#"{"luna":["a","b"]}"#).unwrap(); + let mut old = Mailboxes::read(&path); + assert!(old.has("luna")); + old.open("luna"); + assert!(old.has("luna")); + // A file that is not what this writes is no mark. std::fs::write(&path, "[1,2]").unwrap(); assert_eq!(Mailboxes::read(&path), Mailboxes::default()); - std::fs::write(&path, r#"{"luna":"x"}"#).unwrap(); - let mut odd = Mailboxes::read(&path); - assert_eq!(odd.get("luna"), None); - odd.push("luna", "a"); - assert_eq!(odd.get("luna"), Some(strings(&["a"]))); } } diff --git a/crates/mcp-config/src/codex/status.rs b/crates/mcp-config/src/codex/status.rs index a36287c..76a7c0e 100644 --- a/crates/mcp-config/src/codex/status.rs +++ b/crates/mcp-config/src/codex/status.rs @@ -163,6 +163,9 @@ pub struct Tail { pub status: Status, /// The last turn end read and not yet taken (#294, `limit::turn_end`). turn_end: Option, + /// A turn's `task_started` was read and not yet taken (#403): the seat + /// answered the resume typed into it. + turn_started: bool, /// The reset the last `token_count` reported for its fullest window /// (#294, `limit::rollout_reset`): what the room is told while the /// app-server has not said better. @@ -199,6 +202,7 @@ impl Tail { if let Some(end) = super::limit::turn_end(&value) { self.turn_end = Some(end); } + self.turn_started |= value["type"] == "event_msg" && value["payload"]["type"] == "task_started"; if let Some(reset) = super::limit::rollout_reset(&value) { self.resets_at = Some(reset); } @@ -220,6 +224,11 @@ impl Tail { self.turn_end.take() } + /// Whether a turn started since the previous call (#403). + pub fn take_turn_started(&mut self) -> bool { + std::mem::take(&mut self.turn_started) + } + /// Whether the rate limits were read since the previous call (#364). pub fn take_limits_read(&mut self) -> bool { std::mem::take(&mut self.limits_read) @@ -496,6 +505,10 @@ mod tests { assert_eq!(tail.resets_at, Some(1791613577), "93 is the fuller window in TOKENS"); assert_eq!(tail.take_turn_end(), Some(TurnEnd::UsageLimit)); assert_eq!(tail.take_turn_end(), None); + assert!(!tail.take_turn_started()); + tail.feed(lines(&[r#"{"type":"event_msg","payload":{"type":"task_started"}}"#]).as_bytes()); + assert!(tail.take_turn_started()); + assert!(!tail.take_turn_started(), "taken once"); } #[test] diff --git a/crates/mcp-config/src/lib.rs b/crates/mcp-config/src/lib.rs index bb25753..213321a 100644 --- a/crates/mcp-config/src/lib.rs +++ b/crates/mcp-config/src/lib.rs @@ -38,7 +38,6 @@ pub mod hook_activity; pub mod claude_limit; pub mod claude_notice; pub mod claude_resume; -pub mod limit_recovery; pub mod permission_prompt; pub mod prompt_hold; pub mod usage; diff --git a/crates/mcp-config/src/limit_recovery.rs b/crates/mcp-config/src/limit_recovery.rs deleted file mode 100644 index f06ed52..0000000 --- a/crates/mcp-config/src/limit_recovery.rs +++ /dev/null @@ -1,150 +0,0 @@ -//! The single input handed to a parent after its limit recovery is confirmed. -//! Confirmation remains the caller's Codex limit (docs/3-accounts.md, #389). -use crate::codex::limit::{digest, Held}; - -pub const CONTINUE: &str = "利用上限の解除を確認しました。中断していた作業を再開してください。中断していた作業がなければ、そのまま待機してください。"; - -pub fn input(held: &[Held]) -> String { - match digest(held) { - Some(digest) => format!("{CONTINUE}\n\n{digest}"), - None => CONTINUE.to_string(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{ - codex::limit::{Answer, Limit}, - prompt_hold::{Item, Queue}, - }; - - #[test] - fn empty_mailbox_still_resumes_and_can_wait_without_new_work() { - let text = input(&[]); - assert_eq!(text, CONTINUE); - assert!(text.contains("中断していた作業を再開してください")); - assert!(text.contains("作業がなければ、そのまま待機")); - assert!(!text.contains("read_room_history")); - } - - #[test] - fn held_posts_share_one_input_with_the_continuation() { - let held = [Held { - message_id: "post-1".into(), - speaker: "Master".into(), - at: None, - content: "continue the same issue".into(), - addressed: true, - }]; - let text = input(&held); - assert!(text.starts_with(CONTINUE)); - assert_eq!(text.matches(CONTINUE).count(), 1); - assert!(text.contains(&digest(&held).unwrap())); - assert!(text.contains("post-1")); - assert!(text.contains("continue the same issue")); - } - - #[test] - fn codex_confirmation_releases_once_even_with_no_posts() { - let mut limit = Limit::default(); - limit.stop(100, Some(200)); - assert!(!limit.answer(201, Answer::Unavailable)); - assert!(limit.is_limited()); - assert!(!limit.answer( - 202, - Answer::Denied { - resets_at: Some(200) - } - )); - assert!(limit.is_limited()); - assert!(limit.answer(204, Answer::Allowed)); - assert_eq!(input(&limit.release()), CONTINUE); - assert!(!limit.answer(203, Answer::Allowed)); - } - - #[test] - fn confirmed_recovery_keeps_one_continuation_through_permission_wait() { - let mut limit = Limit::default(); - limit.stop(100, Some(200)); - assert!(limit.answer(201, Answer::Allowed)); - let mut queue = Queue::new(); - let item = Item { - message_id: "recovery".into(), - text: input(&limit.release()), - post: None, - launch_bound: true, - relaunch_text: None, - }; - assert!(queue.pass(true, item).is_none()); - assert!(!limit.is_limited()); - assert!(!queue.begin(true)); - assert!(queue.begin(false)); - assert_eq!(queue.next(false).unwrap().text, CONTINUE); - assert!(queue.next(false).is_none()); - assert!(!queue.begin(false)); - } - - #[test] - fn recovery_waiting_on_permission_is_not_carried_to_another_launch() { - let recovery = Item { - message_id: "recovery".into(), - text: input(&[]), - post: None, - launch_bound: true, - relaunch_text: None, - }; - let ordinary = Item { - message_id: "post".into(), - text: "ordinary room post".into(), - post: None, - launch_bound: false, - relaunch_text: None, - }; - let mut queue = Queue::new(); - queue.pass(true, recovery.clone()); - queue.pass(true, ordinary.clone()); - let mut next_launch = queue.carry(); - assert_eq!(next_launch.len(), 1); - // An earlier launch's in-flight hand-over can also refuse after carry. - next_launch.put_first(recovery); - assert_eq!(next_launch.len(), 1); - assert!(next_launch.begin(false)); - assert_eq!(next_launch.next(false), Some(ordinary)); - assert!(next_launch.next(false).is_none()); - } - - #[test] - fn relaunch_keeps_the_existing_digest_and_watermark_without_continuation() { - let held = [Held { - message_id: "post-1".into(), - speaker: "Master".into(), - at: None, - content: "same issue".into(), - addressed: true, - }]; - let fallback = digest(&held).unwrap(); - let item = Item { - message_id: "recovery-watermark".into(), - text: input(&held), - post: None, - launch_bound: true, - relaunch_text: Some(fallback.clone()), - }; - let mut queue = Queue::new(); - queue.pass(true, item.clone()); - let mut next_launch = queue.carry(); - assert!(next_launch.begin(false)); - let carried = next_launch.next(false).unwrap(); - assert_eq!(carried.message_id, "recovery-watermark"); - assert_eq!(carried.text, fallback); - assert!(!carried.text.contains(CONTINUE)); - assert!(next_launch.next(false).is_none()); - // A refusal of an item already taken by the old launch has the same fallback. - next_launch.put_first(item); - let mut third_launch = next_launch.carry(); - assert!(third_launch.begin(false)); - assert_eq!(third_launch.next(false), Some(carried)); - assert!(third_launch.next(false).is_none()); - } -} diff --git a/crates/mcp-config/src/prompt_hold.rs b/crates/mcp-config/src/prompt_hold.rs index 59f06f2..4e47046 100644 --- a/crates/mcp-config/src/prompt_hold.rs +++ b/crates/mcp-config/src/prompt_hold.rs @@ -22,23 +22,16 @@ //! to end. An item the terminal refused is put back at the front and the //! hand-over ends: nothing is dropped. //! -//! **The usage-limit hold comes first** (#294). A post for a Codex seat stopped -//! on its usage limit is that hold's, as before, and never reaches this queue. -//! A Claude Code seat has no such hold (#401): its posts come here like any. -//! A kept post whose hand-over finds the seat stopped on its limit is given to -//! that hold (`deliver`) rather than typed, and goes out with its digest at the -//! recovery. The digest itself, typed at a recovery that finds the seat -//! waiting, is kept here like a post and typed after the wait — it is already -//! the limit hold's release, and is not given back to it. One order is not -//! kept: a post held here before a limit began goes into the limit's mailbox -//! after the posts the limit itself held while this wait lasted. +//! **No seat is held back on its usage limit** (Claude #401, Codex #403): a +//! post for a seat stopped on its limit comes here like any. The resume typed +//! at a recovery that finds the seat waiting is kept here like a post and +//! typed after the wait, for that launch only (`Item::launch_bound`). //! //! **A wait's end is not a hand-over at once.** The hand-over is begun //! `RELEASE_GRACE` after the end was heard, and only if no wait has begun in //! between: a prompt answered in the terminal can be followed at once by the //! next one (a second call waiting on its own permission), and the request of //! that one is heard before its prompt is drawn. -use crate::codex::limit::Held; use std::collections::VecDeque; use std::time::Duration; @@ -49,27 +42,19 @@ pub const RELEASE_GRACE: Duration = Duration::from_millis(1500); /// One thing the room would have typed into the seat's terminal. #[derive(Debug, Clone, PartialEq)] pub struct Item { - /// The post's id, or the watermark a digest is labelled with: for the log - /// only. + /// The post's id, or the watermark a resume is labelled with: for the + /// log only. pub message_id: String, /// What is typed, label line and all (`terminal_input::compose`). pub text: String, - /// The post as the usage-limit hold keeps one, or `None` for what is not a - /// post of the room's — the limit hold's own digest. - pub post: Option, - /// Recovery continuation belongs only to the launch that confirmed it. + /// A resume belongs only to the launch it was typed for (#389, #401): it + /// is not carried to the next launch, which types its own. pub launch_bound: bool, - /// The existing held-post digest carried across relaunch, without continuation. - pub relaunch_text: Option, } impl Item { - fn for_relaunch(mut self) -> Option { - if self.launch_bound { - self.text = self.relaunch_text.take()?; - self.launch_bound = false; - } - Some(self) + fn for_relaunch(self) -> Option { + (!self.launch_bound).then_some(self) } } @@ -138,7 +123,7 @@ impl Queue { } /// The queue of a seat launched again: what was kept carries over to the - /// new launch, except continuation bound to the old launch (#389). A + /// new launch, except a resume bound to the old launch (#389). A /// hand-over that was under way for the old one ends. pub fn carry(self) -> Queue { Queue { @@ -156,19 +141,10 @@ impl Queue { } } -/// How one item of a hand-over went. -#[derive(Debug, PartialEq)] -pub enum Delivered { - /// Given to the usage-limit hold, the seat being stopped on its limit. - ToLimit, - /// Handed to the terminal. - Typed, -} - /// One line of `permission_prompt::LOG_FILE` for a kept post (#346): -/// `[prompt-hold] message= room= +/// `[prompt-hold] message= room= /// account= at=