Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions crates/topic-index/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
16 changes: 14 additions & 2 deletions docs/0-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 で退けた「部屋が誰の既読を持つ」形に触らずに済む。トピックによる復帰はこの制約を緩めるのではなく、不要にする。
Expand Down Expand Up @@ -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` フレーム、発言者以外の全参加者への配送、接続同一性による自分の発言の抑止と名簿の同一性、人間を含む名簿)
Expand Down Expand Up @@ -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` に固定)、部屋そのものは一つである。
Expand Down Expand Up @@ -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 一致でのみ効くこと) |

## 往復が成立しないときの切り分け

Expand Down Expand Up @@ -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 はどのトピックにも属さないまま残る。押す操作が二つ同時に要る幅であり、席のロックと索引のロックを起動をまたいで両方持つ形を避けるほうを採る |

## 位置づけ
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading