Skip to content

feat(#338): config dir 起動時不在でも祖先 watch で自動リカバリ#339

Merged
GeneralD merged 7 commits into
mainfrom
feat/338-ancestor-watch
Jul 17, 2026
Merged

feat(#338): config dir 起動時不在でも祖先 watch で自動リカバリ#339
GeneralD merged 7 commits into
mainfrom
feat/338-ancestor-watch

Conversation

@GeneralD

@GeneralD GeneralD commented Jul 17, 2026

Copy link
Copy Markdown
Owner

agent type scope complexity version

Closes #338

概要

config ディレクトリ($XDG_CONFIG_HOME/lyra など)が daemon 起動時に存在しない場合、これまでは watch を張る fd が無く watchChanges が nil を返すため、後からディレクトリと config を作成しても daemon の再起動が必要だった(#329 で明示的にスコープ外とした制約、#337 Round 4 レビューで再指摘)。

本 PR は「祖先 watch + 段階的 re-arm」でこの制約を撤廃する。

設計

ConfigWatchSession の directory tier を 2 状態にした(新規 tier は追加せず、既存の re-arm 機構に統合):

  • target モード — 従来どおり config dir を watch。イベント毎に file tier を re-arm
  • ancestor park モード — config dir を watch できないときは、directoryAncestors(純関数)で得た祖先チェーン(例: ~/.config~/)を近い順に試し、最初に arm できた祖先へ park

祖先イベントの扱いが issue の検討事項「頻度が高いのでフィルタ必須」への回答:

  • 祖先イベント毎に directory tier を再解決し、パスが出現した分だけ watch を下位へ張り替えるmkdir -p の一括作成は一発で config dir まで到達)
  • config dir 本体への昇格が成立したときだけ file tier を arm して onChange を発火(= initial load)。祖先ノイズ($HOME 直下の変化など)は syscall 数回で終わり、decode/reload は一切走らない
  • 昇格 ping 自体は上流 ConfigInteractor の 150ms debounce が他イベントと同様に吸収するため、この層に追加の debounce は不要

おまけの対称性として、稼働中に config dir が削除された場合も同機構で祖先へ降格 park する(従来は dead fd のまま沈黙し再起動が必要だった)。再作成されれば昇格して復帰する。

実装メモ

  • 祖先探索は lazy.compactMap { … }.first だと lazy collection の transform 二重評価startIndex 計算時と subscript 時)で watch が重複 arm され fd がリークするため、素直なループで実装(コメントに理由を明記)
  • FakeWatchGateway を live gateway と同じ「ディレクトリが実在するときだけ arm 成功」に変更し、cancel 済み handler は発火しない live handler 管理へ(cancelled DispatchSource はイベントを配送しないことのミラー)
  • armed() が nil を返すのは祖先チェーン全滅時のみ(live では / が常に存在するため実質発生しない)

テスト

  • 祖先 park / 昇格 + initial load / ノイズフィルタ / 中間ディレクトリのみ出現時の walk-down / 稼働中削除→降格→復帰 / directoryAncestors 純関数(fake gateway 駆動)
  • 実 FS + 実 FileWatchGateway で「config dir を後から作成 → 昇格 → onChange 発火 → in-place 編集も file tier で検知」の E2E
  • 全 1355 テスト green、make lint クリーン

ドキュメント

  • docs/ARCHITECTURE.md / AGENTS.md の「provided the config directory itself exists at start」制約記述を新契約に更新
  • ConfigWatchSession / ConfigDataSource.watchChanges の doc コメント更新

バージョン

feat のため minor bump: 2.27.2 → 2.28.0

Summary by CodeRabbit

  • Bug Fixes

    • Improved configuration hot reload when configuration folders are missing, deleted, recreated, or replaced during saves.
    • Configuration changes are now detected more reliably after directory moves, renames, and atomic file updates.
    • Added recovery when configuration locations become available after application startup.
  • Documentation

    • Clarified configuration hot-reload behavior and recovery scenarios in the project documentation.
  • Release

    • Updated the application version to 2.28.0.

GeneralD added 4 commits July 17, 2026 20:29
ConfigWatchSession の directory tier を 2 状態化: config dir を watch
できないときは最も近い実在祖先に park し、祖先イベント毎に再解決して
パス出現に合わせて watch を下位へ張り替える。config dir 本体への昇格が
成立したときだけ file tier を arm して onChange(initial load)を発火
するため、祖先ノイズ($HOME 直下の変化など)は syscall 数回で済み
decode/reload は走らない。稼働中の config dir 削除も同機構で祖先へ
降格 park し、再作成で復帰する。

FakeWatchGateway は live gateway と同じく「ディレクトリが実在するときだけ
arm 成功」とし、cancel 済み handler は発火しないよう live handler 管理に
変更。祖先探索は lazy.compactMap.first だと lazy collection の transform
二重評価で fd が重複 arm されるため、素直なループで実装。
#329 時点の「config dir が起動時に存在する場合に限る」制約記述を、
祖先 park → 段階的張り替え → 昇格時 initial load / 削除時降格の
新契約に更新。
Copilot AI review requested due to automatic review settings July 17, 2026 11:30
@GeneralD GeneralD self-assigned this Jul 17, 2026
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@GeneralD, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 40 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 832c4a76-f673-4a72-beef-af135dfecc65

📥 Commits

Reviewing files that changed from the base of the PR and between b47d456 and b997fbf.

📒 Files selected for processing (3)
  • Sources/ConfigDataSource/ConfigWatch.swift
  • Tests/ConfigDataSourceTests/ConfigWatchTests.swift
  • docs/ARCHITECTURE.md
📝 Walkthrough

Walkthrough

Config hot-reload watching now parks on existing ancestors when the config directory is absent, promotes or demotes directory watches as paths change, and re-arms directory and file tiers on every event. Tests cover inode replacement, lifecycle transitions, and live edits. Documentation and the version are updated.

Changes

Config watch recovery

Layer / File(s) Summary
Directory parking and tier re-arming
Sources/ConfigDataSource/ConfigWatch.swift, Sources/Domain/DataSource/ConfigDataSource.swift, AGENTS.md, docs/ARCHITECTURE.md, Sources/VersionHandler/Resources/version.txt
ConfigWatchSession parks on the nearest watchable ancestor, promotes to the config directory when available, demotes after deletion, re-arms both watch tiers, and documents the updated behavior. The version changes to 2.28.0.
Lifecycle and vnode-aware test coverage
Tests/ConfigDataSourceTests/ConfigWatchTests.swift
Tests cover ancestor parking, promotion, demotion, directory recreation, unrelated events, live edits, inode-aware directory watches, and separate file/directory stop counts.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ConfigWatchSession
  participant AncestorDirectory
  participant ConfigDirectory
  participant FileWatchGateway
  ConfigWatchSession->>FileWatchGateway: armDirectoryTierLocked()
  FileWatchGateway-->>ConfigWatchSession: watch nearest ancestor
  AncestorDirectory->>ConfigWatchSession: ancestorChanged()
  ConfigWatchSession->>FileWatchGateway: resolve and watch config directory
  ConfigWatchSession->>FileWatchGateway: re-arm file tier
  ConfigWatchSession-->>ConfigDirectory: onChange()
Loading

Possibly related PRs

  • GeneralD/lyra#337: Introduced the preceding two-tier config hot-reload watch implementation.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the missing-config-dir ancestor watch auto-recovery change for #338.
Linked Issues check ✅ Passed The implementation adds ancestor parking, promotion/demotion, initial load, noise suppression, and recovery after deletion, matching #338.
Out of Scope Changes check ✅ Passed The changes stay within the config-watch recovery work and the related docs, tests, and version bump listed in the objectives.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/338-ancestor-watch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends Lyra’s config hot-reload watch pipeline so that if the config directory (e.g. $XDG_CONFIG_HOME/lyra) is missing at daemon start, the watcher parks on the nearest existing ancestor directory and automatically “walks down” / promotes once the config directory appears (and demotes/re-recovers if it’s deleted while running).

Changes:

  • Implement ancestor-parking + promotion/demotion logic in ConfigWatchSession (including a pure directoryAncestors(of:) helper).
  • Add unit + live-FS E2E tests covering ancestor park/promote/noise filtering and recovery after deletion.
  • Update docs/contracts and bump version to 2.28.0.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
Tests/ConfigDataSourceTests/ConfigWatchTests.swift Adds #338 regression tests and updates the fake watch gateway behavior used by tests.
Sources/ConfigDataSource/ConfigWatch.swift Implements ancestor park/promote/demote watch-tier behavior and adds directoryAncestors(of:).
Sources/Domain/DataSource/ConfigDataSource.swift Updates watchChanges contract docs to reflect ancestor parking behavior.
Sources/VersionHandler/Resources/version.txt Minor version bump to 2.28.0.
docs/ARCHITECTURE.md Updates hot-reload architecture docs to reflect new ancestor parking behavior.
AGENTS.md Updates repository-level contract summary for config watch behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +360 to +363
let handle = FileHandle(forWritingAtPath: lyraDir + "/config.toml")
try handle?.seekToEnd()
try handle?.write(contentsOf: Data("\n# edited\n".utf8))
try handle?.close()

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agent action

ba6f82f修正しました。try #require(FileHandle(forWritingAtPath:))変更し、open 失敗テスト前提条件の失敗として即座に顕在化するようにしています(期待値タイムアウトに化けて原因が読めなくなるのを防止)。同パターンだった既存テスト liveGatewayObservesIncludeEditsAndSurvivesAtomicSave の 2 箇所も同時に統一しました。なお同内容の Suggested changeset は、適用範囲を広げたローカル編集で取り込んだため widget 側は outdated になっています。

Comment on lines 471 to 475
func watch(file: String, onChange: @escaping @Sendable () -> Void) -> (any ConfigWatchToken)? {
lock.withLock {
_watchedFiles.append(file)
return FakeToken { [weak self] in self?.recordStop() }
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agent action

これは意図的な非対称です。directory watch の成否は ConfigWatchSession の制御フロー(祖先 park / 昇格の分岐)に直結するため、この PR で fake を実在ベースに変えました。一方 file watch の成否に session が分岐することはなく、失敗した arm は compactMap で落ちるだけです。fake が「要求されたターゲット」を記録するのはテストがターゲット解決の契約 — 欠損 include も watch 要求され続けること(keepsMissingIncludesInWatchTargets)— を検証するためで、実在ベースにするとこの契約自体が観測できなくなります。実際の arm セマンティクス(missing file → nil)は実 FileWatchGateway を使う live E2E テスト側でカバーしています。誤解を防ぐため、この意図watch(file:) の doc コメントとして ba6f82f で明文化しました。

@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…tch 契約をコメント明文化

Copilot 指摘: FileHandle(forWritingAtPath:) の optional chaining は open
失敗時に編集が黙って skip され、失敗が期待値タイムアウトとして現れて
原因が読めない。try #require で前提条件の失敗として即座に落とす
(新規テスト 1 箇所 + 同パターンの既存 2 箇所も統一)。

FakeWatchGateway.watch(file:) が常に成功するのは意図的(session は
file-arm 成否で分岐せず、テストはターゲット解決を検証する)である旨を
doc コメントに明文化。

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9554b95614

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +181 to +182
guard ancestorPath == nil, (try? Folder(path: dataSource.configDir)) == nil else { return }
armDirectoryTierLocked()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-arm after a delete/recreate even when the path exists

When the watched config directory is removed and recreated before the dispatch handler runs, this existence check succeeds for the new directory and skips armDirectoryTierLocked(), leaving directoryToken attached to the deleted vnode. If the recreated directory is initially empty, rearmFilesLocked() also has no file tier to install, so a later config.toml creation in the new directory is never observed until some unrelated restart/re-arm occurs, which breaks the intended recovery for directories that come and go.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agent action

バグでした。b47d456 で修正しています。指摘のシナリオを退行テストとして書いたところ、修正前に赤・修正後に緑になることを実測で確認できました。

修正の方針は、存在チェックを inode 比較へ精密化するのではなく、チェック自体の撤廃です。changed() で directory tier も file tier と同じく毎イベント無条件に re-arm します。理由は 2 つあります。パス存在チェックでは inode の置換原理的に検知できず、精密化しても「チェックが十分か」を考え続けることになること。そして再解決のコストが open(2) 一回で、file tier が既に全 fd を張り直している以上ほぼ無視できることです。結果として「全 tier が毎イベント disk から再解決する」という一様な不変条件に収束し、reparkDirectoryTierIfGoneLocked不要になったので削除しました。

再 arm と reload の順序(directory → files → onChange、いずれも disk を読み直す)により、fd 張り替えの隙間に落ちた作成もその後の disk 読みが拾うため、無条件化による取りこぼしはありません。

テスト側も併せて直しました。FakeWatchGateway が arm 時の inode を pin し、「削除された watch は最後の .delete を配送した後は二度と発火しない」という実 vnode の挙動再現します。これが無いと re-arm 漏れがテストから観測できず、まさにこの指摘を取りこぼしていました。

Codex レビュー指摘の実バグ。config dir が削除→即再作成されると、
ハンドラ実行時にはパスが再び存在するため存在チェックが通り、
reparkDirectoryTierIfGoneLocked が re-arm を skip していた。fd は削除済み
vnode を掴んだままなので二度と発火せず、新ディレクトリに後から置かれる
config.toml が永久に観測されない(#338 が意図した復帰が壊れる)。

修正は staleness チェックの精密化ではなく撤廃。changed() で directory tier
も file tier と同じく毎イベント無条件に re-arm する。パス存在チェックでは
inode 置換を原理的に検知できず、チェックこそが間違えどころ。再解決コストは
open(2) 一回で、file tier が既に全 fd を張り直している以上ほぼ無視できる。
結果として「全 tier が毎イベント disk から再解決する」という一様な不変条件に
収束し、reparkDirectoryTierIfGoneLocked は不要になり削除。

テスト: FakeWatchGateway を実 vnode セマンティクスに寄せ、arm 時の inode を
pin して「削除された watch は最後の .delete を配送した後は二度と発火しない」
を再現。これにより re-arm 漏れが観測可能になり、退行テストが修正前に赤・
修正後に緑となることを実測で確認。stop カウンタは file/directory に分離。

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/ConfigDataSource/ConfigWatch.swift`:
- Around line 158-161: Update armDirectoryTierLocked to preserve the existing
directoryToken and ancestorPath until a replacement watch is successfully
created; only stop and clear the active watch after securing the replacement. If
gateway.watch returns nil, retain the current token or schedule a retry, while
ensuring directory watch tiers are re-armed from disk after each event.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ae04bbdf-d342-4dd0-a546-4ec6e0a99e51

📥 Commits

Reviewing files that changed from the base of the PR and between c224165 and b47d456.

📒 Files selected for processing (6)
  • AGENTS.md
  • Sources/ConfigDataSource/ConfigWatch.swift
  • Sources/Domain/DataSource/ConfigDataSource.swift
  • Sources/VersionHandler/Resources/version.txt
  • Tests/ConfigDataSourceTests/ConfigWatchTests.swift
  • docs/ARCHITECTURE.md

Comment thread Sources/ConfigDataSource/ConfigWatch.swift Outdated
CodeRabbit レビュー指摘。armDirectoryTierLocked が「stop してから open」の
順だったため、再 arm 時に open(2) が全滅すると(fd 枯渇 — 祖先チェーンが
行き着く / すら開けない状況)directoryToken が nil のまま残り、以後どの
イベントも届かないので再試行の契機すら失われ、daemon 再起動まで恒久的に
無反応になる。#338 が消そうとした状態そのもの。

しかも直前の「毎イベント無条件 re-arm」化により、この窓は dir 消失時のみ
から全 config 編集へ広がっていた。露出を持ち込んだのはこちらの変更なので
併せて塞ぐ。

freshDirectoryTierLocked() を state 非破壊の純粋な取得に切り出し、成功時
のみ stop → swap する stage-and-swap へ。全滅時は現行 token を維持する:
生きている可能性があり、少なくとも nil より悪くはならない。retry timer は
不要 — 生きた watch が残っていればイベント毎に arm が自然に再試行される。
@GeneralD
GeneralD merged commit 610e323 into main Jul 17, 2026
4 checks passed
@GeneralD
GeneralD deleted the feat/338-ancestor-watch branch July 17, 2026 12:33
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.

config ディレクトリが起動時に存在しない場合の自動リカバリ(祖先ディレクトリ watch)

2 participants