diff --git a/crates/mcp-config/src/claude_limit.rs b/crates/mcp-config/src/claude_limit.rs index d5c1e99..c147972 100644 --- a/crates/mcp-config/src/claude_limit.rs +++ b/crates/mcp-config/src/claude_limit.rs @@ -238,9 +238,11 @@ pub struct State { order: VecDeque, } impl State { - pub fn new(floor: i64, persisted: bool) -> Self { + /// `limited` = the launch starts stopped: its parent's last event before + /// it is an unanswered rejection (#366, `last_rejection`). + pub fn new(floor: i64, limited: bool) -> Self { Self { - limited: persisted, + limited, floor, latest: floor - 1, rejected: None, @@ -274,48 +276,6 @@ impl State { } } -/// The room delivery gate. Kept held through the recovery notice and closed -/// only while enqueuing its one digest, under the shared Claude table lock. -pub struct Gate { - pub state: State, - held: Vec, - recovery_hold: bool, -} -impl Gate { - pub fn new(floor: i64, persisted: bool) -> Self { - Self { - state: State::new(floor, persisted), - held: Vec::new(), - recovery_hold: false, - } - } - pub fn hold(&mut self, post: impl FnOnce() -> super::codex::limit::Held) -> Option { - if !self.is_limited() { - return None; - } - let post = post(); - let id = post.message_id.clone(); - if !self.held.iter().any(|p| p.message_id == id) { - self.held.push(post); - } - Some(id) - } - pub fn is_limited(&self) -> bool { - self.state.limited || self.recovery_hold - } - pub fn keep_holding(&mut self) { - self.recovery_hold = true; - } - pub fn held(&self) -> Vec { - self.held.clone() - } - pub fn release(&mut self) -> Vec { - self.state.limited = false; - self.recovery_hold = false; - std::mem::take(&mut self.held) - } -} - /// Incremental, bounded reader, retaining only an incomplete metadata line. /// Missing files leave the state alone. Replacement restarts reading, while /// launch timestamp and UUID guards prevent historical/duplicate recovery. @@ -324,7 +284,6 @@ pub struct Tail { partial: Vec, skipping: bool, checkpoint: Option, - caught_up: bool, created: Option, modified: Option, } @@ -334,7 +293,6 @@ impl Tail { offset, partial: Vec::new(), skipping: false, - caught_up: false, created: std::fs::metadata(path).ok().and_then(|m| m.created().ok()), modified: std::fs::metadata(path).ok().and_then(|m| m.modified().ok()), checkpoint: std::fs::File::open(path) @@ -342,9 +300,6 @@ impl Tail { .and_then(|mut f| fingerprint(&mut f, offset).ok()), } } - pub fn caught_up(&self) -> bool { - self.caught_up - } pub fn feed(&mut self, parent: &str, bytes: &[u8]) -> Vec { let mut events = Vec::new(); for &b in bytes { @@ -370,7 +325,6 @@ impl Tail { events } pub fn read(&mut self, path: &Path, parent: &str) -> std::io::Result> { - self.caught_up = false; let mut f = std::fs::File::open(path)?; let meta = f.metadata()?; if meta.len() < self.offset @@ -397,8 +351,6 @@ impl Tail { self.modified = meta.modified().ok(); self.checkpoint = Some(fingerprint(&mut f, self.offset)?); let events = self.feed(parent, &bytes); - self.caught_up = - self.offset == f.metadata()?.len() && self.partial.is_empty() && !self.skipping; Ok(events) } } @@ -419,7 +371,6 @@ fn fingerprint(f: &mut std::fs::File, offset: u64) -> std::io::Result { #[cfg(test)] mod tests { - use super::super::codex::limit::{Held, Mailboxes}; use super::*; use serde_json::json; const PARENT: &str = "512c102e-fe28-4e8e-8f23-6cc4226ce2fc"; @@ -447,15 +398,6 @@ mod tests { std::fs::create_dir_all(&p).unwrap(); p } - fn held(id: &str) -> Held { - Held { - message_id: id.into(), - speaker: "other".into(), - at: None, - content: id.into(), - addressed: true, - } - } #[test] fn observed_parent_reject_normal_without_error_flag_and_reject_again() { @@ -617,7 +559,7 @@ mod tests { std::fs::remove_dir_all(root).unwrap(); } #[test] - fn catch_up_does_not_release_success_before_rejection_in_next_chunk_or_partial() { + fn a_success_then_a_rejection_in_the_next_chunk_or_a_partial_line_is_read_in_order() { let root = dir(); let path = root.join("catchup.jsonl"); let mut all = bytes(&line(2, false)); @@ -626,20 +568,15 @@ mod tests { all.extend(bytes(&line(3, true))); std::fs::write(&path, all).unwrap(); let mut tail = Tail::new(&path, 0); - let mut gate = Gate::new(0, true); + let mut state = State::new(0, true); for e in tail.read(&path, PARENT).unwrap() { - gate.state.apply(e); + state.apply(e); } - assert!(!gate.state.limited); - assert!(!tail.caught_up()); - gate.keep_holding(); - assert!(gate.hold(|| held("still-held")).is_some()); + assert!(!state.limited); for e in tail.read(&path, PARENT).unwrap() { - gate.state.apply(e); + state.apply(e); } - assert!(tail.caught_up()); - assert!(gate.is_limited()); - assert!(gate.state.limited); + assert!(state.limited); let normal = bytes(&line(4, false)); use std::io::Write; let mut file = std::fs::OpenOptions::new() @@ -648,18 +585,13 @@ mod tests { .unwrap(); file.write_all(&normal[..normal.len() - 1]).unwrap(); assert!(tail.read(&path, PARENT).unwrap().is_empty()); - assert!(!tail.caught_up()); + assert!(state.limited); file.write_all(b"\n").unwrap(); drop(file); for e in tail.read(&path, PARENT).unwrap() { - gate.state.apply(e); + state.apply(e); } - assert!(tail.caught_up()); - assert!(!gate.state.limited); - assert!(gate.is_limited()); - assert_eq!(gate.release().len(), 1); - assert!(!gate.is_limited()); - assert!(gate.release().is_empty()); + assert!(!state.limited); std::fs::remove_dir_all(root).unwrap(); } #[test] @@ -755,85 +687,6 @@ mod tests { assert_eq!(tail.feed(PARENT, &bytes(&line(4, false))).len(), 1); } #[test] - fn only_stopped_seats_hold_ordered_deduplicated_posts_across_restart_then_release_once() { - let root = dir(); - let path = root.join("claude-mailboxes.json"); - let mut a = Gate::new(0, false); - let mut b = Gate::new(0, false); - a.state.apply(event(PARENT, &line(1, true)).unwrap()); - let mut boxes = Mailboxes::read(&path); - boxes.open("a"); - boxes.write(&path).unwrap(); - for id in ["one", "two", "one"] { - let held = a.hold(|| held(id)).unwrap(); - boxes.push("a", &held); - } - assert!(b - .hold(|| panic!("free seat must deliver normally")) - .is_none()); - b.state.apply(event(PARENT, &line(1, true)).unwrap()); - boxes.open("b"); - let id = b.hold(|| held("three")).unwrap(); - boxes.push("b", &id); - boxes.write(&path).unwrap(); - assert_eq!( - a.held() - .iter() - .map(|p| p.message_id.as_str()) - .collect::>(), - ["one", "two"] - ); - drop(a); - drop(b); // New app/launch retains only the file's ids. - let mut boxes = Mailboxes::read(&path); - let ids = boxes.get("a").unwrap(); - assert_eq!(ids, ["one", "two"]); - assert_eq!(boxes.get("b").unwrap(), ["three"]); - let mut a = Gate::new(0, boxes.get("a").is_some()); - assert!(a.state.limited); - a.state.apply(event(PARENT, &line(2, false)).unwrap()); - assert!(!a.state.limited); - // Production holds through the notice, then releases under the lock. - a.state.limited = true; - a.hold(|| held("four")); - let released = a.release(); - let record: Vec<_> = ["one", "two"] - .into_iter() - .map(|id| super::super::codex::limit::Recorded { - message_id: id.into(), - speaker: "other".into(), - account: None, - at: None, - content: id.into(), - to: vec!["seat-a".into()], - }) - .collect(); - let payload = super::super::codex::limit::mailbox( - &record, - &super::super::codex::limit::Seat { - name: "seat-a", - account_id: "a", - }, - &ids, - released.clone(), - ); - assert_eq!( - payload - .iter() - .map(|p| p.message_id.as_str()) - .collect::>(), - ["one", "two", "four"] - ); - assert!(super::super::codex::limit::digest(&payload).is_some()); - boxes.close("a"); - boxes.write(&path).unwrap(); - assert_eq!(released[0].message_id, "four"); - assert!(a.release().is_empty()); - assert!(Mailboxes::read(&path).get("a").is_none()); - assert!(Mailboxes::read(&path).get("b").is_some()); - std::fs::remove_dir_all(root).unwrap(); - } - #[test] fn restart_reads_the_parents_last_rejection_and_its_reset() { let root = dir(); let path = root.join(format!("{PARENT}.jsonl")); diff --git a/crates/mcp-config/src/claude_notice.rs b/crates/mcp-config/src/claude_notice.rs index 24aa92a..0ed6eb4 100644 --- a/crates/mcp-config/src/claude_notice.rs +++ b/crates/mcp-config/src/claude_notice.rs @@ -1,4 +1,4 @@ -//! Notification only (#355). Neither a reset nor a child changes the parent gate. +//! Notification only (#355). Neither a reset nor a child changes the parent state. use serde_json::Value; use std::{ collections::HashMap, @@ -219,9 +219,9 @@ pub fn stopped(name: &str, stop: &Stop, clock: Option<&str>) -> String { .map(|c| format!("解除予定は {c} です。")) .unwrap_or_else(|| "解除予定は不明です。".into()); let action = match (stop.subject == "parent", clock.is_some()) { - (true, true) => "親への部屋の投稿を保留します。解除予定時刻を過ぎたら Pullcept が端末へ再開の合図を一度打ち、正常親応答を確認した後にまとめを渡します。", - (true, false) => "親への部屋の投稿を保留します。解除予定が分からないため自動では再開を試しません。端末で手動再開し、正常親応答を確認した後にまとめを渡します。", - (false, _) => "子の停止だけでは親への部屋の投稿を保留しません。", + (true, true) => "解除予定時刻を過ぎたら、Pullcept が端末へ再開の指示を一度打ちます。", + (true, false) => "解除予定が分からないため、自動では再開を試しません。端末で手動再開できます。", + (false, _) => "子の停止だけでは親の停止・回復を判定しません。", }; format!("{who} は利用上限で停止しました。{reset}{action}") } @@ -236,7 +236,7 @@ pub fn reset_known(name: &str, stop: &Stop, clock: &str) -> String { let (who, action) = if stop.subject == "parent" { ( format!("{name} の親セッション"), - "解除予定時刻を過ぎたら端末へ再開の合図を一度打ちます。", + "解除予定時刻を過ぎたら端末へ再開の指示を一度打ちます。", ) } else { ( @@ -249,27 +249,21 @@ pub fn reset_known(name: &str, stop: &Stop, clock: &str) -> String { }; format!("{who} の解除予定は {clock} と分かりました。回復は未確認です。{action}") } -/// The parent resume check delivered as one room notice (#399). -pub fn nudged(name: &str) -> String { - format!("{name} の親セッションの解除予定時刻を過ぎたため、利用上限の解除を確認します。{name} の親セッションだけが作業を始めず、部屋にも返信せず、端末で一言だけ応答してください。正常親応答を確認するまで投稿の保留を続けます。") -} -/// The confirmed recovery, whether the nudge or a person's input drew it. -pub fn resumed(name: &str, held: usize) -> String { - let base = format!("{name} の親セッションは再開しました(正常親応答を確認)。"); - if held == 0 { - base +/// Said as the resume prompt is typed into the seat's terminal (#401). +pub fn typed(name: &str, retry: bool) -> String { + if retry { + format!("{name} の親セッションはまだ利用上限で止まっていたため、端末へ再開の指示をもう一度打ちます。") } else { - format!("{base}預かっていた {held} 件の発言は、一通にまとめて {name} に渡します。") + format!("{name} の親セッションの解除予定時刻を過ぎたため、端末へ再開の指示を打ちます。") } } -pub fn still_limited(name: &str, reset: Option<&str>, next: &str) -> String { - let reset = reset - .map(|c| format!("解除予定は {c} です。")) - .unwrap_or_else(|| "解除予定は不明です。".into()); - format!("{name} の親セッションはまだ利用上限で止まっています。{reset}投稿の保留を続け、{next} にもう一度再開を試します。") +/// The typed prompt drew neither a rejection nor a response (#401). +pub fn other_cause(name: &str) -> String { + format!("{name} の親セッションは、再開の指示を打ってから応答がありません。利用上限以外の理由(起動時の選択画面、ログイン切れ、信頼の確認など)で止まっているようです。指示は打ち直しません。端末を見てください。") } -pub fn no_answer(name: &str) -> String { - format!("{name} の親セッションから再開の合図への応答を確認できませんでした。自動の再開はここで止め、投稿の保留を続けます。端末で手動再開できます。") +/// A seat the room was told of with [`other_cause`] answered normally again. +pub fn back_after_other_cause(name: &str) -> String { + format!("{name} の親セッションは再開しました(正常な応答を確認)。先の「端末を見てください」は解消しています。") } #[cfg(test)] @@ -401,22 +395,22 @@ mod tests { .. } )); - let mut gate = super::super::claude_limit::Gate::new(100000, false); + let mut state = super::super::claude_limit::State::new(100000, false); let mut notices = Notices::default(); let hook=serde_json::to_vec(&json!({"session_id":"parent","hook_event_name":"StopFailure","error":"rate_limit"})).unwrap(); assert!(child(&hook).is_none()); // Hook first creates no parent announcement. - assert!(gate.state.apply(event.clone())); + assert!(state.apply(event.clone())); let stop = Stop { subject: "parent".into(), reset: Some(110), episode: None, }; assert!(notices.stop(&stop)); - assert!(!gate.state.apply(event)); + assert!(!state.apply(event)); assert!(!notices.stop(&stop)); assert!(child(&hook).is_none()); // Watcher first also creates no second notice. assert_eq!(notices.due(110), vec!["parent"]); - assert!(gate.is_limited()); + assert!(state.limited); assert!(notices.due(111).is_empty()); } } @@ -470,24 +464,28 @@ mod tests { Some("old"), &body )); - let gate = super::super::claude_limit::Gate::new(100000, false); + let state = super::super::claude_limit::State::new(100000, false); let mut notices = Notices::default(); assert!(notices.stop(&stop)); assert!(!notices.stop(&stop)); notices.due(110); - assert!(!gate.is_limited()); + assert!(!state.limited); std::fs::remove_dir_all(root).unwrap(); } #[test] - fn parent_resume_notices_name_the_attempt_and_both_outcomes() { + fn parent_resume_notices_name_the_attempt_and_the_other_cause() { let parent = Stop { subject: "parent".into(), reset: Some(110), episode: None, }; // A known reset announces the automatic attempt; an unknown one says - // it will not be tried and points at the manual path. - assert!(stopped("seat", &parent, Some("10月10日 01:20")).contains("再開の合図を一度打ち")); + // it will not be tried and points at the manual path. Neither speaks + // of holding posts (#401). + let known = stopped("seat", &parent, Some("10月10日 01:20")); + // The stop itself is still told to the whole room (#401 item 5). + assert!(known.starts_with("seat の親セッション は利用上限で停止しました。解除予定は 10月10日 01:20 です。"), "{known}"); + assert!(known.contains("再開の指示を一度打ちます")); let unknown = stopped( "seat", &Stop { @@ -497,41 +495,27 @@ mod tests { None, ); assert!(unknown.contains("自動では再開を試しません")); - assert!(reset_known("seat", &parent, "10月10日 01:20").contains("再開の合図")); + assert!(reset_known("seat", &parent, "10月10日 01:20").contains("再開の指示")); let child = Stop { subject: "child:a".into(), ..parent }; - assert!(!reset_known("seat", &child, "10月10日 01:20").contains("再開の合図")); - assert!(nudged("seat").contains("保留を続けます")); - assert_eq!( - resumed("seat", 0), - "seat の親セッションは再開しました(正常親応答を確認)。" - ); - assert!(resumed("seat", 48).contains("預かっていた 48 件")); - let still = still_limited("seat", Some("10月10日 06:20"), "10月10日 06:20"); - assert!(still.contains("まだ利用上限") && still.contains("もう一度再開を試します")); - assert!(still_limited("seat", None, "10月10日 01:21").contains("解除予定は不明")); - assert!(no_answer("seat").contains("自動の再開はここで止め")); + assert!(!reset_known("seat", &child, "10月10日 01:20").contains("再開の指示")); + let child_stop = stopped("seat", &child, None); + assert!(typed("seat", false).contains("再開の指示を打ちます")); + assert!(typed("seat", true).contains("まだ利用上限") && typed("seat", true).contains("もう一度")); + let other = other_cause("seat"); + assert!(other.contains("利用上限以外の理由") && other.contains("打ち直しません") && other.contains("端末を見て")); + let back = back_after_other_cause("seat"); + assert!(back.contains("再開しました") && back.contains("解消")); + assert!(!back.contains("(返信不要)") && !back.contains("保留")); + for text in [known, unknown, child_stop, typed("seat", false), typed("seat", true), other.clone()] { + assert!(!text.contains("保留"), "{text}"); + } // The unified format (#356) is added once by the app's notice layer. - for text in [ - nudged("seat"), - resumed("seat", 1), - still, - no_answer("seat"), - reached("seat", "child:a"), - ] { + for text in [typed("seat", false), other, reached("seat", "child:a")] { assert!(!text.contains("(返信不要)")); } } - #[test] - fn resume_notice_requests_terminal_answer_without_claiming_delivery() { - let text = nudged("seat"); - assert!(text.contains("解除予定時刻を過ぎた")); - assert!(text.contains("一言だけ応答")); - assert!(text.contains("作業を始めず、部屋にも返信せず")); - assert!(!text.contains("打ちました")); - assert!(!text.contains("(返信不要)")); - } } diff --git a/crates/mcp-config/src/claude_resume.rs b/crates/mcp-config/src/claude_resume.rs index 7877827..b718702 100644 --- a/crates/mcp-config/src/claude_resume.rs +++ b/crates/mcp-config/src/claude_resume.rs @@ -1,45 +1,47 @@ -//! The resume attempt of a limited Claude parent at its reset (#357). +//! The resume prompt a limited Claude parent is typed at its reset (#357, +//! simplified by #401). //! -//! The parent gate of #342 is untouched: only a new normal parent response -//! read from the transcript releases it. What this adds is the start of that -//! confirmation. A little after the known reset, the app types one short -//! nudge into the seat's terminal; the seat's own answer is the confirmation. -//! A rejection read after the nudge is "still limited" and schedules the next -//! attempt. No answer at all stops the automatic attempts, so that unsent -//! nudges never pile up in an input box. Child limits are not scheduled here. +//! The parent's stop and recovery are read from its transcript (#342): a +//! rejection stops it, a later normal parent response recovers it. What this +//! adds is the one input that lets a seat left alone come back by itself. A +//! little after the known reset, the app types [`PROMPT`] once into the +//! terminal that occupies the seat — whichever launch it is. No terminal, no +//! typing: the parent's unanswered rejection stays in its transcript, and the +//! seat's next launch reads it back ([`Resume::restored`]) and types then. +//! +//! After typing, three outcomes. A rejection read after it: still limited, so +//! the next attempt is scheduled and typed again. A normal response: the seat +//! is back, and nothing more is typed. Neither within [`ANSWER_WAIT`]: the +//! seat stopped on something other than the limit (a selection screen, an +//! expired login, a trust prompt), where a typed submit key could pick a +//! choice — so nothing is typed again, the room is told once, and the attempts +//! stop until a new rejection arms them. Child limits are not scheduled here. //! //! Times are Unix seconds, passed in; a rejection's `at` is the transcript's //! milliseconds. use super::codex::limit::{BACKOFF, GRACE}; -/// How long a nudge waits for the seat's answer (a rejection or a normal -/// response) before the attempt counts as unanswered. +/// How long a typed prompt waits for the seat's answer (a rejection or a +/// normal response) before the seat counts as stopped on something else. pub const ANSWER_WAIT: i64 = 120; -/// Internal routing metadata, never deserialized from room/socket posts. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Launch { - pub topic: String, - pub account: String, - pub nonce: String, - pub pty: String, -} -impl Launch { - pub fn matches(&self, topic: &str, account: &str, nonce: &str, pty: &str) -> bool { - self.topic == topic && self.account == account && self.nonce == nonce && self.pty == pty - } -} +/// What is typed into the seat's terminal at its reset. +pub const PROMPT: &str = "利用上限の解除予定時刻を過ぎました。中断していた作業を再開してください。中断していた作業がなければ、そのまま待機してください。"; + +/// One line per decision, beside the app's other logs (`logs/`). +pub const LOG_FILE: &str = "claude-resume.log"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Action { - /// Send the resume room notice now. The attempt counts as sent from this moment. - Nudge, - /// The last nudge drew no answer within [`ANSWER_WAIT`]. Automatic - /// attempts stop until the next rejection arms them again. - NoAnswer, + /// Type [`PROMPT`] now. `retry` when the last one was answered by the + /// limit. The attempt counts as typed from this moment. + Type { retry: bool }, + /// The last prompt drew no answer within [`ANSWER_WAIT`]. Nothing is typed + /// again until the next rejection arms the schedule. + OtherCause, } -/// What a rejection meant for an outstanding nudge. +/// What a rejection meant for an outstanding prompt. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct StillLimited { pub reset: Option, @@ -51,19 +53,21 @@ pub struct Resume { next: Option, sent: Option, misses: u32, + retry: bool, } impl Resume { - /// A parent rejection the gate has just applied. Returns `Some` when it - /// answers an outstanding nudge (the seat is still limited), with the - /// next attempt. Otherwise it only arms or moves the schedule, and an - /// unknown reset arms nothing: there is no reset time to act at. + /// A parent rejection just applied. Returns `Some` when it answers an + /// outstanding prompt (the seat is still limited), with the next attempt. + /// Otherwise it only arms or moves the schedule, and an unknown reset arms + /// nothing: there is no reset time to act at. pub fn rejected(&mut self, at_ms: i64, reset: Option, now: i64) -> Option { if let Some(sent) = self.sent { if at_ms < sent.saturating_mul(1000) { - return None; // Written before the nudge: not its answer. + return None; // Written before the prompt: not its answer. } self.sent = None; + self.retry = true; let next = match reset { Some(r) if r + GRACE > now + BACKOFF[0] => { self.misses = 0; @@ -88,10 +92,11 @@ impl Resume { None } - /// The schedule of a seat restarted while still limited (#366), from the - /// reset its last parent rejection named. Before the reset, one nudge a - /// little after it; with the reset already passed, one nudge right away. - /// An unknown reset arms nothing, as in [`Resume::rejected`]. + /// The schedule of a launch whose parent's last event before it is an + /// unanswered rejection (#366): the pending resume of a seat that had no + /// terminal at its reset. Before the reset, one prompt a little after it; + /// with the reset already passed, one right away. An unknown reset arms + /// nothing, as in [`Resume::rejected`]. pub fn restored(reset: Option, now: i64) -> Self { Self { next: reset.map(|r| (r + GRACE).max(now)), @@ -99,21 +104,19 @@ impl Resume { } } - /// Called once a second while the parent is limited by a rejection. + /// Called once a second while the parent is limited. pub fn tick(&mut self, now: i64) -> Option { if let Some(sent) = self.sent { if now >= sent + ANSWER_WAIT { - self.sent = None; - self.next = None; - self.misses = 0; - return Some(Action::NoAnswer); + *self = Self::default(); + return Some(Action::OtherCause); } return None; } if self.next.is_some_and(|n| now >= n) { self.next = None; self.sent = Some(now); - return Some(Action::Nudge); + return Some(Action::Type { retry: self.retry }); } None } @@ -123,41 +126,95 @@ impl Resume { *self = Self::default(); } + /// Whether a prompt is still to be typed or answered. + pub fn pending(&self) -> bool { + self.next.is_some() || self.sent.is_some() + } + pub fn next(&self) -> Option { self.next } } +/// The seats the room was told are stopped on another cause ([`Action::OtherCause`]), +/// kept per seat (topic, account) across its launches. A normal response of +/// that seat's parent later answers the notice once, so "look at the terminal" +/// is not left standing. +#[derive(Debug, Default)] +pub struct Stalled(std::collections::HashSet<(String, String)>); + +impl Stalled { + pub fn told(&mut self, topic: &str, account: &str) { + self.0.insert((topic.into(), account.into())); + } + + /// The seat's parent answered normally. `true` once when the room was + /// told of another cause and is now to hear that the seat is back. + pub fn answered(&mut self, topic: &str, account: &str) -> bool { + self.0.remove(&(topic.to_string(), account.to_string())) + } +} + +/// One line of [`LOG_FILE`]: `[claude-resume] room= +/// account= at=