From c95e6f008c82d9981d48cedd6c8237f048d782f7 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Sat, 29 Aug 2026 04:57:54 +0900 Subject: [PATCH] fix(session): drop the resume record when the room never saw the session (topic-index crate, room_log, session.rs, main.ts, docs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit セッション id は spawn が返った直後に記録される。プロセスが立ったことは 会話ができたことではないため、起動直後の確認プロンプトで止めたまま終えた 回は会話の無い id を残す。以後その(アカウント, トピック)の組は押すたびに 再開の線へ入り、`No conversation found with session ID` で失敗する——落ちる 先が無く、記録を消すかトピックを消すまで直らない。#127 判別は既に在る三つの観測だけで行う:起動が再開の線であったこと(席が持つ)、 部屋の名簿がそのアカウントを一度も運ばなかったこと(行が「起動中」を描いて いるのと同じ観測)、セッションが自分で終わったこと(終了イベントが終了 コードを運んだかどうか。値ではない)。CLI の出力も文言も読まない——文言の 一致を条件にすると、CLI の版が変われば黙って効かなくなる。 外すのは記録がその id のままであるときだけであり、トピックの項目も発言も 残る。外したことは状態行で述べ、自動では起動し直さない。次に押せば通常 起動で立つ。 - `crates/topic-index`: `TopicIndex::forget_session` と、項目・他アカウント を残すこと / id 一致でのみ効くことのテスト - `src-tauri`: `room_log::forget_session` と `room_forget_session` コマンド、 `LaunchLine.resumed` を `resumed_from` へ、席と起動の戻り値が再開元の id と 起動時のトピックを運ぶ - `src/main.ts`: 端末が再開元・トピック・名簿への到着を持ち、終了時に記録を 外して状態行で述べる。最初の名簿の読みも `renderRoster` を通す - `docs/0-requirements.md`: 「トピック」へ判断を追記、実装済み / 未実装 / テストの表 / 受容したトレードオフを更新 Co-Authored-By: Claude Opus 5 --- crates/topic-index/src/lib.rs | 75 +++++++++++++++++ docs/0-requirements.md | 16 +++- src-tauri/src/lib.rs | 1 + src-tauri/src/room_log.rs | 63 ++++++++++++++ src-tauri/src/session.rs | 58 ++++++++++--- src/main.ts | 154 ++++++++++++++++++++++++++++++++-- 6 files changed, 346 insertions(+), 21 deletions(-) diff --git a/crates/topic-index/src/lib.rs b/crates/topic-index/src/lib.rs index 95425ae..bd16b0e 100644 --- a/crates/topic-index/src/lib.rs +++ b/crates/topic-index/src/lib.rs @@ -126,6 +126,32 @@ impl TopicIndex { self.find_mut(topic_id).expect("just inserted when absent") } + /// Take one account's session id off a topic, when the topic still holds + /// exactly that id. Answers whether anything was taken. + /// + /// The id is named rather than assumed, so this removes the record it was + /// told about and never a later one. A resume that could not go back and a + /// fresh launch that recorded a new id are the same account in the same + /// topic; matching on the id is what keeps the first from reaching into the + /// second (#127). + /// + /// A topic this index does not name, an account with nothing on record, and + /// a record holding some other id all answer false. None of the three is an + /// error: the caller is undoing a record it may already have lost the race + /// for, and there being nothing to undo is a legitimate outcome of that. + /// + /// The entry itself stays. What is dropped is the way back into one + /// session; the topic is the conversation, and the conversation did happen. + pub fn forget_session(&mut self, topic_id: &str, account_id: &str, session_id: &str) -> bool { + let Some(topic) = self.find_mut(topic_id) else { + return false; + }; + if topic.sessions.get(account_id).map(String::as_str) != Some(session_id) { + return false; + } + topic.sessions.remove(account_id).is_some() + } + /// Take one topic's entry out. Answers whether there was one. /// /// Private, and a delete path cannot be built out of it by accident: the @@ -466,6 +492,55 @@ mod tests { assert!(read_now(scratch.path()).find("orphaned").is_none()); } + /// The way back out of a session id that no conversation stands behind. + /// + /// The record is written at spawn, which is earlier than the moment a + /// conversation exists, so a launch that ended before the CLI made one + /// leaves an id that every later resume fails on (#127). Taking it off is + /// what puts the next launch back on the normal line. + #[test] + fn forgetting_a_session_leaves_the_topic_and_the_other_accounts() { + let mut index = TopicIndex::default(); + let topic = index.realize("alpha", NOW); + topic.sessions.insert("lay".to_string(), "dead".to_string()); + topic.sessions.insert("lin".to_string(), "alive".to_string()); + + assert!(index.forget_session("alpha", "lay", "dead")); + + let topic = index.find("alpha").expect("the topic itself stays"); + assert_eq!(topic.sessions.get("lay"), None); + assert_eq!( + topic.sessions.get("lin").map(String::as_str), + Some("alive"), + "one account's dead id is not another account's" + ); + } + + /// The guard that keeps a late undo from reaching a live record. + /// + /// The account is seatless the moment its session ends, so it can be + /// launched again before the exit is acted on. That launch records a new + /// id under the same topic and the same account, and it is the id — not the + /// pair — that says which of the two this is. + #[test] + fn forgetting_takes_only_the_id_it_was_told_about() { + let mut index = TopicIndex::default(); + index + .realize("alpha", NOW) + .sessions + .insert("lay".to_string(), "fresh".to_string()); + + assert!(!index.forget_session("alpha", "lay", "dead")); + assert_eq!( + index.find("alpha").expect("topic").sessions.get("lay").map(String::as_str), + Some("fresh"), + "a record that has moved on is not this caller's to undo" + ); + + assert!(!index.forget_session("alpha", "nobody", "dead")); + assert!(!index.forget_session("missing", "lay", "fresh")); + } + #[test] fn refuses_an_id_that_is_not_one_file_name() { let scratch = Scratch::new(); diff --git a/docs/0-requirements.md b/docs/0-requirements.md index 4e51b2a..f9ca88d 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -279,6 +279,16 @@ logs/{部屋名}/{トピック}.jsonl そのトピックの発言、追記の **再開できない席があってもトピックは開く。** 復帰できる席は復帰させ、できない席は新規起動として着席する。読み出しがあるため、復帰できなかった席も自分で辿れる。トピックが開かない形は取らない。resume だったかどうかは画面の状況行で言う——黙って同じ言葉で済ませると、戻ってきたのかどうかを人が判別できない。 +**戻れなかった再開先は、そのトピックから外す(#127)。** セッション id は spawn が返った直後に記録される。プロセスが立ったことは会話ができたことではなく、起動直後の確認プロンプトで止めたまま終えた回は、会話の無い id を残す。以後その(アカウント, トピック)の組は押すたびに再開の線へ入り、同じように失敗する——再開の失敗から通常起動へ落ちる経路は無いため、記録を消すかトピックを消すまで直らない。**再開の線で起動したセッションが、部屋の名簿に一度も現れないまま自分で終了したら、そのトピックからその id の記録を外す。** + +**判定に CLI の出力は読まない。** 文言の一致を条件にすれば、CLI の版で言い回しが変われば黙って効かなくなる。下記「未実装」が「CLI の出力から状態を読む形」として保留している軸にも踏み込まない。読むのは既に在る三つだけである:起動が再開の線であったこと(席が持つ。席はアプリに在り、画面の再読み込みを越える)、名簿がそのアカウントを一度も運ばなかったこと(行が「起動中」を描いているのと同じ観測であり、下記「参加者パネル」が持つ「走っている」の定義そのものである)、そのセッションが自分で終わったこと(終了イベントが終了コードを運んだかどうか。値ではない——こちらから止めたセッションは reap の前に一覧から外れるため、コードの無い終了として着く)。**こちらから止めたものは数えない。** 行の `❌`、トピックの削除、アプリの終了はいずれも人が頼んだ終わりであり、再開の失敗を意味しない。確認プロンプトは起動した CLI を数分のあいだ部屋の外に留めるため(下記「未実装」の #89 の項)、その間に起動し違えたアカウントを止めることは普通の操作であり、成立したはずの戻り道をそれで失ってはならない。**会話ができた瞬間をアプリが知る必要は無い。** 知る必要があるのは戻れなかったことだけであり、それは終了として観測できる。 + +**外すのは、記録がその id のままであるときだけである。** セッションが終われば席は空くため、外す前に同じアカウントが同じトピックへ起動し直して新しい id を書きうる。id で照合すれば、その新しい記録には触れない。**外すのは戻り道だけであり、トピックの項目も発言も残る。** その会話は在ったのであり、無かったのは戻り先である。 + +**外したことは状態行で述べる。** 握り潰すと、履歴が繋がっていないまま話が続く。上記「復帰の手段は二段構えである」と同じ形であり、resume だったかどうかを状況行で言うのと同じ理由である。**自動では起動し直さない(AI 判断)。** 記録が外れているため、次に押せば通常起動で立つ。こちらで再試行を回すと失敗の繰り返しをアプリが回す形になり、`model-loop-safety` が避ける形に当たる。押すかどうかは人が決める。 + +**再開の線に適用範囲を持たせる形は、ここでは扱わない。** 再開コマンドはもともとスリープ復帰への手当てとして入ったものであり(#108、#84)、`resolve_launch` はその由来を知らない。どの状況のための線かをアカウント側が言えるようにする形は設定の概念を増やす変更であり、この復帰とは別の判断である(#127、別 issue)。 + **トピックへ入ると床は空になり、席は入口へ戻る。** 前のトピックの発言は、入るトピックの床ではない。残したままにすると、開き直したトピックで最初に発言した参加者が、前のトピックの保持窓ごと「見落とし」として返される——実際にはその全部を読んでいる参加者に対して、である。席そのものは残る。トピックの切り替えは誰かを部屋から出すことではなく、どこで話しているかが変わることである。 **部屋は過去の発言を channel へ push しない。** 席を取る前の発言が届かないことは変えない。#31 / #39 で退けた「部屋が誰の既読を持つ」形に触らずに済む。トピックによる復帰はこの制約を緩めるのではなく、不要にする。 @@ -340,7 +350,7 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - 部屋のログ(`logs/main/{トピック}.jsonl` への追記、床の判定と同じロック内での書き込み、保存する五欄、`hue` と `own` を持ち込まない写像、追記の失敗の画面への通知、トピックを指定する読み出しコマンド、参加者への再配信をしないこと) - トピック(手動の区切り、起動時の新規トピックと遅延生成、索引とディレクトリの照合、最初の発言からの命名とその場での変更、既存 `logs/main.jsonl` の移行、切り替え時の床の初期化) - トピックの削除(行の `❌`、確認のダイアログ、ファイルと索引の項目の両方の削除、そのトピックで走っているセッションの終了、開いているトピックを消したときの新しいトピックへの移動、題の無い行も消せること) -- トピックの再開(アカウントの再開コマンド、`{session_id}` の置換、トピックへのセッション id の記録、再開できない席の新規着席) +- トピックの再開(アカウントの再開コマンド、`{session_id}` の置換、トピックへのセッション id の記録、再開できない席の新規着席、名簿に現れないまま終えた再開の記録の取り消しと状態行での通知) - 参加者からの読み出し(サイドカーの `read_room_history`、`history` / `history_result` フレーム、pull のみ、ページと `before` の巻き戻し) - トピックの一覧(会話面の左の列、新しい順、先頭に固定の「新規」と索引に無い現在のトピックでのその選択状態、選択による部屋への読み戻し、参加者パネルと対の寸法、日付を含む時刻、名前から導く色) - 参加者モデル(統一 `post` フレーム、発言者以外の全参加者への配送、接続同一性による自分の発言の抑止と名簿の同一性、人間を含む名簿) @@ -474,6 +484,7 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - 部屋のログとトピックの実機確認。追記されること、再起動後に左の列へトピックが並ぶこと、部屋の本文が空から始まること、選んだトピックが本文へ戻ること、名前の自動生成とその場での変更、二つの面の寸法が揃うことはいずれも実装済みで CI の型検査と Rust のコンパイルは通っているが、実機での操作は未確認である。追記の失敗の経路(`room-log-error`)はそもそも失敗を作り出す必要があるため、実機でも未観測である。 - 既存 `logs/main.jsonl` の移行の実機確認。移動と索引への採録は実装済みで、対象は現時点で 2 行だが、実機では未実行である。 - トピックの再開の実機確認。`--session-id` で配った id が実際に `claude --resume` で戻るところは未計測である。CLI の選択肢の存在は 2026-08-27 に確認しているが(#115 の前提)、往復そのものは辿っていない。同じ id を二度 `--session-id` で渡した場合の CLI の挙動も未計測であり、この実装はその経路へ入らない——記録が在るときは、新規起動側でも新しい id を配り直す。 +- 戻れなかった再開先を外すところの実機確認(#127、上記「トピック」)。再開の線で起動して名簿に現れないまま終えた回で記録が外れること、状態行がそう述べること、次に押すと通常起動で立つことは実装済みで、CI の型検査・Rust のコンパイル・`crates/topic-index` のテストは通っているが、実機での操作は未確認である。この経路を実機で作るには、確認プロンプトで止まった起動を id が記録された状態で終える必要がある——#123 の実機確認がそれを起こした回であり、狙って再現する手順としては未確認である。 - `read_room_history` を実際のセッションが呼ぶところの実機確認。サイドカーのラウンドトリップテストは通っているが、エージェントが必要な場面で自分から引くかどうかは未計測である。押し付けないという判断がそのまま「引かれない」に落ちる可能性は残っており、そこは `instructions` の書き方の問題として観測してから判断する。 - トピックを切り替えたときに、席を持っている参加者がそれをどう受け取るかの実機確認。床は空になり席は入口へ戻るが(上記「トピック」)、走っているセッション自身は自分の文脈をそのまま持ったままである。 - 複数の部屋。ログのパスは部屋名を位置として持つが(`logs/{部屋名}/`、現在は `main` に固定)、部屋そのものは一つである。 @@ -984,7 +995,7 @@ CI が実行するもの: | `cargo check --target x86_64-pc-windows-gnu` | アプリのコンパイル | | `cargo test`(`crates/mcp-config`) | `.mcp.json` マージ保全、起動フラグ検査、起動しないサーバの名指し | | `cargo test`(`crates/room-floor`) | 床の判定(未読による拒否、自分の発言の除外、解決できない `last_seen`、席の位置、同時発話の順序付け、拒否が運ぶ宣言色) | -| `cargo test`(`crates/topic-index`) | トピックの保存(索引に無いファイルの採録、削除がファイルと項目の両方を取ること、項目だけを消した場合の復活、他のトピックを乱さないこと、ファイルの無い項目の削除、一ファイル名でない id の拒否、題の生成、千切れた行の読み飛ばしと計数) | +| `cargo test`(`crates/topic-index`) | トピックの保存(索引に無いファイルの採録、削除がファイルと項目の両方を取ること、項目だけを消した場合の復活、他のトピックを乱さないこと、ファイルの無い項目の削除、一ファイル名でない id の拒否、題の生成、千切れた行の読み飛ばしと計数、セッション id の取り消しが項目と他アカウントを残すことと id 一致でのみ効くこと) | ## 往復が成立しないときの切り分け @@ -1056,6 +1067,7 @@ CI が実行するもの: | 読み戻した行が宣言色を持たない(#115) | ログは `hue` を持たないため、トピックを開き直すと過去の発言は名前から導いた色で出る。同じ名前を名乗った二人は一色になり、この画面自身の過去の発言もアクセントでは出ない。誰も宣言していない色を保存しないことの代償である | | セッション id の置換子を起動オプションへ書く(#115) | 「新しいセッションを始める」側に専用の欄が無く、`{session_id}` を自分で書く必要がある。どのフラグが id を運ぶかは CLI ごとの問いであり、アプリがフラグを持てば `claude` 以外で外れる | | 索引をディレクトリと照合して建て直す(#115) | 遅延生成の安全性がこの照合に乗る。#115 の時点では `AppHandle` 依存のため `cargo test` の外に在ったが、#119 が `crates/topic-index/` へ出した(上記「テストの配置」) | +| 死んだセッション id を次の再開の失敗で外す(#127) | 記録の位置は spawn の直後のままであり、会話ができた瞬間をアプリは知らない。会話の無い id は一度は書かれ、外れるのはその id で再開して失敗した後である——一回は失敗する。判定を名簿の到着で行うため、再開そのものは成った上でサイドカーが部屋へ繋がらないまま終えたセッションも同じ扱いになる。そちらは戻り道を失うが、読み出し(上記「復帰の手段は二段構えである」)は残る | | 起動途中の席は削除から見えない(#119) | 席は PTY が立つ前に確保されるため(`Seat::Starting`)、その窓の中に在るセッションには止めるものがまだ無く、削除はそれを終えられない。トピックが消えた後にその起動が完了すれば、セッション id はどのトピックにも属さないまま残る。押す操作が二つ同時に要る幅であり、席のロックと索引のロックを起動をまたいで両方持つ形を避けるほうを採る | ## 位置づけ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 272ef72..e3ef420 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -54,6 +54,7 @@ pub fn run() { room_log::room_topics, room_log::room_topic_log, room_log::room_rename_topic, + room_log::room_forget_session, session::seated_accounts, session::parse_launch_options, session::preview_launch_args, diff --git a/src-tauri/src/room_log.rs b/src-tauri/src/room_log.rs index 9abbc9b..743560d 100644 --- a/src-tauri/src/room_log.rs +++ b/src-tauri/src/room_log.rs @@ -411,6 +411,46 @@ pub fn record_session( Ok(()) } +/// Take a session id off a topic, because there is no conversation behind it. +/// +/// The undo of [`record_session`], and it exists because that record is written +/// one step too early to be sure of itself. The id is written when the spawn +/// returns, which says a process started and does not say a conversation was +/// made — a CLI that stops at a confirm prompt and is closed there leaves an id +/// the next resume fails on, and every resume after it (#127). Nothing falls +/// back on its own, so the pair (account, topic) stays broken until the record +/// goes. +/// +/// The id is named, so this undoes the record it was told about and not a later +/// one; the match is the crate's (`TopicIndex::forget_session`). Answering +/// false is a normal outcome and not a failure — see there. +/// +/// The topic's entry stays, and so do its posts. What is dropped is the way +/// back into one session, which is the one thing here that was never true. +pub fn forget_session( + app: &AppHandle, + topic_id: &str, + account_id: &str, + session_id: &str, +) -> Result { + let forgotten = { + let _guard = INDEX_LOCK.lock(); + let mut index = read_index(app)?; + let forgotten = index.forget_session(topic_id, account_id, session_id); + // Written only when something changed. A read that adopted a file has + // already been written back by `read_index`, so there is nothing else + // here waiting on this write. + if forgotten { + write_index(app, &index)?; + } + forgotten + }; + if forgotten { + announce(app); + } + Ok(forgotten) +} + /// Delete one topic: its posts, and the entry that annotated them. /// /// The store half of the delete. What surrounds it — stopping the sessions that @@ -466,6 +506,29 @@ pub fn room_topic_log(app: AppHandle, topic_id: String) -> Result Result { + forget_session(&app, &topic_id, &account_id, &session_id) +} + /// Rename a topic. /// /// The generated title is a starting point in an editable field, which is the diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs index a97b678..86a7a0f 100644 --- a/src-tauri/src/session.rs +++ b/src-tauri/src/session.rs @@ -149,6 +149,16 @@ pub struct RunningSession { /// which is the one session a delete cannot see; see the accepted tradeoff /// in docs/0-requirements.md. pub topic_id: String, + /// The session id this launch went back into, or `None` when it started + /// fresh. + /// + /// Which of the two lines ran is a fact about the launch, like the three + /// above, and it is kept here for the reason the pty id is: the screen + /// loses it on a reload and the seat does not. What reads it is the exit — + /// a resume that ends without the room ever having seen it leaves a record + /// with no conversation behind it, and dropping that record is what puts + /// the next launch back on the normal line (#127). + pub resumed_from: Option, } /// One account's seat, as the screen reads it. @@ -344,8 +354,14 @@ struct LaunchLine { /// topic has something to resume. A resume passes an id it was given and /// mints nothing, so it is `None` — there is nothing new to record. session_id: Option, - /// Whether this is the resume line rather than the launch line. - resumed: bool, + /// The session id this launch is going back into, when it is a resume. + /// + /// `Some` is the resume line and `None` is the launch line, so this is + /// also the answer to which of the two ran. One field rather than a flag + /// beside an id: the two would have to agree, and the id is what the undo + /// needs — a resume that could not go back has to name the record it is + /// dropping (#127). + resumed_from: Option, } /// Which of the account's two lines this launch is, and with which id. @@ -394,7 +410,7 @@ fn resolve_launch( command, args: substitute_session_id(&parts, session_id), session_id: None, - resumed: true, + resumed_from: Some(session_id.to_string()), }); } @@ -404,7 +420,7 @@ fn resolve_launch( command: account.command.clone(), args: substitute_session_id(&account.args, &session_id), session_id: Some(session_id), - resumed: false, + resumed_from: None, }); } @@ -412,7 +428,7 @@ fn resolve_launch( command: account.command.clone(), args: account.args.clone(), session_id: None, - resumed: false, + resumed_from: None, }) } @@ -430,15 +446,28 @@ pub struct StartedSession { /// late. Same clock as a post's `ts`, so the panel's start time and the /// first line of the conversation can be read against each other. pub started_at: String, - /// True when this went in through the account's resume line rather than its - /// launch line. + /// The topic this launch went into, and the topic the record it may have to + /// drop is on. /// - /// Handed back so the screen can say which of the two happened. Under + /// The launch's own, not the room's as it reads later: the room moves + /// between topics while a session runs, and a session that ends badly has + /// to reach the topic it started in (#127). Same fact the seat holds, sent + /// here as well because the screen acts on the exit and the seat is gone by + /// then. + pub topic_id: String, + /// The session id this went back into, or `null` when it started fresh. + /// + /// Handed back so the screen can say which of the two lines ran. Under /// decision 6 a topic opens whether or not a seat could be resumed, and a /// seat that came back fresh is not a failure — but it is a different thing /// from one that came back carrying its own context, and the person is the /// one who can tell whether that matters. - pub resumed: bool, + /// + /// The id itself rides rather than a flag, because the screen has one more + /// thing to do with it: a resume that ends without the room ever seeing it + /// is a record with nothing behind it, and dropping that record names it + /// (#127). + pub resumed_from: Option, } /// Put one account into the room. @@ -589,6 +618,7 @@ pub fn start_session( &server_name, &room_url, &cwd, + &topic.topic_id, cols, rows, ) { @@ -625,6 +655,10 @@ pub fn start_session( // current one: the two are the same here, and reading the // room again would make them the same only by luck. topic_id: topic.topic_id.clone(), + // The line that ran, for the same reason the command is: + // this is the launch's own fact, and the seat is where it + // survives a reload of the screen (#127). + resumed_from: launch_line.resumed_from.clone(), }, ); Ok(started) @@ -664,6 +698,9 @@ fn launch( server_name: &str, room_url: &str, cwd: &Path, + // The topic this launch is going into, carried through so the answer names + // the topic a failed resume would have to be undone on (#127). + topic_id: &str, cols: u16, rows: u16, ) -> Result { @@ -700,6 +737,7 @@ fn launch( pty_id, mcp_config: mcp_config.to_string_lossy().to_string(), started_at, - resumed: line.resumed, + topic_id: topic_id.to_string(), + resumed_from: line.resumed_from.clone(), }) } diff --git a/src/main.ts b/src/main.ts index dc85221..e9f24ae 100644 --- a/src/main.ts +++ b/src/main.ts @@ -224,12 +224,18 @@ interface StartedSession { mcp_config: string; /** When the session was launched, stamped by the room's own clock. */ started_at: string; - /** True when this went in through the account's resume line rather than its - * launch line — the topic held a session for it, and the CLI came back - * carrying its own context (#115, decision 4B). False is not a failure: it - * is a seat starting fresh in a reopened topic, which is what a topic does - * for every seat it cannot resume (decision 6). */ - resumed: boolean; + /** The topic this launch went into. The launch's own fact: the room moves + * between topics while a session runs, so the topic a failed resume has to + * be undone on is this one and not the room's current (#127). */ + topic_id: string; + /** The session id this went back into, or null when it started fresh — the + * topic held a session for it, and the CLI came back carrying its own + * context (#115, decision 4B). Null is not a failure: it is a seat starting + * fresh in a reopened topic, which is what a topic does for every seat it + * cannot resume (decision 6). The id itself rather than a flag, because a + * resume that ends without the room ever seeing it has a record to drop, and + * dropping it names it (#127). */ + resumed_from: string | null; } /** @@ -254,6 +260,11 @@ interface RunningSession { * was started. Read when a topic is deleted, which ends the sessions that * were in that topic and no others (#119, decision 4). */ topic_id: string; + /** The session id this launch went back into, or null when it started fresh. + * A launch's own fact like the three above, and kept on the seat for the + * reason the pty id is: this screen loses it on a reload and the seat does + * not (#127). */ + resumed_from: string | null; } /** One account holding a seat, and what it is running. */ @@ -612,6 +623,32 @@ interface SessionView { fit: FitAddon; host: HTMLElement; unlisten: UnlistenFn[]; + /** The topic this session was launched into, so its record can be reached + * after it ends. Empty until the launch returns, like `ptyId`. */ + topicId: string; + /** + * The session id this launch went back into, or null when it started fresh. + * + * Half of what says a resume failed. The other half is `seenInRoom` below, + * and the exit is where the two are read together (#127). + */ + resumedFrom: string | null; + /** + * True once the room's roster has carried this account. + * + * The screen's own definition of a session having arrived, and the one the + * rows already draw 起動中 from: a process is up and the room has not seen it + * yet (`memberRow`). Raised and never lowered — a session that was in the + * room and then dropped its connection did arrive, and what this answers is + * whether it ever did. + * + * Read at the exit, because a resume that ends without this having been + * raised is a resume that went back into nothing: the CLI it was handed to + * stopped before it started the servers that join the room. That is + * observable without reading a word the CLI printed, which is what the id + * being dropped on an error message would have cost (#127). + */ + seenInRoom: boolean; /** How the session ended, or null while it is still running. */ ended: string | null; /** @@ -2117,12 +2154,25 @@ function renderPanel(): void { renderAddressees(); } -/** Take the room's roster and redraw the panel around it. */ +/** + * Take the room's roster and redraw the panel around it. + * + * The one door the roster comes in by, the event and the first read alike. A + * second assignment to `participants` elsewhere would be a roster that arrived + * without the two readings below happening to it. + */ function renderRoster(joined: Participant[]): void { participants = joined; // Against the roster that just arrived, before it is drawn: who the room is // waiting on is only meaningful about someone who is in it (#82). pruneAwaiting(); + // The other reading of the same roster: which sessions have arrived at all. + // Kept on the view rather than asked at the exit, because by then the + // connection is gone and the roster no longer remembers it was there (#127). + for (const view of views.values()) { + if (view.seenInRoom) continue; + if (joined.some((one) => one.account === view.accountId)) view.seenInRoom = true; + } renderPanel(); } @@ -2638,10 +2688,18 @@ function openView(account: Account, running?: RunningSession): SessionView { command: running?.command ?? account.command, cwd: running?.cwd ?? account.cwd, startedAt: running?.started_at ?? "", + topicId: running?.topic_id ?? "", + resumedFrom: running?.resumed_from ?? null, term, fit, host, unlisten: [], + // False even for a session picked up again after a reload. The roster is + // read on its own event and this account is on it if the session is in the + // room, so the answer arrives rather than being assumed here — and assuming + // it from the roster as it stands would read a connection the previous run + // of this account has not finished dropping as this one having arrived. + seenInRoom: false, ended: null, // Quiet until something arrives. A session picked up again after a reload // starts here too: its terminal is new even though its process is not, so @@ -2765,6 +2823,11 @@ async function attachSession(view: SessionView, ptyId: string): Promise { view.unlisten = []; const name = viewName(view); status(`${name} が終了しました(${detail})。端末を確認してください。`, "error"); + // A resume that ended on its own without the room ever having seen it + // went back into a session that is not there. The record it went in on is + // what every later launch into this topic will fail on the same way, so + // it goes (#127). + void dropDeadResume(view, code, detail); // The seat this account held is free the moment its session ends, so the // panel says 未起動 again and the account can be started once more. void refreshSeats(); @@ -2777,6 +2840,70 @@ async function attachSession(view: SessionView, ptyId: string): Promise { ); } +/** + * Take this topic's record of a session a resume could not go back into. + * + * The failure it answers has no other way out. A session id is recorded when the + * spawn returns, which is a process having started and not a conversation having + * been made — a CLI stopped at a confirm prompt and closed there leaves an id + * behind it. Every launch of that account into that topic afterwards takes the + * resume line, fails on the id, and ends; the pair is broken until the record + * goes, and nothing was taking it (#127). + * + * The condition is three things this app already observes: the launch went in on + * the resume line, the room never carried the account, and the session ended on + * its own. The CLI's own words are not read for any of them. Matching the + * message it prints would be a check that stops working the day the wording + * changes, and stops silently (#127, 制約). + * + * The third is what an exit code being carried at all says, not what its value + * is: a session the app killed is taken out of the map before it is reaped, and + * the exit then arrives with no code (`pty.rs`). That is the row's ❌, the topic + * delete, and the app closing — an end somebody asked for, and none of them says + * the resume failed. The window it covers is real: the confirm prompt holds a + * launched CLI outside the room for minutes (#89), and ending the wrong account + * during it is an ordinary act that must not cost a resume that would have + * worked. A code missing for any other reason falls the same way, which is the + * safe side — the poisoned launch ends by itself and is caught on the next press. + * + * It does not start anything again. The record is off, so the next press is a + * normal launch — and whether to press is the person's. Retrying here would be + * this screen running a failure round and round, which is the shape + * `model-loop-safety` is about (#127, AI 判断2). + * + * Said in the status line, because a repair nobody is told about is a history + * that quietly stopped being continuous. Only after the app answers that a + * record was actually dropped: the account is seatless the moment it exits, so + * a launch made in between owns the record now, and this says what happened + * rather than what it asked for. + */ +async function dropDeadResume( + view: SessionView, + code: number | null, + detail: string, +): Promise { + const dead = view.resumedFrom; + if (dead === null || view.seenInRoom || view.topicId === "" || code === null) return; + const name = viewName(view); + try { + const dropped = await invoke("room_forget_session", { + topicId: view.topicId, + accountId: view.accountId, + sessionId: dead, + }); + if (!dropped) return; + status( + `${name} は会話へ戻れないまま終了しました(${detail})。このトピックの再開先を外したので、次は通常の起動になります。`, + "error", + ); + } catch (err) { + // The record is still there, which means the next launch fails the same + // way. Saying so is the whole of what is left to do here — a repair that + // failed quietly reads as a repair that happened. + status(`${name} の再開先を外せませんでした: ${err}`, "error"); + } +} + /** * Follow a launched session until it dies. * @@ -2787,6 +2914,11 @@ async function attachSession(view: SessionView, ptyId: string): Promise { async function followSession(view: SessionView, started: StartedSession): Promise { view.ptyId = started.pty_id; view.startedAt = started.started_at; + // Which line ran and where it ran, both from the launch's own answer. They + // are what the exit reads, and the exit can arrive as soon as the listener + // below is attached, so they are set before it (#127). + view.topicId = started.topic_id; + view.resumedFrom = started.resumed_from; renderPanel(); renderSessionFacts(); @@ -2874,7 +3006,7 @@ async function startSession(account: Account): Promise { // can tell whether it mattered. A seat that came back fresh in a reopened // topic is a legitimate outcome and not a silent one (#115, decision 6). status( - started.resumed + started.resumed_from !== null ? `${name} を再開しました。${started.mcp_config} に登録済み。` : `${name} を起動しました。${started.mcp_config} に登録済み。`, ); @@ -3481,7 +3613,11 @@ async function main(): Promise { try { await join(); - participants = await invoke("room_participants"); + // Through the same door the event uses. Read directly into `participants`, + // this first roster would be the one arrival the readings in `renderRoster` + // never see — and a session adopted just above (`refreshSeats`) is exactly + // what is on it (#127). + renderRoster(await invoke("room_participants")); const port = await invoke("room_port"); if (port !== null) renderSocket(port); renderPanel();