From b92152298cb3385e94ae781066324d5c1fa7c6c7 Mon Sep 17 00:00:00 2001 From: lipluscodex <268560960+lipluscodex@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:13:28 +0900 Subject: [PATCH 1/2] fix(session): resume interrupted work after confirmed limit recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 利用上限の解除確定後、本人の親セッションへ中断作業の再開指示を一度渡す。預かり0件でも入力し、預かりがあれば同じ入力へまとめる。 許可待ちの保留を維持し、確認した起動に入力を束縛して別セッションへの持越しを防ぐ。仕様と回帰テストを更新する。 Refs #389 --- crates/mcp-config/src/lib.rs | 1 + crates/mcp-config/src/limit_recovery.rs | 126 ++++++++++++++++++++++++ crates/mcp-config/src/prompt_hold.rs | 13 ++- docs/1-room.md | 2 +- docs/2-screen.md | 2 +- docs/3-accounts.md | 4 +- docs/6-tradeoffs.md | 2 +- src-tauri/src/claude_limit.rs | 61 ++++++------ src-tauri/src/codex_limit.rs | 32 +++++- src-tauri/src/room.rs | 1 + 10 files changed, 201 insertions(+), 43 deletions(-) create mode 100644 crates/mcp-config/src/limit_recovery.rs diff --git a/crates/mcp-config/src/lib.rs b/crates/mcp-config/src/lib.rs index 213321a..bb25753 100644 --- a/crates/mcp-config/src/lib.rs +++ b/crates/mcp-config/src/lib.rs @@ -38,6 +38,7 @@ 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 new file mode 100644 index 0000000..8436167 --- /dev/null +++ b/crates/mcp-config/src/limit_recovery.rs @@ -0,0 +1,126 @@ +//! The single input handed to a parent after its limit recovery is confirmed. +//! Confirmation remains the caller's Codex/Claude gate (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::{ + claude_limit::{Event, Gate}, + 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 claude_confirmed_response_keeps_one_continuation_through_permission_wait() { + let mut gate = Gate::new(100, false); + gate.state.apply(Event::Rejected { + at: 101, + id: "reject".into(), + reset: Some(200), + }); + assert!(gate.is_limited()); + let success = Event::Succeeded { + at: 201, + id: "success".into(), + }; + assert!(gate.state.apply(success.clone())); + gate.keep_holding(); + assert!(gate.is_limited()); + let mut queue = Queue::new(); + let item = Item { + message_id: "recovery".into(), + text: input(&gate.release()), + post: None, + launch_bound: true, + }; + assert!(queue.pass(true, item).is_none()); + assert!(!gate.is_limited()); + assert!(!gate.state.apply(success)); + 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, + }; + let ordinary = Item { + message_id: "post".into(), + text: "ordinary room post".into(), + post: None, + launch_bound: false, + }; + 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()); + } +} diff --git a/crates/mcp-config/src/prompt_hold.rs b/crates/mcp-config/src/prompt_hold.rs index d9739fa..a182e4b 100644 --- a/crates/mcp-config/src/prompt_hold.rs +++ b/crates/mcp-config/src/prompt_hold.rs @@ -56,6 +56,8 @@ pub struct Item { /// 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. + pub launch_bound: bool, } /// One seat's kept posts. @@ -117,14 +119,17 @@ impl Queue { /// front of the queue the launch after it carried (`carry`). A hand-over of /// that launch, if one is under way, is not ended by it. pub fn put_first(&mut self, item: Item) { - self.items.push_front(item); + if !item.launch_bound { + self.items.push_front(item); + } } /// The queue of a seat launched again: what was kept carries over to the - /// new launch, and a hand-over that was under way for the old one ends. + /// new launch, except continuation bound to the old launch (#389). A + /// hand-over that was under way for the old one ends. pub fn carry(self) -> Queue { Queue { - items: self.items, + items: self.items.into_iter().filter(|item| !item.launch_bound).collect(), handing: false, } } @@ -198,6 +203,7 @@ mod tests { Item { message_id: id.into(), text: format!("[pullcept] {id}"), + launch_bound: false, post: Some(Held { message_id: id.into(), speaker: "Lin".into(), @@ -213,6 +219,7 @@ mod tests { message_id: id.into(), text: format!("[pullcept] digest {id}"), post: None, + launch_bound: false, } } diff --git a/docs/1-room.md b/docs/1-room.md index 627dfa5..33cfeb6 100644 --- a/docs/1-room.md +++ b/docs/1-room.md @@ -57,7 +57,7 @@ Pullcept (Tauri app) = 部屋の壁 - **札に `role` を載せる。書くのはアプリであり、発言の本文からは決して取らない(#195)。** 値は発言が部屋へ入った口から決まる(`terminal_input::role`): - 画面のコマンド(`room_post`、webview の Tauri コマンド)から来た発言は `admin` である。**`admin` になるのはこれだけである。** - アプリが受けた MCP サーバの通知は `mcp` である(発言するアカウントの種別)。 - - アプリ自身の知らせ——Codex の席の利用上限での停止と解除、解除後にその席へ打つ一通(#294、「[Codex の席が利用上限で止まったとき](3-accounts.md#codex-の席が利用上限で止まったとき294)」)——は `app` である。発言者は `Pullcept(自動通知)` で、アカウントを持たない。どのアカウントの種別でもなく、ソケットからは作らない。 + - アプリ自身の知らせ——Codex の席の利用上限での停止と解除、解除確定後に本人の中断作業の再開指示と預かり発言のまとめを同じ入力で一度渡す一通(預かり 0 件でも再開指示を渡す、#389 / #294、「[Codex の席が利用上限で止まったとき](3-accounts.md#codex-の席が利用上限で止まったとき294)」)——は `app` である。発言者は `Pullcept(自動通知)` で、アカウントを持たない。どのアカウントの種別でもなく、ソケットからは作らない。 - 部屋ソケットから来た発言は、その席が `hello` で名乗ったアカウントの種別である(`claude_code` / `cli`)。名乗りが無い、アプリに無いアカウントを名乗った、または `admin` や `mcp` のアカウントを名乗った接続は `unknown` である。 - **`admin` はソケットから作らない(Master 判断、2026-09-28)。** 部屋のトークンは部屋全体で一つで、各セッションの `.mcp.json` に書かれている。`hello` の `account_id` も検証されない。セッションは自分で二本目のソケットを張り、`admin` のアカウント id を名乗れる。したがってソケットから来た発言は、名乗った id に依らず `admin` にしない。セッションが届かない口——webview の中のコマンド——だけが `admin` を作る。席ごとのトークンで id を縛る形より部品が少ない。 - **本物の札は一行目だけである。** 一行目を書けるのはアプリだけであり、本文は送信キーを持たない(下記)。二行目以降に札の形の行があっても、それは本文である。この規則の置き場はサイドカーの `instructions` 一か所である(Master 判断、2026-09-28)——AI 側(キャラクター・CLAUDE.md 等)に作法を書き込まずに済み、CLI の種別にも依らない。 diff --git a/docs/2-screen.md b/docs/2-screen.md index 94c66f3..1c42578 100644 --- a/docs/2-screen.md +++ b/docs/2-screen.md @@ -553,7 +553,7 @@ Master「コメント書き込み欄の上にある下までスクロールボ - **待ちは席の hook から読む。** 本体かサブエージェントのどれか一つでも許可待ちであれば待ちとする(`Activity::any_waiting`)。どのエージェントの画面も同じ一つの端末に出るためである。待ちの始まりと終わりは上の「許可待ち」と同じである:`PermissionRequest` で始まり、人が端末に打った答えのキー(本体だけ)、そのエージェントの次の `PreToolUse`、同じツール名の終わり、ターンの区切り、`SubagentStop`、読めない `PostToolUse` で終わる。 - **預かるかどうかは、発言を端末の列へ渡すときに決める。** 列で順番を待っている間に許可の画面が出た発言は、そのまま打たれる。`PermissionRequest` の hook は画面より約 3 秒早く届く(上の「カードは `PermissionRequest` の hook から出る」)ため、この隙間は短い。 - **待ちが終わってすぐには打たない。** 終わりを聞いてから 1.5 秒(`prompt_hold::RELEASE_GRACE`)待ち、その時点でまだどのエージェントも待っていなければ渡し始める。端末で答えた直後に次の許可(並列の別のツール)が続くことがあり、その要求の hook は画面より前に届くためである。渡している途中で待ちが始まれば、そこで止め、残りは次の待ちの終わりまで預かる。 -- **順番を守り、一度だけ渡す。** 預かっている発言があるか、渡している途中の間は、待ちが終わっていても新しい発言はその後ろへ並ぶ。端末が受け付けなかった発言は列の先頭へ戻し、捨てない。席を起動し直したときは、預かった発言を新しい起動へ持ち越す。 +- **順番を守り、一度だけ渡す。** 預かっている発言があるか、渡している途中の間は、待ちが終わっていても新しい発言はその後ろへ並ぶ。端末が受け付けなかった発言は列の先頭へ戻し、捨てない。席を起動し直したときは、預かった発言を新しい起動へ持ち越す。ただし利用上限の解除確定後の作業再開入力(#389)は確認した起動にだけ渡し、別の起動へは持ち越さない。 - **利用上限の預かりが先である(#294・#342)。** 利用上限で止まっている席への発言は、今までどおりその預かり(受け箱)に入り、この列には来ない。許可待ちが終わったときに席が利用上限で止まっていれば、預かった発言は打たずに利用上限の預かりへ移し、解除のまとめと一緒に一度だけ渡る。利用上限の解除のまとめを打つときに席が許可待ちであれば、まとめもこの列で預かり、待ちの後に打つ。まとめは解除そのものであるため、その後にまた上限で止まっていても利用上限の預かりへは戻さない。順番が一つだけ崩れる場合がある:許可待ちの間にこの列へ入った発言は、その待ちの途中で利用上限が始まると、利用上限が預かった発言の後ろに並ぶ。 - **端末で答えることは変えない(#336)。** 預かるのは部屋がアプリから打ち込む発言だけである。人が端末の欄に打つキーは今までどおり届き、許可の画面に答えられる。 - **記録する。** 預かった発言と、渡したときの行き先(`typed`:端末へ打った、`to-limit`:利用上限の預かりへ移した、`refused`:端末が受け付けず列へ戻した)を、上の `logs\permission-requests.log` に一行ずつ書く(`prompt_hold::log_line`)。発言の本文は書かず、`message_id` だけを書く。 diff --git a/docs/3-accounts.md b/docs/3-accounts.md index c144951..4d8f77a 100644 --- a/docs/3-accounts.md +++ b/docs/3-accounts.md @@ -625,7 +625,7 @@ CLI は `.mcp.json` のサーバのうち承認されていないものを、セ - 止まっている間に部屋の発言を預かったら、次の問い合わせを今に繰り上げる(#377)。ただし、前の問い合わせから 60 秒は空ける(`HELD_GAP`)。解除予定まで待つと、その前に上限を手でリセットしても、予定の時刻まで解除に気づけない——2026-10-11 に Master がリセットしたとき、解除予定は 10/14 だった。タイマーで聞き直さず、発言を契機にするのは Master の案である(「タイマー方式じゃなく、発言があった時にってするのはどう?」)。解除を急いで知る必要があるのは、渡したい発言があるときだけだからである。誰も発言しない間は、今までどおり解除予定まで待つ。 - app-server に繋がらなければ解除を確かめられず、席は「制限中」のまま、発言は預かったままである(「[受容したトレードオフ](6-tradeoffs.md#受容したトレードオフ)」)。 -**解除を確かめたら、部屋に一通、席に一通。** 部屋へは解除を知らせる(`Pullcept(自動通知)`、`app`。その席の端末へは打たない)。席へは、預かった発言を全部は渡さず、次の一通を打つ:「制限中に部屋で N 件の発言がありました。必要なら read_room_history で読んでください。」その席宛て(`to` に席の名前がある)の発言だけは、発言者・時刻・`message_id` と本文を続けて付ける。預かった発言が無ければ席へは何も打たない。この一通の札の `message_id` は、それが勘定に入れた最新の発言(部屋への解除の知らせ。それが入らなかったときは預かった最後の発言)の id である——席が次の `say_to_room` の `last_seen` に使えば、預かった発言で弾かれない。一通を打ってから席を「制限中」から戻すまでは一つの錠の内で行い、その後に届いた発言は一通の後に打たれる。 +**解除を確かめたら、部屋に一通、席に一通。** 部屋へは解除を知らせる(`Pullcept(自動通知)`、`app`。その席の端末へは打たない)。席へは、預かった発言を全部は渡さず、次の一通を打つ:「制限中に部屋で N 件の発言がありました。必要なら read_room_history で読んでください。」その席宛て(`to` に席の名前がある)の発言だけは、発言者・時刻・`message_id` と本文を続けて付ける。預かった発言が無くても、解除を確定した本人の親セッションへ「中断していた作業を再開してください。中断していた作業がなければ、そのまま待機してください。」と一度入力する(#389)。預かりがある場合は、その再開指示と上記のまとめを同じ入力に含める。この一通の札の `message_id` は、それが勘定に入れた最新の発言(部屋への解除の知らせ。それが入らなかったときは預かった最後の発言)の id である——席が次の `say_to_room` の `last_seen` に使えば、預かった発言で弾かれない。一通を打ってから席を「制限中」から戻すまでは一つの錠の内で行い、その後に届いた発言は一通の後に打たれる。 **止まっている席の受け箱(#312)。** 預かりがメモリの中だけにあると、止まった席のセッションが終わるか Pullcept が起き直したときに消え、そのあと上限が外れていても、起き直した席は止まっていない状態から始まるので解除の流れに進まず、止まっていたあいだの発言のまとめが届かない(2026-10-07 に実際に起きた)。そこで、止まっている席にだけ、再起動をまたいで残る小さな記録——受け箱——を持つ。 @@ -675,7 +675,7 @@ launcher の台帳が同じ起動の live な PTY を持つときだけ最初の 保存範囲は launcher の `CLAUDE_CONFIG_DIR`(未指定なら `~/.claude`)配下の `projects//<親ID>.jsonl` に限定する。報告された path を canonical 化して basename・親 ID・保存範囲を検証し、子の subagents フォルダー、任意ファイル、範囲外 symlink を拒否する。`CLAUDE_CODE_PROJECT_DIR_NAME` による名前変更と長い cwd の派生名にも、reporter の検証済み path で対応する([Claude Code 公式 sessions](https://code.claude.com/docs/en/sessions#name-the-project-directory-yourself))。resume の既存ファイルは spawn 前の EOF を採り、未知 path や置換されたファイルは launch 時刻より前のイベントを除く。欠落・部分追記・不正 JSON は回復の証拠にせず、本文を状態・ログへ保存しない。 -親の `isSidechain=false` の新しい assistant にある `isApiErrorMessage=true` / `error=rate_limit` の構造化拒否で停止する。回復はその後の requestId と実モデルを持つ正常な親 assistant のみであり、API error フラグの未設定を許容するが `` の応答では解除しない。reset 時刻や usage 値だけで解除しない。人間は制限解除後に端末から通常どおり手動再開でき、その実応答を確認してから room 投稿の保持のまとめを一度渡す。解除予定が分かっていれば、その時刻を過ぎたところで Pullcept が端末へ再開の合図を一度打ち、この確認を自分で始める(下記「[解除予定時刻に親の再開を試す](#解除予定時刻に親の再開を試す357)」)。SubagentStop の到着を捏造せず、保持した subagent 一覧は実 hook のまま扱う。 +親の `isSidechain=false` の新しい assistant にある `isApiErrorMessage=true` / `error=rate_limit` の構造化拒否で停止する。回復はその後の requestId と実モデルを持つ正常な親 assistant のみであり、API error フラグの未設定を許容するが `` の応答では解除しない。reset 時刻や usage 値だけで解除しない。人間は制限解除後に端末から通常どおり手動再開でき、その実応答を確認してから、中断作業の再開指示を一度渡す(#389)。保持した room 投稿があれば同じ入力にまとめ、0 件でも再開指示を渡す。解除予定時刻だけではこの指示を送らず、確認専用の合図は作業を始めさせない。現在の親・nonce・PTY を照合し、許可待ちと未送信入力の保留を維持する。再開指示は解除を確認した起動だけに束縛し、許可待ち中に席を起動し直した場合は新しいセッションへ持ち越さない。同じ正常応答の再観測では繰り返さない。解除予定が分かっていれば、その時刻を過ぎたところで Pullcept が端末へ再開の合図を一度打ち、この確認を自分で始める(下記「[解除予定時刻に親の再開を試す](#解除予定時刻に親の再開を試す357)」)。SubagentStop の到着を捏造せず、保持した subagent 一覧は実 hook のまま扱う。 Claude mailbox は Codex と別ファイルであり、全 Claude 席の一つの lock が read/modify/write と release を守る。保持 ID の順序・重複排除を維持し、再開/アプリ再起動でも失わない。放出は mailbox を閉じてから端末入力を enqueue し、enqueue の直前にアプリが終了した場合は再送しない(Codex の mailbox と同じ重複回避の境界)。実機の親制限→手動再開→正常応答後に一度通知する確認は、合成試験の成功だけでは完了としない。 diff --git a/docs/6-tradeoffs.md b/docs/6-tradeoffs.md index a0e2eff..7779858 100644 --- a/docs/6-tradeoffs.md +++ b/docs/6-tradeoffs.md @@ -39,7 +39,7 @@ native ID は初回の実会話で取得するため、起動直後にはまだ | app-server に繋がらなければ Codex の席は「制限中」のまま(#294) | 解除の確認は `account/rateLimits/read` の `ordinaryUsageAllowed: true` だけである。繋がらない・壊れた答えのあいだ、席は解除後も「制限中」のまま、発言は預かったままになる。席を起動し直しても、受け箱が残っているので止まった状態から始まる(#312)。プロトコルが使用率と解除時刻からの推測を禁じており、推測で戻して失敗の連鎖を再び起こすより、止まったままの側へ倒した | | 解除した Codex の席へは要約の一通だけを渡す(#294) | 預かった発言のうち本文が届くのはその席宛てのものだけで、他は件数だけである。中身は `read_room_history` で席が自分で読む。全部を流し込めば、それぞれが一ターンを起こし、制限明けの枠を部屋の雑談で使い切りうる | | 止まっている席の受け箱には預かった発言だけが入る(#294、#312) | 受け箱は預かった発言の id の並びで、再起動をまたいで残り、起動した席はそれがあれば止まった状態から始まって、解除のときにその発言のまとめを受け取る。席を閉じていたあいだと Pullcept が落ちていたあいだの発言は入らず、まとめにも載らない——部屋は過去を押し付けず、席は `read_room_history` で読める(#115 と同じ線、2026-10-07 マスター判断)。受け箱はまとめを打つ前に消すため、その間で落ちれば席はまとめを受け取りそこなう。逆にすれば同じまとめを二度打ちうる。受け箱は席を起動し直すかトピックを消すまで残る | -| 停止と解除の知らせはその席の端末へ打たない(#294) | 席は自分宛ての解除の知らせを端末では受けない。預かった発言が無かった場合、その席の次の `say_to_room` は二つの知らせを「未読」として一度弾かれうる。打てば止まった席に失敗するターンが一つ増える | +| 停止と解除の知らせはその席の端末へ打たない(#294) | 部屋への停止・解除通知自体は本人へ打たない。解除確定後は預かり 0 件でも別の再開入力を一度渡し、その札に解除通知の既読位置を載せる(#389)。停止中の失敗ターンを増やさない | | 問い合わせごとに `codex app-server` を一つ起動する(#294) | 一回 0.5 秒未満のプロセスを、止まっている席についてだけ、多くても 1 分に一度起こす。常駐の接続を持たないため、席ごとのアカウントと環境をそのまま使え、daemon の寿命に依らない | | Codex の再開した席は最初のターンまで五欄が「—」(#283) | rollout には前回の実行の値が残っているが、起動時点のファイル長から読むため出さない。前回の使用率を今回の値として出せば、解けた制限で「制限中」が立ちうる。Claude Code の席が次の報告まで「—」であるのと同じ形である | | ステータスラインの JSON の欄名に依存する(#155 / #161) | 五欄の出所は CLI の欄名であり、CLI がどれかを改名すればパネルの一行が消える。5 時間と週次の二つは「制限中」の出所でもあるため、その二つが改名されれば語ごと消える。値そのものが目的である以上この依存は受ける。端末の出力を読まないこと(#82)は動いていない | diff --git a/src-tauri/src/claude_limit.rs b/src-tauri/src/claude_limit.rs index 691869c..84b26f9 100644 --- a/src-tauri/src/claude_limit.rs +++ b/src-tauri/src/claude_limit.rs @@ -344,6 +344,7 @@ impl ClaudeLimits { message_id: id, text, post: None, + launch_bound: false, }; crate::hook_activity::type_or_keep(app, pty, item); } @@ -531,37 +532,37 @@ fn round(app: &AppHandle, topic: &str, account: &str, nonce: &str, pty: &str) { e.resume.recovered(); let held = limit::mailbox(&record, &seat, &e.stored(account), e.gate.release()); e.persist(account, |boxes, id| boxes.close(id)); - if let Some(text) = limit::digest(&held) { - let watermark = notice.or_else(|| held.last().map(|p| p.message_id.clone())); - let at = terminal_input::at(&crate::room::now_iso()); - let typed = terminal_input::compose( - watermark.as_deref().unwrap_or_default(), - crate::room::APP_SPEAKER, - terminal_input::ROLE_APP, - at.as_deref(), - &[], - &text, - ); - // A seat waiting on a permission prompt keeps the digest until the - // wait ends (#346): its submit key would answer the prompt. Kept - // counts as enqueued; it is this release, and is typed once. - let item = mcp_config::prompt_hold::Item { - message_id: watermark.clone().unwrap_or_default(), - text: typed, - post: None, - }; - if !crate::hook_activity::type_or_keep(app, pty, item) { - // No input was enqueued: retain the mailbox for the next launch. - e.gate.keep_holding(); - e.persist(account, |boxes, id| { - boxes.open(id); - for post in &held { - boxes.push(id, &post.message_id); - } - }); - for post in held { - e.gate.hold(|| post); + let text = mcp_config::limit_recovery::input(&held); + let watermark = notice.or_else(|| held.last().map(|p| p.message_id.clone())); + let at = terminal_input::at(&crate::room::now_iso()); + let typed = terminal_input::compose( + watermark.as_deref().unwrap_or_default(), + crate::room::APP_SPEAKER, + terminal_input::ROLE_APP, + at.as_deref(), + &[], + &text, + ); + // A seat waiting on a permission prompt keeps this input until the + // wait ends (#346): its submit key would answer the prompt. Kept + // counts as enqueued; it is this release, and is typed once. + let item = mcp_config::prompt_hold::Item { + message_id: watermark.clone().unwrap_or_default(), + text: typed, + post: None, + launch_bound: true, + }; + if !crate::hook_activity::type_or_keep(app, pty, item) { + // No input was enqueued: retain the mailbox for the next launch. + e.gate.keep_holding(); + e.persist(account, |boxes, id| { + boxes.open(id); + for post in &held { + boxes.push(id, &post.message_id); } + }); + for post in held { + e.gate.hold(|| post); } } } diff --git a/src-tauri/src/codex_limit.rs b/src-tauri/src/codex_limit.rs index bf9d1be..b8fbbe9 100644 --- a/src-tauri/src/codex_limit.rs +++ b/src-tauri/src/codex_limit.rs @@ -261,6 +261,9 @@ impl Limiter { /// restart, and its mailbox file holds the rest. The record is read once, /// before the notice goes in. fn recover(&self) { + if !self.current() { + return; + } let record = self.record(); let seat = limit::Seat { name: &self.name, @@ -276,8 +279,11 @@ impl Limiter { &limit::recovery_notice(&self.name, count), Some(&self.pty_id), ); - let ptys = self.app.state::(); self.with(|entry| { + // The app-server query may have outlived this launch. + if !self.current() { + return; + } // Read again with what was held up to the release, so a post held // after the record was read is not lost. let ids = entry.stored(); @@ -285,9 +291,7 @@ impl Limiter { // The mailbox goes before the line is typed: cut short between the // two, the seat misses its line rather than being handed it twice. entry.persist(|boxes, account| boxes.close(account)); - let Some(text) = limit::digest(&held) else { - return; - }; + let text = mcp_config::limit_recovery::input(&held); // The label carries the id of the newest post this line accounts // for — the recovery notice, or the newest held post if the notice // did not go in — so the seat's next `last_seen` stands past @@ -302,10 +306,28 @@ impl Limiter { &[], &text, ); - ptys.type_in(&self.pty_id, typed); + crate::hook_activity::type_or_keep( + &self.app, + &self.pty_id, + mcp_config::prompt_hold::Item { + message_id: watermark.unwrap_or_default(), + text: typed, + post: None, + launch_bound: true, + }, + ); }); } + fn current(&self) -> bool { + self.app + .state::() + .running_pty(&self.topic_id, &self.account_id) + .as_deref() + == Some(&self.pty_id) + && self.app.state::().is_running(&self.pty_id) + } + /// The topic's record, as `limit::mailbox` reads it. Unreadable is no /// record, which leaves the line to what memory held. fn record(&self) -> Vec { diff --git a/src-tauri/src/room.rs b/src-tauri/src/room.rs index c2dc529..0b29459 100644 --- a/src-tauri/src/room.rs +++ b/src-tauri/src/room.rs @@ -1149,6 +1149,7 @@ fn type_into_sessions( message_id: post.message_id.clone(), text: text.clone(), post: Some(post_for_hold()), + launch_bound: false, }, ); } From f4729ca0decfab5a98b3c856896e6bac4716c805 Mon Sep 17 00:00:00 2001 From: lipluscodex <268560960+lipluscodex@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:15:48 +0900 Subject: [PATCH 2/2] fix(session): preserve held digest across recovery relaunch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 許可待ち中の再起動では再開指示だけを除き、同じ札と既読位置の預かり発言のまとめを従来どおり引き継ぐ。0件なら別起動へ入力を運ばない。 旧起動の配送拒否後にも同じ変換を適用し、保持済み発言を失わないための回帰試験を追加する。 Refs #389 --- crates/mcp-config/src/limit_recovery.rs | 37 +++++++++++++++++++++++++ crates/mcp-config/src/prompt_hold.rs | 18 ++++++++++-- docs/2-screen.md | 2 +- docs/3-accounts.md | 2 +- src-tauri/src/claude_limit.rs | 8 ++++-- src-tauri/src/codex_limit.rs | 9 +++--- src-tauri/src/room.rs | 1 + 7 files changed, 66 insertions(+), 11 deletions(-) diff --git a/crates/mcp-config/src/limit_recovery.rs b/crates/mcp-config/src/limit_recovery.rs index 8436167..7a58905 100644 --- a/crates/mcp-config/src/limit_recovery.rs +++ b/crates/mcp-config/src/limit_recovery.rs @@ -86,6 +86,7 @@ mod tests { text: input(&gate.release()), post: None, launch_bound: true, + relaunch_text: None, }; assert!(queue.pass(true, item).is_none()); assert!(!gate.is_limited()); @@ -104,12 +105,14 @@ mod tests { 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()); @@ -123,4 +126,38 @@ mod tests { 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 a182e4b..fe19806 100644 --- a/crates/mcp-config/src/prompt_hold.rs +++ b/crates/mcp-config/src/prompt_hold.rs @@ -58,6 +58,18 @@ pub struct Item { pub post: Option, /// Recovery continuation belongs only to the launch that confirmed it. 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) + } } /// One seat's kept posts. @@ -119,7 +131,7 @@ impl Queue { /// front of the queue the launch after it carried (`carry`). A hand-over of /// that launch, if one is under way, is not ended by it. pub fn put_first(&mut self, item: Item) { - if !item.launch_bound { + if let Some(item) = item.for_relaunch() { self.items.push_front(item); } } @@ -129,7 +141,7 @@ impl Queue { /// hand-over that was under way for the old one ends. pub fn carry(self) -> Queue { Queue { - items: self.items.into_iter().filter(|item| !item.launch_bound).collect(), + items: self.items.into_iter().filter_map(Item::for_relaunch).collect(), handing: false, } } @@ -204,6 +216,7 @@ mod tests { message_id: id.into(), text: format!("[pullcept] {id}"), launch_bound: false, + relaunch_text: None, post: Some(Held { message_id: id.into(), speaker: "Lin".into(), @@ -220,6 +233,7 @@ mod tests { text: format!("[pullcept] digest {id}"), post: None, launch_bound: false, + relaunch_text: None, } } diff --git a/docs/2-screen.md b/docs/2-screen.md index 1c42578..5a1bdeb 100644 --- a/docs/2-screen.md +++ b/docs/2-screen.md @@ -553,7 +553,7 @@ Master「コメント書き込み欄の上にある下までスクロールボ - **待ちは席の hook から読む。** 本体かサブエージェントのどれか一つでも許可待ちであれば待ちとする(`Activity::any_waiting`)。どのエージェントの画面も同じ一つの端末に出るためである。待ちの始まりと終わりは上の「許可待ち」と同じである:`PermissionRequest` で始まり、人が端末に打った答えのキー(本体だけ)、そのエージェントの次の `PreToolUse`、同じツール名の終わり、ターンの区切り、`SubagentStop`、読めない `PostToolUse` で終わる。 - **預かるかどうかは、発言を端末の列へ渡すときに決める。** 列で順番を待っている間に許可の画面が出た発言は、そのまま打たれる。`PermissionRequest` の hook は画面より約 3 秒早く届く(上の「カードは `PermissionRequest` の hook から出る」)ため、この隙間は短い。 - **待ちが終わってすぐには打たない。** 終わりを聞いてから 1.5 秒(`prompt_hold::RELEASE_GRACE`)待ち、その時点でまだどのエージェントも待っていなければ渡し始める。端末で答えた直後に次の許可(並列の別のツール)が続くことがあり、その要求の hook は画面より前に届くためである。渡している途中で待ちが始まれば、そこで止め、残りは次の待ちの終わりまで預かる。 -- **順番を守り、一度だけ渡す。** 預かっている発言があるか、渡している途中の間は、待ちが終わっていても新しい発言はその後ろへ並ぶ。端末が受け付けなかった発言は列の先頭へ戻し、捨てない。席を起動し直したときは、預かった発言を新しい起動へ持ち越す。ただし利用上限の解除確定後の作業再開入力(#389)は確認した起動にだけ渡し、別の起動へは持ち越さない。 +- **順番を守り、一度だけ渡す。** 預かっている発言があるか、渡している途中の間は、待ちが終わっていても新しい発言はその後ろへ並ぶ。端末が受け付けなかった発言は列の先頭へ戻し、捨てない。席を起動し直したときは、預かった発言を新しい起動へ持ち越す。ただし利用上限の解除確定後の作業再開入力(#389)は確認した起動にだけ渡し、別の起動へは再開指示を持ち越さない。まとめを同梱していた場合は、従来の預かり発言のまとめだけを同じ既読位置で持ち越す。 - **利用上限の預かりが先である(#294・#342)。** 利用上限で止まっている席への発言は、今までどおりその預かり(受け箱)に入り、この列には来ない。許可待ちが終わったときに席が利用上限で止まっていれば、預かった発言は打たずに利用上限の預かりへ移し、解除のまとめと一緒に一度だけ渡る。利用上限の解除のまとめを打つときに席が許可待ちであれば、まとめもこの列で預かり、待ちの後に打つ。まとめは解除そのものであるため、その後にまた上限で止まっていても利用上限の預かりへは戻さない。順番が一つだけ崩れる場合がある:許可待ちの間にこの列へ入った発言は、その待ちの途中で利用上限が始まると、利用上限が預かった発言の後ろに並ぶ。 - **端末で答えることは変えない(#336)。** 預かるのは部屋がアプリから打ち込む発言だけである。人が端末の欄に打つキーは今までどおり届き、許可の画面に答えられる。 - **記録する。** 預かった発言と、渡したときの行き先(`typed`:端末へ打った、`to-limit`:利用上限の預かりへ移した、`refused`:端末が受け付けず列へ戻した)を、上の `logs\permission-requests.log` に一行ずつ書く(`prompt_hold::log_line`)。発言の本文は書かず、`message_id` だけを書く。 diff --git a/docs/3-accounts.md b/docs/3-accounts.md index 4d8f77a..1279370 100644 --- a/docs/3-accounts.md +++ b/docs/3-accounts.md @@ -675,7 +675,7 @@ launcher の台帳が同じ起動の live な PTY を持つときだけ最初の 保存範囲は launcher の `CLAUDE_CONFIG_DIR`(未指定なら `~/.claude`)配下の `projects//<親ID>.jsonl` に限定する。報告された path を canonical 化して basename・親 ID・保存範囲を検証し、子の subagents フォルダー、任意ファイル、範囲外 symlink を拒否する。`CLAUDE_CODE_PROJECT_DIR_NAME` による名前変更と長い cwd の派生名にも、reporter の検証済み path で対応する([Claude Code 公式 sessions](https://code.claude.com/docs/en/sessions#name-the-project-directory-yourself))。resume の既存ファイルは spawn 前の EOF を採り、未知 path や置換されたファイルは launch 時刻より前のイベントを除く。欠落・部分追記・不正 JSON は回復の証拠にせず、本文を状態・ログへ保存しない。 -親の `isSidechain=false` の新しい assistant にある `isApiErrorMessage=true` / `error=rate_limit` の構造化拒否で停止する。回復はその後の requestId と実モデルを持つ正常な親 assistant のみであり、API error フラグの未設定を許容するが `` の応答では解除しない。reset 時刻や usage 値だけで解除しない。人間は制限解除後に端末から通常どおり手動再開でき、その実応答を確認してから、中断作業の再開指示を一度渡す(#389)。保持した room 投稿があれば同じ入力にまとめ、0 件でも再開指示を渡す。解除予定時刻だけではこの指示を送らず、確認専用の合図は作業を始めさせない。現在の親・nonce・PTY を照合し、許可待ちと未送信入力の保留を維持する。再開指示は解除を確認した起動だけに束縛し、許可待ち中に席を起動し直した場合は新しいセッションへ持ち越さない。同じ正常応答の再観測では繰り返さない。解除予定が分かっていれば、その時刻を過ぎたところで Pullcept が端末へ再開の合図を一度打ち、この確認を自分で始める(下記「[解除予定時刻に親の再開を試す](#解除予定時刻に親の再開を試す357)」)。SubagentStop の到着を捏造せず、保持した subagent 一覧は実 hook のまま扱う。 +親の `isSidechain=false` の新しい assistant にある `isApiErrorMessage=true` / `error=rate_limit` の構造化拒否で停止する。回復はその後の requestId と実モデルを持つ正常な親 assistant のみであり、API error フラグの未設定を許容するが `` の応答では解除しない。reset 時刻や usage 値だけで解除しない。人間は制限解除後に端末から通常どおり手動再開でき、その実応答を確認してから、中断作業の再開指示を一度渡す(#389)。保持した room 投稿があれば同じ入力にまとめ、0 件でも再開指示を渡す。解除予定時刻だけではこの指示を送らず、確認専用の合図は作業を始めさせない。現在の親・nonce・PTY を照合し、許可待ちと未送信入力の保留を維持する。再開指示は解除を確認した起動だけに束縛し、許可待ち中に席を起動し直した場合は新しいセッションへ再開指示を持ち越さない。同梱した預かり発言のまとめは従来どおり同じ札・既読位置で引き継ぐ。同じ正常応答の再観測では繰り返さない。解除予定が分かっていれば、その時刻を過ぎたところで Pullcept が端末へ再開の合図を一度打ち、この確認を自分で始める(下記「[解除予定時刻に親の再開を試す](#解除予定時刻に親の再開を試す357)」)。SubagentStop の到着を捏造せず、保持した subagent 一覧は実 hook のまま扱う。 Claude mailbox は Codex と別ファイルであり、全 Claude 席の一つの lock が read/modify/write と release を守る。保持 ID の順序・重複排除を維持し、再開/アプリ再起動でも失わない。放出は mailbox を閉じてから端末入力を enqueue し、enqueue の直前にアプリが終了した場合は再送しない(Codex の mailbox と同じ重複回避の境界)。実機の親制限→手動再開→正常応答後に一度通知する確認は、合成試験の成功だけでは完了としない。 diff --git a/src-tauri/src/claude_limit.rs b/src-tauri/src/claude_limit.rs index 84b26f9..dec8165 100644 --- a/src-tauri/src/claude_limit.rs +++ b/src-tauri/src/claude_limit.rs @@ -345,6 +345,7 @@ impl ClaudeLimits { text, post: None, launch_bound: false, + relaunch_text: None, }; crate::hook_activity::type_or_keep(app, pty, item); } @@ -535,22 +536,23 @@ fn round(app: &AppHandle, topic: &str, account: &str, nonce: &str, pty: &str) { let text = mcp_config::limit_recovery::input(&held); let watermark = notice.or_else(|| held.last().map(|p| p.message_id.clone())); let at = terminal_input::at(&crate::room::now_iso()); - let typed = terminal_input::compose( + let compose = |text: &str| terminal_input::compose( watermark.as_deref().unwrap_or_default(), crate::room::APP_SPEAKER, terminal_input::ROLE_APP, at.as_deref(), &[], - &text, + text, ); // A seat waiting on a permission prompt keeps this input until the // wait ends (#346): its submit key would answer the prompt. Kept // counts as enqueued; it is this release, and is typed once. let item = mcp_config::prompt_hold::Item { message_id: watermark.clone().unwrap_or_default(), - text: typed, + text: compose(&text), post: None, launch_bound: true, + relaunch_text: limit::digest(&held).map(|digest| compose(&digest)), }; if !crate::hook_activity::type_or_keep(app, pty, item) { // No input was enqueued: retain the mailbox for the next launch. diff --git a/src-tauri/src/codex_limit.rs b/src-tauri/src/codex_limit.rs index b8fbbe9..64b82db 100644 --- a/src-tauri/src/codex_limit.rs +++ b/src-tauri/src/codex_limit.rs @@ -298,22 +298,23 @@ impl Limiter { // everything it was told of here (#47). let watermark = notice.or_else(|| held.last().map(|p| p.message_id.clone())); let at = terminal_input::at(&crate::room::now_iso()); - let typed = terminal_input::compose( + let compose = |text: &str| terminal_input::compose( watermark.as_deref().unwrap_or_default(), crate::room::APP_SPEAKER, terminal_input::ROLE_APP, at.as_deref(), &[], - &text, + text, ); crate::hook_activity::type_or_keep( &self.app, &self.pty_id, mcp_config::prompt_hold::Item { - message_id: watermark.unwrap_or_default(), - text: typed, + message_id: watermark.clone().unwrap_or_default(), + text: compose(&text), post: None, launch_bound: true, + relaunch_text: limit::digest(&held).map(|digest| compose(&digest)), }, ); }); diff --git a/src-tauri/src/room.rs b/src-tauri/src/room.rs index 0b29459..f04ce87 100644 --- a/src-tauri/src/room.rs +++ b/src-tauri/src/room.rs @@ -1150,6 +1150,7 @@ fn type_into_sessions( text: text.clone(), post: Some(post_for_hold()), launch_bound: false, + relaunch_text: None, }, ); }