Skip to content

fix: スクロールの重複 (PC/スマホ)・待ちの嘘・音声の案内・添付の口 + 出荷前の関門 - #35

Merged
tacyan merged 8 commits into
mainfrom
fix/i18n-line-continuation
Aug 17, 2026
Merged

tacyan merged 8 commits into
mainfrom
fix/i18n-line-continuation

Conversation

@tacyan

@tacyan tacyan commented Aug 17, 2026

Copy link
Copy Markdown
Owner

PR #34 の上に積んでいます (base = fix/dup-view-and-tree-filter)。
#34 は CI 11/11 緑です。#34 を先に取り込んでからこちらを見てください。

報告された 5 件を直し、あわせて同じ種類のバグが出荷まで生き残らない仕組みを作りました。

1. スクロールで同じ文字が出る — PC

原因: Session::adjust_scroll がホイール 1 ノッチでパーサのロックを 3 回に分けて取っていた。

  1. 取り込んで今の位置を読む → 離す
  2. n = self.scroll + delta で目標を組み立てる
  3. set_scroll(n) が中でもう一度取り込み → set_scrollback(n)

読取スレッドは 1 回の read(8KB ≒ 130 行)をロックを握ったまま流す。1 と 3 の隙間に M 行押し出されると目標が M 行ぶん古くなり、しかも set_scroll は入口で位置を直しておきながら直した値を捨てて古い目標を撃つ。

実測 (20 ノッチ × delta=2、期待 +40 行):

隙間の押し出し 0 1 行 3 行 8 行 130 行
直す前 +40 +20 −20 −120 −2560
直した後 +40 +39 +37 +32 −90

M > delta で上へ回したのに下へ戻る = さっき見た行がまた出る。出力が流れている端末ほど激しい。

直し方: 基準の読み直しと適用を 1 回のロックの中へ。ドラッグの自動スクロールも同じ経路なので一緒に直る。

白だったもの(調べて否定した 7 件): vendor/vt100 の窓 / 押し出し計数 / scrolled_delta / all_terminal_lines / selection_text_abs / 代替画面の出入り / DECSTBM。vendor/vt100 は 1 バイトも触っていません。

2. スクロールで同じ文字が出る — スマホ

実物の 45-term.js を Node で回して決定的に再現しました(応答順を台本で握る)。

prependOlder の応答を待っている間に MAX_ROWS の間引きが起きると bufFrom(絶対行の対応)が await をまたいで動く。戻ってきた応答は今の bufFrom で接合位置を計算するので 60 行の穴が空き、次の追従がずれた添字へ書き込んで同じ 60 行が 2 か所に出る。

「同じ buf を触る」問題ではないので、遡り同士を守る loading では防げません。

筋書き 前 後
遡り中に間引き 重複 60 行 0
遡り中にエージェント切替 余所の端末の行 300 0
遡り中に空状態 嘘の「これより前は無い」 1 0
追記だけ(反証用) 継ぎ足せた 360 / 重複 0 変わらず

最後の行は「捨てすぎていない」証明です。ここが赤なら忙しい端末では二度と遡れません。

3. 「待っているエージェントはいません」が残り続ける

#34 で直した「端末へ戻っても空カードが消えない」とは別物です。あれは描く場所、これはデータの出どころ。

バッジは /api/state(2.5 秒)、一覧は /api/agents(1.5 秒)と別の応答から来ていました。判定関数は同じでも取得の時点が違ううえ、/api/agents が落ちたときだけ描き直さないので、**「⏳ 待ち ②」と出ているのに「待っているエージェントはいません」**が残ります。

真実の在り処を 1 つに寄せ、一覧が画面にある間はバッジも一覧も同じ応答から作るようにしました。さらに「読めていない」と「0 件」を三値で分け(読めないときは ⚠ カード + バッジは …)、取得に締め切りを付けてポーリングが死なないようにしています。

HEAD では 10 個の不変条件のうち 6 個が赤、直した後は 10 個すべて緑。

4. スマホの音声入力

調べた結果を事実として書きます。プレーン http では、どの方法でもブラウザの音声認識はできません:

  • SpeechRecognition も MediaDevices も IDL が [SecureContext]。iOS Safari では webkitSpeechRecognition が存在すらしない
  • したがって「録って PC へ送って認識する」逃げ道も塞がっている(navigator.mediaDevices が undefined)
  • LAN の private IP も *.ts.net (MagicDNS) も potentially trustworthy ではない

一方で https にすれば今のコードのまま動きます(判定は端末ではなく isSecureContext)。案内文に「原因」だけでなくこの直し方を書き足しました。

そのうえで依頼どおりクリックで消せるようにし、消したことを覚えます。消したきり理由に辿り着けなくならないよう、🎤 の長押し (600ms) で戻せます。

5. スマホから画像などを送る 📎

入力欄の左にボタンを足しました。送るの実体は (a) 作業フォルダの下へ保存し (b) そのパスを入力欄へ入れる。宛先は既存の仕組み(bulkMode / /api/bulk)にそのまま乗せ、新しい宛先の概念は 1 つも作っていません。

安全側: 名前を 1 要素へ畳み、..・ドライブ・制御文字を落とし、字句と canonicalize の両方でワークスペースの外へ出られないことを確かめます。上限超過は黙って切らずに断ります。置き場には * だけの .gitignore を落とし、利用者の写真が git add -A で紛れ込まないようにしました。

6. 出荷前の関門 — これが本題かもしれません

今回すり抜けた 4 件はどれも「実際に動かせば 5 秒で分かる」のに、Rust のテストはソースの文字列を見るだけで、ブラウザの DOM も CSS も 1 度も評価していませんでした。 道具は揃っていて、走らせるかどうかが人の記憶に委ねられていただけです。

  • tools/remote-check.js — スマホ画面を build.rs と同じ順序で組み、本物の Chromium に CDP で描かせて検査。偽 PC 側も内蔵、npm 依存ゼロ
  • tools/release-gate.sh — 出荷前はこれ 1 本。最後に 緑 / 赤 / 未確認 の表と判定行を出す。[skip] は緑ではない(CI では不合格)
  • docs/preflight.md — 4 件がどうすり抜けたかの表と層の地図
  • .github/workflows/test.yml に remote-screen ジョブを追記(Rust を 1 行もコンパイルしない)

見るのは既知の形ではなく不変条件です: 見えている .view は 1 つ / 主枠は同時に 1 つ / 矩形が重ならない / 横に見切れない / 押せない大きさのボタンが無い / JS 例外ゼロ / ポーリングが実際に飛んでいる / チップを押すと端末の中身が変わる。これを総当たり + 種を固定した乱歩で回し、1 実行あたり 385 状態を見ます。

空振りする検査を残さないため、--self-test が 7 つの欠陥を仕込んで全部捕まることを毎回証明します(今日出た #alist.mid の詳細度もその 1 つ):

✓ 素の状態 — 違反なし (385 状態)
✓ history-css [slot] / two-views [view] / overflow [overflow] / dead-button [tap]
✓ no-poll [poll] / chip-dead [stuck] / js-error [console]

ブラウザが無い環境の最後の砦として、CSS の法則を Rust の番人に置きました — 「display:none で隠したものを見せる規則は、クラス集合の包含で 1 本の鎖になっていること」。枝分かれ(#alist.show と #alist.mid)が抜け道になります。

検証

  • cargo test --bin zai 4929 / 4930 通過。落ちた 1 件は remote::tests::張り直してもトークンは変わらずurlのホストだけ変わる で、開発機で本物の Zaivern が動いていてポート 8899 を握っているため。テスト自身が「同じポートの 127.0.0.1 を別プロセスが握っている可能性」と出力しています
  • tools/remote-check.sh --self-test 緑(385 状態 + 7 つの仕込みを全部検出)
  • zai i18n missing 0 件(新規 ID は 6 言語ぶん)
  • cargo fmt --check 緑 / clippy 緑
  • 追加した番人はすべて変異試験で赤化を確認しています

途中で一度こちらの主張が崩れています

翻訳の一括修正で「意味も体裁も壊していない」と報告しましたが、反証役が 120 箇所で単語がくっついていることを見つけました(runs regardless → runsregardless)。日本語は改行位置に空白が要らないが英語・スペイン語・ポルトガル語・韓国語は要る、という当たり前のことを機械規則が踏み潰していました。撤回して言語ごとに繋ぎ直しています。

🤖 Generated with Claude Code

@tacyan
tacyan changed the base branch from fix/dup-view-and-tree-filter to main August 17, 2026 03:35
tacyan and others added 6 commits August 17, 2026 12:35
## 静かな壊れ方

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>
利用者の報告: **「スクロールを何回かやったら、同じ文字が発生する」** (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>
## 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>
## なぜ要るのか

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>
## なぜ

スマホのブラウザの音声認識はセキュアコンテキストでしか動かない。
`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>
## 直した赤

`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>
@tacyan
tacyan force-pushed the fix/i18n-line-continuation branch from 00e0670 to dbc25d3 Compare August 17, 2026 03:39
tacyan and others added 2 commits August 17, 2026 14:31
新設した関門 `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>
次に読む人が同じ穴に落ちないように、**この版で実際に踏んだもの**だけを残す。

## 多言語

- **走査に現れない文字列は、番人も `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>
@tacyan
tacyan merged commit eab8dce into main Aug 17, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant