2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AI エージェントの「不寝番」アプリを作ったら、テスト全通過のまま実機で 3 回やらかした話

2
Posted at

AI エージェントが作業している間だけ Mac をスリープさせないメニューバーアプリを Claude Code と一緒に作りました。テスト 70 個はすべてグリーンだったのに、実機に配属した途端、見張り番の二等兵が 3 回やらかしました。その始末書です。

配属の経緯

韓国語の「불침번(プルチムボン)」は、日本語の「不寝番」と同じ漢字です。みんなが眠れるように、一人が夜通し見張る当番のこと。

一方、最近の Mac はよく寝すぎます。

Claude Code に「このリファクタリングやっといて」と頼んで寝ると、朝こうなっています。

  • Mac がスリープして作業が途中で止まっている 😇
  • または caffeinate -d を止め忘れて一晩中画面が点きっぱなし
  • 最悪は sudo pmset disablesleep 1 を戻し忘れてバッテリー 0%

そこで Mac にも不寝番を立てることにしました。ピクセルアートの二等兵が懐中電灯を持ってメニューバーの下を巡回し、エージェントの仕事が終わると「交代します!🫡」と敬礼して Mac を寝かせます。

デモ

「作業中」をどう判定するか

最初は単純でした。Claude Code のプロセスが生きていれば勤務。

結果:永遠に交代しない。 Claude Code のウィンドウを何個も開いたままにする人間には、これでは除隊まで不寝番です。

最終的な判定基準はこうなりました。

シグナル 理由
エージェントに caffeinate の子プロセスがある Claude Code は作業中にだけ自分で caffeinate -i -t 300 を起動する
プロセスツリーの CPU が 60 秒平均で 2% 以上 ビルドやテストなどのツール実行を拾う
--tools= で起動したヘッドレスセッションは除外 claude-mem などのプラグインが常駐させる補助セッションのため

最初は 5 秒ごとの CPU サンプルで判定していましたが、エージェントが立ち上げた監視スクリプトがときどき CPU をちょっとだけ使うせいで、18 分間交代できませんでした。 60 秒平均に変えて解決。二等兵のサボりではなく、測り方の問題でした。

判定ロジックはシステムコールを一切持たない純粋なステートマシン(DutyEngine)に切り出し、偽の時計で「5 分静かなら交代」「バッテリー 20% 以下で撤収」「猛暑なら休憩して自動復帰」といったシナリオテストを 70 個書きました。

オールグリーン。 自信満々で実戦配備です。

やらかし 1:見張りが上官と一緒に帰った

蓋を閉じても起きているには pmset -a disablesleep 1 が必要です。問題は、アプリが強制終了されるとこの設定が残りっぱなしになること。カバンの中で Mac が熱くなるコースです。

そこで管理者権限で pmset を有効にするとき、nohup で見張りスクリプトも一緒に起動しました。アプリが死んだら見張りが設定を元に戻す仕組みです。

実測:管理者シェルが終わった瞬間、見張りも消えました。 見張りに立たせたはずが、上官の退勤と一緒に退勤していたわけです。

osascript の do shell script ... with administrator privileges が終わるときに同じセッションの子プロセスまで片付けられているようでした。nohup は SIGHUP を防ぐだけなので、ここでは生き残れません。

/usr/bin/perl で fork → setsid() で新しいセッションを作って完全に切り離しました。すると今度は A && B & がサブシェルを残して osascript が永遠に終わらない二次災害。最終的に、パイプで「切り離し完了」を受け取ってから親が終了するよう同期しました。

最終実測:アプリを kill -9 しても 3 秒以内にスリープ設定が元に戻ります。

やらかし 2:再起動すると勤務が消える

「アプリの再起動・アップデート後も残り勤務時間を引き継ぐ」機能。テストも通っていました。

実測:再起動すると勤務が消えていました。

原因は拍子抜けするものでした。init で persist() を先に呼んで保存済みの勤務を空の状態で上書きし、そのあとで resume() が走っていたのです。引き継ぎ日誌を、次の当番が読む前にシュレッダーにかけていたようなものです。

やらかし 3:報告が一度も届かない

勤務交代、猛暑注意報、離席中の引き継ぎ…通知をがんばって作りました。

実測:通知が一度も来ませんでした。 エラーもなし。

UNErrorDomain Code=1 "Notifications are not allowed for this application"

macOS は Developer ID(Team ID)で署名されていない ad-hoc 署名のアプリに通知の許可そのものを出しません。 なのに許可リクエストのコールバックはこう書いていました。

center.requestAuthorization(options: [.alert, .sound]) { _, _ in }

{ _, _ in }。失敗をきれいに無視するコードです。二等兵が報告書を一生懸命書いて、そのままゴミ箱に入れていたわけです。

今は許可がなければ osascript の display notification で代わりに送ります。文言はスクリプト文字列に埋め込まず argv で渡して、AppleScript インジェクションの余地をなくしました。

教訓

  • 純粋ロジックのテストは「何をするか」しか検証しない。 「OS が本当にそうしてくれるか」は実機で動かさないとわからない。3 件とも単体テストでは捕まえられませんでした。
  • 失敗を無視するコールバック({ _, _ in })が一番静かで一番危ない。 AI が書いたコードでも、人が書いたコードでも。
  • AI と一緒に作るとき一番効いたのは「それ、実際に確認した?」と聞き続けることでした。

二等兵の現在

  • Claude Code・Codex・Gemini CLI・Cursor Agent・Copilot CLI・Aider・OpenCode が実際に作業しているときだけ勤務
  • 蓋を閉じても勤務可能。アプリが死んでも見張りが設定を復元
  • 熱くなったら 🥵 休憩、冷えたら自動復帰/バッテリー 20% 以下で撤収
  • 10 分以上離席して戻ると「引き継ぎを報告します!📋」
  • hook・デーモン・root ヘルパーを入れない。macOS 13 から動く

hook でより精密に連動する Adrafinil(macOS 26.4 以降)という似たアプリもあります。違いは README にまとめました。

MIT のオープンソースで、日本語 README もあります。まだ一等兵への昇進審査(Apple の公証)前なので、初回だけ「このまま開く」が必要です。

brew install --cask s3onghyun/tap/bulchimbeon

この記事は Zenn にも投稿しています: https://zenn.dev/s3onghyun/articles/1ec9856f1fb32e

以上、不寝番より報告を終わります。🫡

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?