Skip to content

Commit eab8dce

Browse files
tacyanclaude
andauthored
fix: スクロールの重複 (PC/スマホ)・待ちの嘘・音声の案内・添付の口 + 出荷前の関門 (#35)
* fix(i18n): 行継続を使った 71 箇所の文字列が「永久に日本語のまま」だったのを直す ## 静かな壊れ方 Rust は `\` + 改行 で文字列をソース上で折り返せる (規則: 改行と次の行の 先頭空白を全部捨てる)。長い案内文で実際に 71 箇所使われている書き方だが、 翻訳の土台がこれを 2 か所で取り違えていた: * `scan_source_literals` の正規表現 `(?:[^"\\]|\\.)*` — `.` は既定で改行に 当たらないので `\` + 改行 を食えず、`[^"\\]` も `\` を食えないため **閉じ `"` に到達できずマッチ自体が不成立**。つまりその `tr()` は走査結果に 1 度も現れない * `unescape_rust` が `\` + 改行 を「知らないエスケープ」として通し、改行と 字下げを本文へ残していた 結果、**辞書のキーが実行時の文字列と食い違ったまま**になり、その行はどの 言語でも永久に日本語で出る。しかも画面には出るのに `zai i18n missing` も 番人テストも何も言わない — いちばん見つけにくい形だった。 実測: 走査は 3436 → 3507 件 (+71)。うち **40 件は辞書に正しい原文が 1 つも無く**、残り 31 件は別 ID に正しい形が重複していて偶然動いていた。 ## 直したもの * 正規表現のエスケープ受け皿を `\\(?s:.)` にして行継続を食えるようにした * `unescape_rust` に行継続の腕を足した。**飛ばすのは ASCII の空白だけ** (rustc の `skip_ascii_whitespace` と同じ) — `char::is_whitespace()` にすると 全角スペースまで食うが、rustc はそれを本文として残す * `locales/*.json` 6 言語 × 71 キーを復元。ja は原文と 1 バイト一致 * Rust 側にも字下げが本文へ焼き付いていた 3 箇所を直した (`panels.rs` は画面に「対象外です — (空白 30 個) そちらは」と出ていた) ## 訳の繋ぎ直しは言語ごとに判断した 最初に「改行 + 3 個以上の空白を削除」で 6 言語を一括処理したが、**反証で 崩れた**: 日本語は改行位置に空白が要らないが、英語・スペイン語・ ポルトガル語・韓国語は要る。`runs regardless` が `runsregardless` になる形で **120 箇所**壊していた。撤回して、言語ごとに接合部を 1 件ずつ判断し直した (挿入した半角スペース: en 35 / es 35 / pt-BR 35 / ko 36 / zh-CN 7)。 `guard.rs` の `\x20 (1)` による意図的な 2 空白字下げ、CLI の使い方の桁揃えは **ja が持っているものは訳も持つ**という規則で守っている。 ## 番人 (変異テストで空振りしないことを確認済み) * `locale::tests::行継続は改行と次行の先頭空白を捨てる` — Rust Reference の 規則を表で固定 (全角スペースを食わないことも) * `locale::tests::走査は行継続を含む呼び出しも拾う` — 取りこぼしの検出 * `locale::tests::辞書に行継続の焼き付きが残っていない` — 6 言語すべてを検査。 ja が持つ本物の字下げ (CLI の使い方) だけ免除 ## 残っている穴 (正直に) * `src/mesh.rs:2304` の `tr(concat!(…))` は静的に辿れないので走査に出ない。 実測 `LC_ALL=en_US.UTF-8 zai mesh` は **18 行中 15 行が日本語のまま**なのに `zai i18n missing` は 0 件と言う * `src/cli.rs` の `pub const HELP_*` は `tr()` を 1 度も通らない (`zai lease --help` 16/16 行が英語ロケールでも日本語) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(terminal): ホイールを回すと上へ行かず下へ戻る — 同じ行がまた出るのを直す 利用者の報告: **「スクロールを何回かやったら、同じ文字が発生する」** (PC)。 ## 原因 — ホイール 1 ノッチでパーサのロックを 3 回に分けて取っていた `Session::adjust_scroll` はこう動いていた: 1. `adopt_scrolled_output()` … ロックを取って今の位置を読み、離す 2. `n = self.scroll + delta` … 目標を組み立てる 3. `set_scroll(n)` … 中でもう一度取り込み → `set_scrollback(n)` 読取スレッドは 1 回の `read` (**8KB ≒ 130 行**) を**ロックを握ったまま**流す。 1 と 3 の隙間に M 行押し出されると目標 `n` が M 行ぶん古くなり、1 ノッチで 動くのは `delta - M` 行。しかも `set_scroll` は入口で取り込んで `self.scroll` を 最新へ直しておきながら、**直した値を捨てて古い `n` を撃つ**。 `M > delta` なら**上へ回したのに下へ戻る** = さっき見た行がまた出てくる。 ## 実測 (20 ノッチ × delta=2、期待 +40 行) | 隙間の押し出し | 0 | 1 行 | 3 行 | 8 行 | 130 行 | |---|---|---|---|---|---| | 直す前 | +40 | +20 | **-20** | **-120** | **-2560** | | 直した後 | +40 | +39 | +37 | +32 | -90 | 直した後に欠けるのは**最初の 1 ノッチだけ** (下端に貼り付いた状態から出力を 追う正しい動き)。2 ノッチ目以降は毎回ちょうど 2 行動く。 ## 直し方 基準の読み直しと `set_scrollback` を**1 回のロックの中**へ入れ、 `self.scroll` から目標を組み立てるのをやめた。ドラッグの自動スクロールも 同じ経路なので一緒に直る。 ## 番人 (どちらも旧コードへ戻して赤になることを確認済み) * `terminal::selection_scroll_tests::ホイール一ノッチは映っている位置からちょうど動く` — 挙動。直す前は **22 行テレポート** (期待 2) で赤 * `terminal::selection_scroll_tests::相対スクロールの基準は適用と同じロックの中で読む` — 構造。並行実行に頼らないので CI でも安定して赤くなる ## 白だったもの (調べて否定した 7 件) `vendor/vt100` の `visible_rows` / `count_around` の押し出し計数 / `scrolled_delta` / `all_terminal_lines` / `selection_text_abs` / 代替画面の出入り / スクロール領域 (DECSTBM)。乱数無しの全 5 実験で重複 0。 **`vendor/vt100` は 1 バイトも触っていない。** Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(remote): スマホの 4 件 — 遡りの重複 / 待ちの嘘 / 消せない案内 / 添付の口 ## 1. 遡っているうちに同じ行が 2 か所に出る 利用者の報告「スクロールを何回かやったら、同じ文字が発生する」のスマホ側。 実物の `45-term.js` を Node で回して**決定的に再現**した (応答順を台本で握る): 1. 遡り 9 回で 2820 行 (`bufFrom=4080`) 2. 上端で遡りを飛ばし、**応答を待たせたまま**下端へ戻る 3. 追従が 2 回走って 240 行追記 → `MAX_ROWS` で先頭 60 行を間引き → **`bufFrom` が 4080→4140 へ動く** 4. 遡りの応答が到着し、**今の** `bufFrom` で接合位置を計算 → 60 行の穴 5. 次の追従がずれた添字へ書き込み → **同じ 60 行が 2 か所に出る** 原因は「同じ `buf` を触る」ことではなく **`bufFrom` (絶対行の対応) が await を またいで動く**こと。だから遡り同士を守る `loading` では防げない。 直し方: `epoch` (対応を作り直した回数) を控え、戻ったとき変わっていたら **その 1 回を捨てる**。末尾追記では `epoch` を進めない — 進めると出力が 流れている端末で二度と遡れなくなる。 | 筋書き | 前 | 後 | |---|---|---| | 遡り中に間引き | 重複 60 行 | 0 | | 遡り中にエージェント切替 | 余所の端末の行 300 | 0 | | 遡り中に空状態 | 嘘の「これより前は無い」 1 | 0 | | 追記だけ (反証用) | 継ぎ足せた 360 / 重複 0 | **変わらず** | ## 2. 「待っているエージェントはいません」が残り続ける 前に直した「端末へ戻っても空カードが消えない」(CSS の詳細度) とは**別物**。 あれは*描く場所*、これは*データの出どころ*。 バッジは `/api/state` (2.5 秒)、一覧は `/api/agents` (1.5 秒) と**別の応答**から 来ていた。判定は `remote::is_waiting_lane` 1 本で同じでも**取得の時点が違う**。 しかも `/api/agents` が落ちたときだけ描き直さないので、 **「⏳ 待ち ②」と出ているのに「待っているエージェントはいません」**が残った。 真実の在り処を 1 つに: 一覧が画面にある間は**バッジも一覧も同じ応答**から 作る (食い違いが構造的に起こり得ない)。「読めていない」と「0 件」を三値で 分け、読めないときは ⚠ カード + バッジは `…`。取得に締め切りを付けて 無応答でもポーリングが死なないようにした。 HEAD では 10 個の不変条件のうち **6 個が赤**、直した後は **10 個すべて緑**。 ## 3. 音声が使えない案内が消せない **プレーン http では、どの方法でもブラウザの音声認識はできない** — `SpeechRecognition` も `MediaDevices` も IDL が `[SecureContext]` で、 iOS Safari では `webkitSpeechRecognition` が存在すらしない。 `MediaRecorder` で録って PC へ送る逃げ道も `navigator.mediaDevices` が `undefined` なので塞がっている。LAN の private IP も `*.ts.net` も potentially trustworthy ではない。 一方 **https にすれば今のコードのまま動く** (判定は端末ではなく `isSecureContext`)。案内に「原因」だけでなく**この直し方**を書き足した。 そのうえで案内を**閉じられる**ようにし、閉じたことを覚える。 消したきり理由に辿り着けなくならないよう、**🎤 の長押し (600ms) で戻せる**。 ## 4. 入力欄の左に 📎 — 画像などをどのエージェントへも送れる 送るの実体は **(a) 作業フォルダの下へ保存し (b) そのパスを入力欄へ入れる**。 宛先は既存の仕組み (`bulkMode` / `/api/bulk`) にそのまま乗せ、新しい宛先の 概念は 1 つも作っていない。置き場は `agent_cwd()` から導出 (直書き無し)。 名前は 1 要素へ畳み、`..`・ドライブ・制御文字を落とし、字句と `canonicalize` の両方で**ワークスペースの外へ出られない**ことを確かめる。 上限超過は黙って切らずに断る。置き場には `*` だけの `.gitignore` を落とし、 利用者の写真が `git add -A` で紛れ込まないようにした。 ## 番人 (すべて変異試験で赤化を確認) `remote::tests::` に 9 本 — 遡りの前提破棄 / 待ち件数と一覧が同じ応答 / 一覧が読めないとき 0 件と言わない / 音声の案内を閉じて覚える / 長押しで戻せる / 添付ボタンの位置と宛先 / ワークスペース外へ書けない / 同名の番号避け / base64 の膨らみを見込んだ上限。 `file_tree::tests::破壊的なファイル操作は確認を経ずに呼ばれない` が 添付の後始末 (`remove_file`) を捕まえたので、**理由を書いて通した**うえで 「消してよいのは `create_new` で自分が作ったものだけ」を構造で押さえた。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(tools): 出荷前の関門 1 本 — 誰も動かしていない層を残さない ## なぜ要るのか 0.18 系で 4 件が出荷まで生き残った: * スマホで端末と一覧が同時に見える (CSS の詳細度) * スマホでエージェントのチップを押しても切り替わらない * PC で履歴が二重になる / ホイールが逆へ動く * `tr("…\` の行継続 71 箇所がどの言語でも永久に日本語のまま **どれも実際に動かせば 5 秒で分かる**のに、Rust のテストはソースの文字列を 見るだけで**ブラウザの DOM も CSS も 1 度も評価していなかった**。 道具 (`verify.sh` / `gui-smoke.sh` / `linux-test.sh` / `windows-check.sh` / `zai i18n missing`) は揃っていて、**走らせるかどうかが人の記憶に委ねられて いた**だけである。 ## 作ったもの * `tools/remote-check.js` — `assets/remote/` を **build.rs と同じ順序で組み、 本物の Chromium に CDP で描かせて**検査する。偽 PC 側 (HTTP + `/api/*`) も 内蔵。**npm 依存ゼロ** (WebSocket の握手とフレームも自前) なので Node 18 以上ならどこでも動く * `tools/remote-check.sh` — 入口。node / ブラウザが無ければ理由つき `[skip]` * `tools/release-gate.sh` — **出荷前はこれ 1 本**。verify → i18n → スマホ画面 → gui-smoke → linux → windows。最後に `緑 / 赤 / 未確認` の表と判定行 * `docs/preflight.md` — 4 件がどうすり抜けたかの表、層の地図、限界 * `.github/workflows/test.yml` に `remote-screen` ジョブを**末尾に追記** (Rust を 1 行もコンパイルしない) ## 見るのは「既知の形」ではなく不変条件 見えている `.view` は 1 つ / 主枠は同時に 1 つ / 兄弟の矩形が重ならない / 横に見切れない / 押せない大きさのボタンが無い / JS 例外ゼロ / **ポーリングが実際に飛んでいる** (判定は偽 PC 側の受信数) / **チップを押すと端末の中身が変わる**。 これを下部ナビ × セグメントの順序つき総当たり (2 手・3 手) + 往復 + **種を固定した乱歩**で回し、1 実行あたり **385 状態**を確認する。 中身は 4 通り (320px / エージェント 0 体 / 英語 / 旧 PC)。 ## 空振りする検査を残さない `--self-test` が **7 つの欠陥を仕込んで全部捕まることを毎回証明する** (今日出た `#alist.mid` の詳細度もその 1 つ)。新しい不変条件を足すときは、 **それを破る仕込みも同時に足す**。 ブラウザが無い環境の最後の砦として、**CSS の法則**を Rust の番人に置いた (`cli::tests::スマホのcssは隠したものを脇道から見せない`) — 「`display:none` で隠したものを見せる規則は、クラス集合の包含で 1 本の鎖に なっていること」。枝分かれ (`#alist.show` と `#alist.mid`) が抜け道になる。 **`[skip]` は緑ではない。** CI では不合格にする。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(remote): Tailscale を HTTPS にして、スマホでも音声認識が使えるようにする ## なぜ スマホのブラウザの音声認識はセキュアコンテキストでしか動かない。 `SpeechRecognition` の IDL に `[SecureContext]` があるので、iOS Safari では 平文の LAN / tailnet で**構造ごと存在しない**。https のオリジンにすれば **スマホ側のコードを 1 バイトも変えずに**そのまま動く。 Tailscale は `<機器名>.<tailnet>.ts.net` に Let's Encrypt の正規証明書を 持っているので、`tailscale serve` に TLS を終端させれば実現できる。 ## 実機の数字 (macOS / 実 tailscale を製品の関数で駆動) | | | |---|---| | `status --json` → ドメイン | 190ms | | `serve --bg --https=443` | 81ms | | `cert` で温める | 73ms | | `curl` 1 回目 | **http 200 / TLS 検証 0 / 42ms** | | `curl` 2 回目 | 18ms | | `serve --https=443 off` の後 | `No serve config` | 温めを入れたのは、**最初の TLS ハンドシェイクで証明書を取りに行く**ため (温めないと 1 回目だけ失敗する。実測で確認した)。 ## 設計 * `Bind::TailscaleHttps` を**変異体として**足した。待ち受け先・URL の作り方・ 受信許可の要否・後片付けの要否が `Tailscale` と全部違うので、bool を足すと 2 つのモードが同時に立つ事故が構造的に起こる * 待ち受けは **loopback 1 本だけ**。TLS は tailscaled が終端して `127.0.0.1:<port>` へ流すので、tailnet 側に平文の口を残す理由が無い (残すと「https でも http でも入れる」= 音声が使えない平文の口が併存する) * `https_host` は **`serve` が rc=0 で返ってからしか入らない**。これが 「https の QR を配ってよい」唯一の合図で、立つまで QR も URL も出さない * `is_plausible_host` を通す — `evil.example/path` のような値で QR の宛先ごと 差し替えられるのを塞ぐ ## `tailscale` CLI を叩く条件 `src/tailscale.rs` の冒頭が「CLI を実行しない」と書いているのは**状態を知る ための常時の経路**の話で、そこは経路表を見る `probe()` のまま変えていない。 CLI を叩くのは**利用者が押したときだけ**で、しかも * 先に `probe()` で tailnet が Up であることを確かめる (落ちているときに 叩くと macOS のラッパが 120 秒返らない — この判断の元になった実測) * 裏のスレッドで時限つき。超えたら `procx::kill_tree` で**ツリーごと**畳む (直接の子だけ kill すると `exec` しないシェルラッパの孫が残る) * macOS では PATH のラッパではなく `.app` 内の実体を優先 ## できない理由は 4 通りに分ける `NotUp` (未導入 / 未接続) / `NoCli` / **`CertsOff`** (tailnet で HTTPS が 有効になっていない → 管理コンソールの URL をコピーできる) / `Failed` (出力をそのまま見せる)。「できませんでした」で終わらせない。 判定 (`CertDomains` が空か) は純関数 `parse_cert_domain` に切り出して表で固定。 ## 後片付け `rebind_remote` が `TailscaleHttps` からの離脱を見て 1 か所で発火する (やめる / Wi-Fi へ戻す / SSH へ切替、全部ここを通る) + `on_exit` (この起動で立てたときだけ)。**解除に失敗したときは待ち受けを戻した後も 「🔓 HTTPS をやめる」を出し続ける**ので、手を画面から失わない。 ## あわせて: 音声を先回りで隠すのをやめた 実測 (Chrome / `http://<LAN の IP>:<port>/`): `{"isSecureContext":false,"hasSpeechRecognition":true,"hasMediaDevices":false}` **Chrome は平文でも `webkitSpeechRecognition` を持っている。** `isSecureContext` で門前払いすると、実際には動く端末の利用者からも機能を取り上げることになる。 判定を「API が在るか」だけにし、使えるかは `start()` の結果で決める。 平文で断られたときに「ブラウザ設定を確認」と出すのも嘘なので (原因は設定ではなくオリジンが http であること)、直し方の案内へ落とす。 ## 番人 (変異試験 11/11 赤化を確認) 新規 10 本 (tailscale 5 / remote 5)。1 個目の設計は変異を素通ししたので 直した — 理由の見出しと次の一手を「組」で比べていたため**見出しだけ同じに 畳んでも緑**だった。利用者が最初に読むのは見出し 1 行なので、別々に一意性を 見るようにして赤になることを確認した。 `cargo test --bin zai` 4938 通過 (赤 1 件は実働の zai が 8899 を握っている 環境要因) / `zai i18n missing` 0 件 (6 言語 20 文字列) / clippy 緑 / `windows-check` 緑 / `linux-test` 緑 / `remote-check --self-test` 緑。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(test): Windows の CRLF で外れる照合を直し、同じ罠を構造で塞ぐ ## 直した赤 `remote::tests::音声の案内は閉じられて閉じたことを覚える` が **Windows の CI でだけ**落ちていた: ``` assertion failed: PAGE.contains("#vnote {\n display:none;") ``` Windows のチェックアウトは CRLF なので、`\n` を含むパターンは素の `include_str!` / `PAGE` では**必ず外れる**。CLAUDE.md に前から書いてある罠を また踏んだ (手元の macOS は緑のまま、CI の 1 面だけが赤くなるので遅れる)。 ## 同じ罠を構造で塞ぐ `cli::tests::改行をまたぐ照合は正規化してから使う` — 「埋め込みソースを読む関数の中で `.contains("…\n…")` を書くなら、 その関数の中で `replace("\r\n", "\n")` を通していること」。 ## この番人自身が 2 回空回りした (記録) - **1 版目**: 正規化を「直前 60 行」で探した → **同じファイルの別のテスト**が 書いた `replace` を拾ってしまい、**わざと壊しても緑** - **2 版目**: 自分のドキュメントコメントの中のパターンと、判定に使う語 (`"SRC"` 等) を自分で拾って誤検出 - **直った形**: **囲っている関数の中だけ**を見る + コメント行は除く + この検査自身は除く。メモリ上で組み立てた文字列 (diff の生成結果など) は CRLF と無関係なので数えない (騒がしいと誰も読まなくなる) わざと `replace` を外すと **`remote.rs:3267` を名指しで赤**になり、戻すと緑。 空振り検知 (`seen >= 3`) も入れてある。 3 版とも「テストは通っている」状態だったが、**守っていたのは 1 版だけ** だった。番人を足したら必ずわざと壊して確かめること — CLAUDE.md へ追記した。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tools): スマホ画面の検査が CI で無音のまま 15 分固まるのを直す 新設した関門 `mobile screen (chrome)` が GitHub Actions の ubuntu で **間欠的にハング**した。03:40 に始まり、**標準出力に 1 バイトも出ないまま** 15 分でジョブが打ち切られる。原因が 1 行も残らないので、次に見た人は 何が起きたのか分からない。 **ランダムに止まる関門は、そのうち誰も見なくなる。** ここを直さずに入れると、 今後の PR を止め続けたうえで信用まで失う。 ## 再現は運任せにしない Docker の Linux (bookworm / Chromium 151 / `/dev/shm` 64MB) で素の検査は 3/3 緑だったので、**機構のほうを再現した** — 検査の 30 秒後に `pkill -STOP -f chrom` でブラウザを凍らせる: | | 凍らせた後 | |---|---| | 直す前 | **180 秒経っても終わらない・出力は 2 行のまま** (CI と同じ形) | | 直した後 | **121 秒で rc=3**、`✗ 時限切れ — 120 秒のあいだ 1 つも進みませんでした / 最後の段: history-css: 総当たり + 乱歩` | ## 原因 (すべて実測で確認。憶測で直していない) 1. CDP の 1 往復に時限が無く、pending が永久に決着しない 2. WebSocket の握手に時限が無い 3. **偽サーバの `srv.close()` が返らない** — 処理中の要求が 1 本あると Node は close のコールバックを呼ばない (12 行の最小再現で確認)。各検査の後始末が 毎回この経路を通っていた 4. 全体の時限が無いので、上のどれに落ちても誰も打ち切らない 5. `proc.kill()` が直接の子だけ → レンダラ / crashpad の孫が残る (CI ログの `Terminate orphan process` と一致) 6. コンテナ / headless 用のフラグ欠落。特に**背景タブの時計を止めない**設定が 無く、止まるとポーリングの検査が誤判定して乱歩が 1 状態も進まない 7. ワークフローが `out=$(…)` で出力を溜めていた → 15 分間 0 バイト 否定できたもの: 固定ポートは元から無し (`listen(0)`)、ブラウザ探索は 6 種 + `ZV_BROWSER` を網羅済み、64MB の `/dev/shm` 単独では再現せず (フラグは残した)。 ## 直し方 — 進捗が観測できる限り待ちを延ばす 時限を 3 段 (1 往復 → 進捗停止の猶予 + 全体 → sh の `ZV_WALL_S`) にし、 **必ず CI ジョブの 15 分より内側で撃つ**。固定の待ちにすると遅いランナーを 誤って殺すので、`step()` で数え直して進んでいる限り延ばす。 20 秒ごとに生存の合図を出すのでログが無音にならない。最後の 1 行は `fs.writeSync(1, …)` で同期出力 (パイプで消えない)。ブラウザは `detached` + 負 PID で**ツリーごと**畳み、SIGINT/TERM/HUP でも同じ道を通る。 ## 空回りする時限を残さない `--hang deadline|cdp|server` を足し、**`--self-test` が毎回**子プロセスで撃つ。 実測 (macOS): deadline **6.2s で rc=3** / cdp **5.1s で rc=1** / server **2.2s で正常終了** (普段の道 2ms・時限だけの道 1502ms で 「接続 1 本を切って進みました」)。`sh` 側の砦も `ZV_WALL_S=15` で 16s に rc=3・孤児 0 を確認。 ## 捏造したフラグを 1 つ落とした `--disable-crash-reporter` は Linux chromium の実体を `grep -a` しても **0 件** (`disable-breakpad` は 1 件 / `no-sandbox` は 3 件)。実在しない綴りだったので 削除した。CLAUDE.md の「フラグや env を捏造しない。実物から確かめる」の実践。 ## 数字 * **Docker Linux・CI と同じ env で 10 回連続: 緑 10 / 赤 0 / ハング 0** (各 172–174 秒。時限の検査 3/3 が毎回通り、chromium/node/crashpad の 残りプロセス 0・一時プロファイル 0) * macOS (実 Chrome 151): 緑。仕込み 7/7 + 時限 3/3 * `shellcheck --severity=error tools/*.sh` 緑 / `cargo test --bin zai cli::` 73 passed / clippy 緑 / actionlint 緑 番人は 2 本 — 時限が在ること、`ZV_DEADLINE_MS < ZV_WALL_S < timeout-minutes` の順序を**数として**照合する (順序が崩れると「無音のまま打ち切り」に戻る)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: この版で踏んだ失敗を CLAUDE.md へ記録する 次に読む人が同じ穴に落ちないように、**この版で実際に踏んだもの**だけを残す。 ## 多言語 - **走査に現れない文字列は、番人も `zai i18n missing` も何も言わないまま 永久に日本語で出る** (71 箇所が実際にこの状態だった)。`tr()` には素の 文字列リテラル 1 つを渡す / 行継続を使わない / `zai i18n missing` が見るのは Rust の `tr()` だけで、スマホ側 JS の `T()` は別の番人が要る - **機械的な置換を言語をまたいで当てない。** 6 言語を 1 本の正規表現で 掃除したら **120 箇所で単語がくっついた** (`runs regardless` → `runsregardless`)。接合部の判断は言語ごとにしかできない ## テストと検証 - **関門そのものが固まると、関門ごと信用を失う。** 時限は 3 段で CI の上限より 内側 / 固定の待ちにせず進捗が見える限り延ばす / `--hang` で自分を固まらせて 時限が効くことを毎回証明する / ワークフローで出力を溜めない。 真犯人だった「**Node の `srv.close()` は処理中の要求が 1 本あると コールバックを呼ばない**」も残した - **CI の赤を「間欠」と言ってよいのは、同じ内容で緑を取れたときだけ。** 再実行せずに「たぶん間欠」と書くのは、直せる赤を捨てるのと同じ - **サブエージェントに `git checkout --` を撃たせない。** 共有ツリーでは 他のエージェントの未コミット作業を消す (実際に 173 行が消え、セッション記録 から復元した) - **squash マージした PR の上に積んだ PR は必ず衝突する。** `git rebase --onto origin/main <積んだ元の先端>` で落としてから push し直す Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3dfbc98 commit eab8dce

31 files changed

Lines changed: 6184 additions & 507 deletions

‎.github/workflows/test.yml‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,3 +568,71 @@ jobs:
568568
echo ""
569569
echo "clippy の -A を 1 行消すごとに債務が 1 種類返済される (.github/workflows/test.yml)。"
570570
} >> "$GITHUB_STEP_SUMMARY"
571+
# スマホ画面 (assets/remote/) を **本物のブラウザで描いて**検査する。
572+
#
573+
# ■ なぜ要るのか
574+
# 0.18 系で「端末と一覧が同時に見える」が出荷まで生き残った。原因は
575+
# CSS の詳細度 (`#alist.mid` が `#alist{display:none}` に勝っていた) で、
576+
# Rust のテストは 1 本も落ちていない — **誰も CSS を評価していなかった**。
577+
# 経緯と層の地図は docs/preflight.md。
578+
#
579+
# ■ 安い
580+
# Rust を 1 行もコンパイルしない (assets/remote/ は読むだけ)。
581+
# ubuntu ランナーには Chrome も Node も最初から入っており、
582+
# npm パッケージは 1 つも要らない (WebSocket も自前)。
583+
#
584+
# ■ `[skip]` を緑にしない
585+
# remote-check.sh は node / ブラウザが無いと **rc=0 のまま降りる**。
586+
# 手元ではそれが正しいが、CI で通すと**何も描かないジョブが永久に緑**に
587+
# なる。ここは揃っている前提なので、降りたら赤にする。
588+
remote-screen:
589+
# fast / pty / msrv / installers / lint / gui-smoke と**同じ判定**にすること。
590+
if: >-
591+
github.event_name != 'pull_request' ||
592+
github.event.pull_request.head.repo.full_name != github.repository ||
593+
!(github.event.pull_request.head.ref == 'main' ||
594+
startsWith(github.event.pull_request.head.ref, 'night/'))
595+
name: mobile screen (chrome)
596+
runs-on: ubuntu-latest
597+
# **最後の砦**。ここに来る前に、道具が自分で理由を書いて赤で終わること
598+
# (ZV_DEADLINE_MS 10 分 < ZV_WALL_S 11 分 < ここ 15 分)。この順序を
599+
# 崩すと「無音のまま打ち切り」に戻り、原因が 1 行も残らない。
600+
timeout-minutes: 15
601+
steps:
602+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
603+
604+
- name: 使う道具の版を出す (降りた理由を後から辿れるように)
605+
run: |
606+
node --version
607+
(google-chrome --version || chromium --version || echo 'ブラウザが見つかりません')
608+
# コンテナだと 64MB しか無いことがある (Chrome が固まる定番)。
609+
df -h /dev/shm || true
610+
nproc || true
611+
612+
# わざと壊して赤になることまで確かめる (--self-test)。
613+
# 素通りする検査を CI に置かない。
614+
#
615+
# **出力を変数へ溜めない。** 以前は out=$(...) で溜めていたので、
616+
# 固まると 15 分間 1 バイトも出ないまま打ち切られ、どこで止まったのか
617+
# 1 行も残らなかった。tee で素通ししながら控えを取る。
618+
- name: スマホ画面を描いて不変条件を確かめる
619+
env:
620+
# ジョブの timeout より必ず短くする。**道具のほうが先に**
621+
# 「なぜ終わらないか」を書いて赤で終わるのが要点。
622+
ZV_DEADLINE_MS: '600000'
623+
ZV_STALL_MS: '120000'
624+
ZV_WALL_S: '660'
625+
ZV_HEARTBEAT_MS: '20000'
626+
run: |
627+
set -o pipefail
628+
log=$(mktemp)
629+
rc=0
630+
sh tools/remote-check.sh --self-test 2>&1 | tee "$log" || rc=$?
631+
if [ "$rc" -ne 0 ]; then
632+
echo "::error::スマホ画面の検査が赤 (rc=$rc)"
633+
exit "$rc"
634+
fi
635+
if grep -q '\[skip\]' "$log"; then
636+
echo "::error::スマホ画面を 1 度も描かずに降りた (このジョブでは skip は不合格)"
637+
exit 1
638+
fi

‎CLAUDE.md‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,21 @@
9797
のどこから変えても `ui_language` と揃う(真実の在り処を 1 つに保つ)。
9898
- スマホ側 (`remote.rs`) も同じ辞書を使う。文言は `T('remote.xxx','日本語原文')` と
9999
`data-i18n` 属性で差し替え、**第 2 引数の原文がフォールバック**になる。
100+
- **走査に現れない文字列は、番人も `zai i18n missing` も何も言わないまま
101+
永久に日本語で出る。** 0.20 系で 71 箇所がこの状態だった (`tr("…\` の
102+
行継続を正規表現が食えず、**マッチ自体が不成立**だった)。画面には出るのに
103+
検査は 0 件と言うので、いちばん見つけにくい。
104+
- `tr(` に渡すのは**素の文字列リテラル 1 つ**。`concat!` / `format!` / 変数は
105+
静的に辿れない (`src/mesh.rs` の usage が実際に全言語で日本語のまま)
106+
- **行継続を使わない**。使うと `unescape_rust` の畳み方と 1 バイトでも
107+
ずれた瞬間に辞書が引けなくなる
108+
- `zai i18n missing` が見るのは **Rust の `tr()` だけ**。スマホ側 JS の
109+
`T()` の ID 抜けは `remote::tests::スマホ画面のt呼び出しidは同梱辞書にある`
110+
でしか捕まらない (抜けても日本語で表示されるので、動いて見える)
111+
- **機械的な置換を言語をまたいで当てない。** 辞書 6 言語を 1 本の正規表現で
112+
掃除したら、**120 箇所で単語がくっついた** (`runs regardless` →
113+
`runsregardless`)。日本語は改行位置に空白が要らないが、英語・スペイン語・
114+
ポルトガル語・韓国語は要る。接合部の判断は言語ごとにしかできない。
100115

101116
## テストと検証
102117

@@ -263,6 +278,64 @@
263278
Windows 由来のリポジトリが「証明できず」**になっていた。
264279
読み書きで**改行を保存する**こと。「対象を汚していない」の検査は内容まで見る。
265280

281+
- **ソースを読む番人は「関数の中だけ」を見る。範囲を広げると空回りする。**
282+
改行をまたぐ照合 (`PAGE.contains("a\nb")`) は Windows の CRLF で必ず外れるので
283+
`replace("\r\n", "\n")` を通す — これは前から書いてあったのに**また踏んだ**
284+
(`remote::tests::音声の案内は…` が Windows の CI でだけ赤)。
285+
そこで `cli::tests::改行をまたぐ照合は正規化してから使う` を足したが、
286+
**最初の 2 版は空回りした**:
287+
- 「直前 60 行」で正規化を探す → **同じファイルの別のテスト**が書いた
288+
`replace` を拾い、わざと壊しても緑
289+
- 自分のコメント文と、判定に使う語 (`"SRC"` 等) を自分で拾って誤検出
290+
直った形は「**囲っている関数の中だけ**を見る + コメント行は除く」。
291+
番人を足したら必ず**わざと壊して赤になること**を確かめる — この 3 版とも
292+
「テストは通っている」状態だったが、守っていたのは 1 版だけだった。
293+
294+
- **関門そのものが固まると、関門ごと信用を失う。**
295+
新設した `mobile screen` が CI で**無音のまま 15 分**でタイムアウトした
296+
(原因が 1 行も残らない)。ランダムに止まる関門は、そのうち誰も見なくなる。
297+
- **時限は 3 段**にし、必ず CI ジョブの上限より内側で撃つ
298+
(`ZV_DEADLINE_MS < ZV_WALL_S < timeout-minutes`。順序は番人が数で照合)
299+
- 固定の待ちにすると遅いランナーを誤って殺す。**進捗が観測できる限り延ばす**
300+
- **`--hang` で自分をわざと固まらせ、時限が効くことを毎回証明する**
301+
(`--self-test` が子プロセスで撃つ)。空回りする時限を残さない
302+
- ワークフローで `out=$(…)` と溜めない。固まると 1 バイトも出ない。`tee` で素通し
303+
- 再現は運任せにしない。`pkill -STOP` でブラウザを凍らせれば**決定的に**作れる
304+
- 実測で分かった真犯人: **Node の `srv.close()` は処理中の要求が 1 本あると
305+
コールバックを呼ばない**。後始末が毎回そこを通っていた (12 行で最小再現)
306+
- **CI の赤を「間欠」と言ってよいのは、同じ内容で緑を取れたときだけ。**
307+
`windows (lease/instances)` は 64 体の取り合いで busy-deny 18 件。
308+
**同一コミットの再実行で緑**になったので負荷依存と判定できた。
309+
再実行せずに「たぶん間欠」と書くのは、直せる赤を捨てるのと同じ。
310+
- **サブエージェントに `git checkout --` を撃たせない。** 共有ツリーでは
311+
**他のエージェントの未コミット作業を消す**。実際に添付アップロードの
312+
173 行が消え、セッション記録から再適用して復元した。退避が要るなら
313+
WIP コミット (`git stash` も全ワークツリー共有なので同じ理由で禁止)。
314+
- **squash マージした PR の上に積んだ PR は、必ず衝突する。**
315+
main には 1 個の squash コミットしか無いので、同じ変更が二重に見える。
316+
`git rebase --onto origin/main <積んだ元の先端>` で落としてから push し直す。
317+
- **リリース前は `tools/release-gate.sh` 1 本。誰も動かしていない層を残さない。**
318+
0.18 系で 4 件が出荷まで生き残った (スマホの 2 重表示 / チップが切り替わらない /
319+
PC の履歴二重 / `tr("…\` 行継続の 71 箇所)。**どれも実際に動かせば 5 秒で
320+
分かる**のに、Rust のテストはソースの文字列を見るだけで**ブラウザの DOM も
321+
CSS も 1 度も評価していなかった**。道具 (`verify.sh` / `gui-smoke.sh` /
322+
`linux-test.sh` / `windows-check.sh` / `zai i18n missing`) は揃っていて、
323+
**走らせるかどうかが人の記憶に委ねられていた**だけである。
324+
- `tools/remote-check.sh` が `assets/remote/` を**本物のブラウザ (CDP)** で
325+
描き、遷移を総当たり + 乱歩 (種は固定) して不変条件を見る:
326+
見えている `.view` は 1 つ / 主枠 (flex:1 の子) は同時に 1 つ /
327+
矩形が重ならない / 横に見切れない / 押せない大きさのボタンが無い /
328+
JS 例外ゼロ / **ポーリングが実際に飛んでいる** (判定は偽 PC 側の受信数)
329+
- **`--self-test` で 7 つの欠陥を仕込み、全部捕まることを毎回証明する。**
330+
空振りする検査は残さない。新しい不変条件を足すときは、**それを破る仕込みも
331+
同時に足す**
332+
- ブラウザが無い環境の最後の砦として、**CSS の法則**を Rust の番人に置いた
333+
(`cli::tests::スマホのcssは隠したものを脇道から見せない`) —
334+
「`display:none` で隠したものを見せる規則は、クラス集合の包含で 1 本の鎖に
335+
なっていること」。枝分かれ (`#alist.show` と `#alist.mid`) が抜け道になる
336+
- **`[skip]` は緑ではない。** 関門は最後に `緑 / 赤 / 未確認` を出す。
337+
CI では skip を不合格にする。経緯と層の地図は `docs/preflight.md`
338+
266339
## UI の原則
267340

268341
- **画面が突然変わらない。** 何かを起動しただけでレイアウトが激変してはいけない。

‎assets/plugins/english-mode/lang/80-tailscale.toml‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,24 @@
3434

3535
# ── 張り替えの失敗 ──
3636
"待ち受けの切り替えに失敗しました: {e} (元の待ち受けに戻しました)" = "Could not switch where we listen: {e} (restored the previous one)"
37+
38+
# ── HTTPS (tailscale serve) — スマホの音声入力が動く唯一の形 ──
39+
"Tailscale の HTTPS 経由 — 🎤 音声入力が使えます (QR を読み取って接続)" = "Over Tailscale HTTPS — 🎤 voice input works (scan the QR to connect)"
40+
"Tailscale に TLS を終端させ、tailnet のホスト名で\n本物の証明書 (Let's Encrypt) を出します。スマホから見た URL が\nhttps になるので、ブラウザの音声認識が動きます\n(平文の http では、どの端末でも仕様上ぜったいに動きません)" = "Let Tailscale terminate TLS and issue a real certificate\n(Let's Encrypt) for your tailnet hostname. The phone then sees an\nhttps URL, so the browser's speech recognition works\n(over plain http it can never work, on any device)."
41+
"tailnet の HTTPS 公開 (tailscale serve) を解除して、\n同じ Wi-Fi から繋げるように戻します" = "Turns off HTTPS publishing (tailscale serve) on your tailnet\nand goes back to connecting from the same Wi-Fi"
42+
"※ 最初の 1 回だけ証明書の取得で待つことがあります\n (失敗したらもう一度読み込んでください。2 回目からは一瞬です)" = "※ Only the very first connection may wait while the certificate is fetched\n (if it fails, just reload — from the second time on it is instant)"
43+
"127.0.0.1 のみ (Tailscale の HTTPS 経由)" = "127.0.0.1 only (via Tailscale HTTPS)"
44+
"🔒 tailnet の HTTPS 公開を準備しています…" = "🔒 Preparing HTTPS publishing on your tailnet…"
45+
"🔒 証明書を取得しています… (初回だけ 1 分ほどかかることがあります)" = "🔒 Fetching the certificate… (the first time can take about a minute)"
46+
"🔓 tailnet の HTTPS 公開を解除しています…" = "🔓 Turning off HTTPS publishing on your tailnet…"
47+
"tailscale コマンドが見つかりません" = "The tailscale command was not found"
48+
"Tailscale アプリは見つかりましたが、CLI を実行できませんでした" = "The Tailscale app was found, but its CLI could not be run"
49+
"この tailnet では HTTPS 証明書が無効です" = "HTTPS certificates are disabled for this tailnet"
50+
"管理コンソールの DNS 画面で「HTTPS Certificates」を有効にしてください" = "Turn on \"HTTPS Certificates\" on the DNS page of the admin console"
51+
"Tailscale の HTTPS 公開に失敗しました" = "Tailscale could not publish over HTTPS"
52+
"下の出力がそのままの理由です" = "The output below is the reason, verbatim"
53+
"🔓 HTTPS をやめる" = "🔓 Turn off HTTPS"
54+
"🎤 HTTPS で待ち受ける (スマホの音声入力が使えます)" = "🎤 Listen over HTTPS (enables voice input on your phone)"
55+
"🔒 https://{d}/ で待ち受けています (スマホの音声入力が使えます)" = "🔒 Listening on https://{d}/ (voice input works on your phone)"
56+
"管理コンソールの URL をコピーします" = "Copies the admin console URL"
57+
"管理コンソールの URL をコピーしました" = "Copied the admin console URL"

‎assets/remote/js/45-term.js‎

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@
5656
let sbOff = false; // /api/scrollback が無いサーバ
5757
let loading = false; // 上への継ぎ足し中
5858
let noTop = false; // これ以上前は無い
59+
// 「いま読んでいる行が絶対何行目か」の対応 (`bufFrom` と buf の並び) を
60+
// 作り直した回数。**取得の前提**であり、await から戻ったときにこれが
61+
// 変わっていたら、その応答はもう今の buf に繋がらない。
62+
// 追い付き (applyTail の末尾追記) は対応を動かさないので数えない —
63+
// 数えると出力が流れている端末では遡りが永久に成立しなくなる
64+
let epoch = 0;
5965
let curAgent = -2; // 表示中のエージェント (変わったら作り直す)
6066
let findOn = false, findQ = '', findTimer = null;
6167
// 一致はいまの buf 内の添字で持つが、**いま選んでいる 1 件だけは絶対行で覚える**。
@@ -222,7 +228,7 @@
222228
// 組み替えると、アイドルのはずの画面が毎秒仕事をする)
223229
function showEmpty() {
224230
if (!buf.length && SCR.classList.contains('zvmid')) return;
225-
buf = []; bufFrom = 0; total = 0; plainSig = '';
231+
buf = []; bufFrom = 0; total = 0; plainSig = ''; epoch++;
226232
hits = []; byRow = new Map(); cur = 0; curAbs = null;
227233
syncCount();
228234
clearBox();
@@ -466,7 +472,7 @@
466472
function applyTail(from, rows) {
467473
const rs = rows.map(norm);
468474
if (!buf.length || from > bufFrom + buf.length || from < bufFrom) {
469-
bufFrom = from; buf = rs; noTop = false;
475+
bufFrom = from; buf = rs; noTop = false; epoch++;
470476
fullRender();
471477
return true;
472478
}
@@ -483,6 +489,7 @@
483489
SCR.appendChild(mkRow(rs[i], null));
484490
}
485491
}
492+
if (buf.length > MAX_ROWS) epoch++; // 前を捨てる = 絶対行の対応が動く
486493
while (buf.length > MAX_ROWS) {
487494
buf.shift(); bufFrom++; changed = true;
488495
if (SCR.firstChild) SCR.removeChild(SCR.firstChild);
@@ -496,33 +503,48 @@
496503
return;
497504
}
498505
loading = true;
506+
// **取ったときの前提を応答に持たせる。** `loading` は遡り同士しか守らない
507+
// ので、await の間に追従 (`pollSb` → `applyTail`) が走りうる:
508+
// * 先頭が MAX_ROWS で間引かれる → `bufFrom` が増える
509+
// * 追い付けない量が出た / セッションが消えた → `buf` ごと作り直す
510+
// * 別のエージェントへ切り替わる (`syncAgent`)
511+
// どれが起きても `from` はもう今の buf に繋がらない。それでも繋ぐと
512+
// 絶対行が **ずれたまま** 残り、次の追従が `from + i - bufFrom` で
513+
// 別の場所へ書き込んで **同じ行を 2 度描く** (実測 60 行)。
514+
// 前提が変わっていたら **この 1 回の取得を捨てる** — `noTop` は立てない
515+
// ので、次に上端へ触れば取り直せる (利用者から見た損失は 1 往復)。
516+
const at = bufFrom, era = epoch;
499517
try {
500-
const j = await fetchSb(CHUNK, bufFrom);
518+
const j = await fetchSb(CHUNK, at);
519+
if (era !== epoch) return; // 前提が変わった — この応答は捨てる
501520
const from = j.from | 0;
502521
const rows = (j.rows || []).map(norm);
503522
if (typeof j.total === 'number') total = j.total;
504-
if (!rows.length || from >= bufFrom) {
523+
if (!rows.length || from >= at) {
505524
noTop = true;
506525
toast(T('remote.term_top_reached', 'これより前の記録はありません'));
507526
} else {
508-
const head = rows.slice(0, Math.min(rows.length, bufFrom - from));
527+
const head = rows.slice(0, Math.min(rows.length, at - from));
509528
// 読んでいた行が飛ばないように、増えたぶんだけ scrollTop をずらす
510529
const h0 = SCR.scrollHeight, t0 = SCR.scrollTop;
511530
const f = document.createDocumentFragment();
512531
for (let i = 0; i < head.length; i++) f.appendChild(mkRow(head[i], null));
513532
SCR.insertBefore(f, SCR.firstChild);
514533
buf = head.concat(buf);
515-
bufFrom = from;
534+
bufFrom = from; epoch++;
516535
SCR.scrollTop = t0 + (SCR.scrollHeight - h0);
517536
if (bufFrom <= 0) noTop = true;
518537
refreshHits(new Map());
519538
syncStatus();
520539
}
521540
} catch (e) {
522541
if (e && e.gone) sbOff = true;
523-
else if (e && e.none) noTop = true; // 今は返せない — 上端を叩き続けない
542+
// 今は返せない — 上端を叩き続けない。ただし前提が変わっているなら
543+
// それは「前が無い」ではなく「別の buf の話」なので立てない
544+
else if (e && e.none && era === epoch) noTop = true;
545+
} finally {
546+
loading = false;
524547
}
525-
loading = false;
526548
}
527549
async function pollSb() {
528550
let j;
@@ -550,7 +572,7 @@
550572
plainSig = text;
551573
const lines = text.split('\n');
552574
buf = lines.map(t => norm({ spans: [{ t: t }] }));
553-
bufFrom = 0; total = buf.length; noTop = true;
575+
bufFrom = 0; total = buf.length; noTop = true; epoch++;
554576
computeHits();
555577
fullRender();
556578
if (follow) scrollBottom();
@@ -562,7 +584,7 @@
562584
const a = agentIdx();
563585
if (a === curAgent) return;
564586
curAgent = a;
565-
buf = []; bufFrom = 0; total = 0; plainSig = '';
587+
buf = []; bufFrom = 0; total = 0; plainSig = ''; epoch++;
566588
noTop = false; follow = true;
567589
hits = []; byRow = new Map(); cur = 0; curAbs = null;
568590
clearBox();

0 commit comments

Comments
 (0)