Skip to content

perf: park always-on bridge polls while nothing can write to them - #400

Merged
yuga-hashimoto merged 4 commits into
mainfrom
claude/battery-consumption-optimization-961b0f
Oct 24, 2026
Merged

yuga-hashimoto merged 4 commits into
mainfrom
claude/battery-consumption-optimization-961b0f

Conversation

@yuga-hashimoto

Copy link
Copy Markdown
Owner

Summary

Three file polls between the app and the in-guest agent ran for the life of the process, waking it every few hundred milliseconds whether or not anything could have been written:

Poll Before Now polls only while
Claude Code permission bridge (ClaudeCodeRuntime) every 250 ms, started by the first session, never cancelled a CLI process is registered
Schedule bridge (ScheduleBridge) every 800 ms on the application scope the app is in the foreground or runtimeWork is active
Guest browser command watcher every 1 s from a LaunchedEffect, which keeps running while the activity is stopped the app is in the foreground

Flow<Boolean>.pollWhileActive(intervalMillis, action) is the shared gate: it polls at once when the gate opens, then every interval, and stays suspended with no timer while the gate is closed.

ScheduleBridge.run now requires the gate instead of defaulting to "always", so a future call site cannot quietly bring the unbounded loop back.

Why the gates are safe

  • Permission bridge: the hook that parks a request dies with its CLI process (see pendingQuestions), so with no process registered nothing can appear. The state is published from the only two places that change sessions, under the same monitor.
  • Schedule bridge: with the app backgrounded and no runtimeWork lease, the device is free to suspend and the guest freezes with it, so there is nobody to answer. Chat/voice sessions ("sessions" lease), scheduled runs (schedule:<id>), installs, ADB and terminal commands all hold leases.
  • Browser watcher: opening a browser nobody is looking at achieves nothing; a command written while away is opened as soon as the app returns.

Also in this PR

  • Requests older than 95 s (just past the guest MCP's 90 s RESPONSE_TIMEOUT_S) are answered with an "expired" error and discarded instead of run. Without this, a request written while the poll was parked would run on the way back in, after the guest had already told the agent the call failed. A missing, zero or future createdAtMs still runs.
  • ClaudeCodeRuntime changes sessions only through registerSession / unregisterSession, which keep the hasLiveSessions flag in step.
  • docs/LOCAL_RUNTIME.md says when the schedule bridge polls.

What to expect

These are small wakeups: a directory listing or a stat each. A delay does not wake a suspended CPU, so they only cost anything while the device is awake for another reason. Expect a modest improvement, not a step change. No before/after battery measurement was done, and the bulk of the drain for this kind of app is likely the agent itself plus screen-on time, which this PR does not touch.

Known limitations and follow-ups

None of these were blocking in the pre-PR review.

  1. The permission-bridge gate is "a CLI process exists", not "a turn is running". CLI processes stay resident after a turn and stop only on stop / stopAll / exit, so after the first Claude chat the 250 ms poll keeps running until those happen. This PR stops it after them and for any period with no process. Tightening it to hasLiveSessions && (foreground || runtimeWork.active) or to a turn-in-progress condition would help more, but a missed condition silently drops permission prompts, so it is left conservative.
  2. Expiry window. The threshold is 95 s against the guest's 90 s deadline, so a request that is 90-95 s old can still run after the guest gave up. The guest's deadline is time.monotonic() (it does not advance while the device is suspended) while createdAtMs is wall clock, so across a suspend the app can answer "expired" while the guest is still waiting; that fails safe (nothing is executed). The KDoc on REQUEST_EXPIRES_AFTER_MILLIS and the "90 s" in docs/LOCAL_RUNTIME.md read slightly stronger than this; a wording fix is a follow-up. andcode-schedule-mcp.py has no reciprocal comment because the shipped script was not touched.
  3. A guest turn that continues outside the app's session tracking (for example an SSE drop longer than the 60 s debounce while the device is awake for another reason) can now get "expired" on a schedule MCP call that used to succeed.
  4. If the browser watcher's gate closes while poll is suspended in withContext(Dispatchers.IO) right after it consumed the command file, that URL is dropped. It is a window of milliseconds, at the moment the app leaves the screen, and is documented in the KDoc.
  5. No unit test covers the registerSession / unregisterSession -> hasLiveSessions wiring, because ClaudeCodeRuntime needs real CLI processes. Extracting sessions and the flag into a small holder would make the onlyProcess semantics testable.
  6. pollWhileActive restarts, and polls immediately, if its upstream re-emits an equal true. Every caller passes a StateFlow or applies distinctUntilChanged(); the helper could dedupe itself.

Test plan

  • ./gradlew :app:testGithubDebugUnitTest: 1404 tests, 0 failures, 0 errors (8 skipped, not from this change).
  • ./gradlew spotlessCheck passes. ./gradlew detekt reports NO-SOURCE locally, so it analysed nothing and is not evidence.
  • :app:assembleGithubDebug and :app:assembleGithubDebugAndroidTest build.
  • Mutation check: disabling the gate in pollWhileActive makes exactly the five gating tests fail and nothing else.
  • A trial merge with the current origin/main is clean.
  • On device: a Claude Code permission/question prompt still arrives, in the foreground and when backgrounded mid-turn.
  • On device: and-code-schedule MCP create/list from an agent still works.
  • On device: a browser-open command opens the guest browser, including one written while the app was backgrounded.
  • Battery: before/after dumpsys batterystats. Not done.

Pre-PR review

Run note (not part of the reviewer's report). The repo-reviewer subagent type is not registered in the Claude Code session this branch was written in. The report below was produced by a read-only kotlin-reviewer subagent that was told to follow .opencode/agents/repo-reviewer.md exactly (same repository map, hard rules and output format). It is that subagent's actual output for HEAD 2026ae52. Two earlier passes, on bad489d8 and 0f8ad22d, also returned APPROVE with no blockers; the later commits apply their cheap suggestions. origin/main has since gained three unrelated commits (chat stall diagnostics) that touch none of this branch's files.

判定: APPROVE

ブロッカー

なし

提案(非ブロッキング)

  • app/src/main/java/com/yugahashimoto/andcode/feature/schedule/ScheduleBridge.kt:385-391 — REQUEST_EXPIRES_AFTER_MILLIS のKDocが実装より強く書かれている — 次の3点を直すと正確になります。
    1. 「一度諦めたリクエストは必ず捨てる」とは言えません。しきい値は95秒、ゲストが諦めるのは約90秒です。gateが再開するタイミングがちょうど90〜95秒に当たると、ゲストが失敗を返したリクエストが実行されます。余白5秒は、ゲストの0.3秒ポーリングと書き込み遅延の分と読めます。そう書き、「drift許容は不要」という文との矛盾も解消してください。docs/LOCAL_RUNTIME.md:221 の「90秒を過ぎた」も同様に「約90秒(実際は95秒)」が正確です。
    2. ゲストの期限は time.monotonic()(andcode-schedule-mcp.py:59)ですが、createdAtMs は壁時計です。Linuxの CLOCK_MONOTONIC は端末suspend中に進みません。そのためsuspendを挟むと、ゲストがまだ待っているのにアプリ側が「expired」を返すことがあります。ゲストにはエラーが即座に返るだけで、実行はされません。安全側の誤判定ですが、「同じカーネルクロックなので許容は不要」という説明は壁時計についてしか成り立ちません。
    3. KDocは「andcode-schedule-mcp.py側と揃えること」と書いていますが、Python側(andcode-schedule-mcp.py:31 の RESPONSE_TIMEOUT_S = 90)には逆向きのコメントがありません。コメントを足すか、テストでアセットの値を読んで REQUEST_EXPIRES_AFTER_MILLIS との関係を固定すると、片方だけ変更される事故を防げます。
  • app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeCodeRuntime.kt:75,285 — permission bridge ウォッチャのgateは「CLIプロセスが存在する」で、「ターンが実行中」ではありません。CLIはターン終了後も常駐し、止まるのは stop と stopAll だけです。一度でも会話すると、アプリが背面でwake lockがなくても、端末が起きている間は250msポーリングが続きます。許可フックが書き込めるのはターン中だけです。foreground || runtimeWork.active との合成にすると、ねらい(バッテリー削減)に近づきます。手間が大きければ現状のままでも妥当です。
  • app/src/test の ClaudeCodeRuntime 関連 — registerSession / unregisterSession → hasLiveSessions の配線にはテストがありません。壊れると許可プロンプトが無言で出なくなる箇所で、コミットメッセージもそこを狙った変更だと説明しています。プロセス起動が絡んで直接テストしづらいので、sessions と hasLiveSessions を小さなホルダークラスに切り出し、onlyProcess の等価性(差し替え済みなら消さない)をユニットテストで固定することを検討してください。
  • app/src/main/java/com/yugahashimoto/andcode/core/util/FlowExt.kt:103 — pollWhileActive は上流が true, true と流すと、ループを再起動して即時ポーリングします。現状の呼び出し元は StateFlow か distinctUntilChanged()(AndCodeApplication.kt:589-591)で重複を除いているので問題ありません。ヘルパー内に distinctUntilChanged() を入れるか、KDocで「呼び出し側で重複を除くこと」と明記すると、将来の事故を防げます。
  • gate設計の既知の割り切り(変更不要。docsかPR本文に一言あるとよい) — ゲスト側のターンがアプリのセッション追跡の外で続く場合(SSE切断が60秒のdebounceを超えた、端末が別の理由で起きているなど)、スケジュールMCPの呼び出しは「expired」エラーになります。以前は成功していたケースです。

チェック済み項目

  • 差分は git fetch origin main のうえ git diff origin/main...HEAD で全hunk(main 6ファイル、test 3ファイル、docs 1ファイル)を読みました。ブランチは origin/main に対して 0 behind / 4 ahead で、作業ツリーはクリーンです。strings.xml などリソースの変更はなく、i18nへの影響はありません。ゲストに返す「Request expired ...」は英語で、「LLM向け・ログは英語」の規約に合っています。
  • 観点1(ClaudeCodeRuntime):
    • sessions の追加は registerSession(ensureProcess の .also)だけです。削除は stop と reader job終了時の unregisterSession(..., onlyProcess = process) だけです。stopAll は stop 経由で、sessions の直接参照は読み取りのみです。
    • onlyProcess の意味は集約前の if (sessions[id]?.process === process) remove と等価です(エントリなしと別プロセスはどちらも null を返します)。stop も旧 remove(id)?.let と等価です。
    • @Synchronized の再入は問題ありません。ensureProcess は send(@synchronized)からのみ呼ばれ、同一モニターは再入可能です。reader job(Dispatchers.IO)は send の完了までロックを待つので、旧コードと同じ順序で register が unregister より先になります。
    • StateFlow の書き込みはモニター内ですが、collector の再開はディスパッチされるので、デッドロックにはなりません。ウォッチャ側のアクションにも this のロック取得はありません。
    • hasLiveSessions はモニター内で sessions と同時に更新されます。プロセス起動失敗時は false のままで正しく、置換(stop → register)時の false→true の一瞬は collectLatest が吸収します。生きたセッションがあるのにウォッチャが止まったままになる経路は見つかりませんでした。
    • summarizeTitle と runInWorkspace は sessions に入りませんが、前者は --tools "" で許可フックが動かず、後者は単なるシェルなので、requestを積まないことを確認しました。
  • 観点2(ScheduleBridge):
    • 値の一致: createdAtMs は Python が int(time.time()*1000)(エポックms)で書き、Kotlin は System::currentTimeMillis と optLong で読むので、単位も時計も一致しています。
    • 境界: 0・欠落・null・非数値は既定値の0になり、createdAtMs > 0 を満たさないので実行されます。未来時刻は差が負なので実行されます。now - createdAtMs にオーバーフローはありません。
    • テスト: 既存テストは createdAtMs = 0 と実時計の組み合わせで、0が「不明」扱いであることを暗黙に固定しています。追加テストは期限切れ、期限内、しきい値の前後、未来時刻と欠落の5系統で、時計を固定しているため決定的です。
    • 副作用: 期限切れ時は file.delete() が応答書き込みより先なので、再実行の恐れはありません。残った応答ファイルは15分の pruneStale で消えます。
    • しきい値の関係(95秒とゲストの90秒、KDocとdocsの記述)は提案の第1項に書いたとおりです。
  • 観点3(AndCodeApplication.kt:584-593):
    • 組み立ては combine(appForeground.foreground, runtimeWork.active) { fg, w -> fg || w }.distinctUntilChanged() で、combine と distinctUntilChanged の import は既存です。
    • runtimeWork の保持元は、"sessions"(activeSessionIds、60秒debounce付き)、スケジュール実行(ScheduleExecutionService:149)、各controllerの install/update、ADB、ターミナル、git-clone、runtime-op です。
    • LocalRuntimeService の wake lock 方針(Installing/Starting/Updating は無条件、Ready は active work 時のみ)とも整合しています。エージェントがMCPを叩き得る典型経路(フォアグラウンドのチャット、バックグラウンドのチャット実行、スケジュール実行)は取りこぼしていません。
    • 唯一の割り切りは、提案の最後の項目に挙げたセッション追跡が外れた場合です。
  • 観点4:
    • GuestBrowserCommandWatcher の呼び出し元は AndCodeApp.kt:710 の1箇所だけで、foreground = app.appForeground.foreground に更新済みです。
    • foreground は ProcessLifecycleAppForeground が保持する val の StateFlow で、同一参照です。LaunchedEffect のキーに入れても再コンポーズで再起動しません。runtimeDirectory の File は equals で比較されるため、既存どおり安定です。
    • ScheduleBridge.run の呼び出し元は AndCodeApplication.kt:592 の1箇所です。テストは pollOnce と新しい run(gate) を使います。リポジトリ全体(--include='*.kt/kts/md')で、ほかの参照元がないことを確認しました。
  • 観点5:
    • pollWhileActive は collectLatest と即時 action と delay の組み合わせで、「false中は何も動かない/true再開で即ポーリング/false化でdelay中のループをキャンセル」という KDoc の記述どおりに動きます。
    • PollWhileActiveTest の4件(閉じたまま・開くと即時+間隔ごと・閉じた直後に停止・再開で即時)、BrowserCommandWatcherTest の1件、ScheduleBridgeTest の run の1件はいずれも、runTest の testScheduler を共有する別の TestScope でループを起動します。advanceTimeBy は境界ちょうどのタスクを実行しないため、runCurrent の併用が正しく、finally の scope.cancel() でリークもありません。
    • BrowserCommandWatcher.kt:45 の withContext(IO) 中にgateが閉じると、消費済みURLが失われる窓があります。ただし KDoc が明示しており、アプリが画面を離れる瞬間のミリ秒単位なので許容と判断しました。
  • 観点6(docs/LOCAL_RUNTIME.md:221): gate条件(前面またはランタイム作業中)、再開時の即時処理、期限切れエラーでの破棄は実装と一致しています。「90秒」は実際には95秒のしきい値です(提案の第1項)。permission bridge とブラウザコマンドのgateを説明している箇所は、docs内にもともとありません。
  • 形式面: 追加行はすべて140桁以内で、import の並びも規約どおり、削除した delay import の取り残しもありません(isActive は reader job で使用中)。ktlint の関数シグネチャ整形も、周辺コードと同じ書き方です。detekt は root の build.gradle.kts にしか適用されておらず、app 側の依存設定にはありません。この差分で新たに失敗する見込みはないと判断しました。
  • 制約: ビルド・テスト・Gradleは実行していません(依頼どおり)。ファイルは一切変更していません。

🤖 Generated with Claude Code

yuga-hashimoto and others added 4 commits October 24, 2026 01:07
Three file polls between the app and the in-guest agent ran for the life of
the process, waking it every few hundred milliseconds whether or not anything
could have been written:

- Claude Code permission bridge: every 250 ms, started by the first session
  and never cancelled, even after every session had ended.
- Schedule bridge: every 800 ms on the application scope.
- Guest browser command watcher: every 1 s from a LaunchedEffect, which keeps
  running while the activity is stopped.

Add Flow<Boolean>.pollWhileActive (poll at once when the gate opens, then every
interval, and stay suspended with no timer while it is closed) and gate each
loop on the condition under which a writer can exist:

- permission bridge: a CLI process is registered. The hook that parks a
  request dies with its process, so with none alive nothing can appear.
- schedule bridge: the app is in the foreground or runtimeWork is active.
  With neither, the device may suspend and the guest freezes with it.
- browser command watcher: the app is in the foreground.

ScheduleBridge.run now requires the gate rather than defaulting to "always" so
a future call site cannot quietly bring the unbounded loop back.

Covered by PollWhileActiveTest plus a gating test each for the schedule bridge
and the browser watcher. Disabling the gate makes exactly those five fail.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Follow-ups from the pre-PR review of the polling gate.

- ScheduleBridge: with the poll parked while the app is backgrounded and idle,
  a request written in that window used to run on the way back in even though
  the guest MCP had already given up after 90 s and told the agent the call
  failed. A create/delete/runNow the agent was told did not happen would then
  happen anyway, and a retry could duplicate it. Requests older than two
  minutes are now answered with an "expired" error and discarded. A missing or
  zero createdAtMs is "unknown" and still runs.
- ClaudeCodeRuntime: route every change to the session map through
  registerSession/unregisterSession, which keep hasLiveSessions in step, so a
  future edit cannot forget to publish and leave permission prompts silently
  unanswered.
- KDoc: say what pollWhileActive does to a suspending action, and that the
  browser watcher's foreground flow must be a stable reference.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Review follow-up. The expiry added for requests that sat unanswered while the
bridge poll was parked was two minutes against the guest's 90 s reply timeout,
which left a 90-120 s window where a request ran after its caller had already
given up. The stated reason for the extra headroom, clock drift, does not
apply: the guest runs under PRoot and reads the same kernel clock as the app.

- REQUEST_EXPIRES_AFTER_MILLIS is now 95 s, with a note to keep it in step
  with RESPONSE_TIMEOUT_S in andcode-schedule-mcp.py.
- Tests pin the boundary and the cases that must still run: a timestamp in the
  future and a request with no timestamp at all.
- docs/LOCAL_RUNTIME.md says when the bridge polls and that stale requests are
  discarded.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@yuga-hashimoto
yuga-hashimoto merged commit f6398e6 into main Oct 24, 2026
4 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