Repository navigation
fix: skill folders created after Gateway start go unwatched on Linux with Node 24 - #178095
Conversation
Node 24's Linux recursive fs.watch fallback inverts its throwIfNoEntry check and swallows setup errors (nodejs/node#65635), so watching a missing or unreadable root returns a watch that never reports instead of throwing. observeOwnedDirectory only falls back to the nearest existing ancestor on a synchronous throw, so a skills folder created after Gateway start kept that inert handle and was only refreshed by the 30-minute safety pass. watchDirectory now admits recursive roots before fs.watch, matching the admission its macOS helper path already performs. Node also builds Linux recursive watches from nested public fs.watch calls, which made the owned-directory sharing tests count Node internals. Recursive cases now stub the native handle the way Node 24 behaves, so counts cover only the observer's registrations and the missing-ancestor case fails on the original defect on every runtime. Proof: Linux Testbox, Node 24.19.0 and 26.12.0, fresh process per run: main 10/10 red, new tests with the old adapter 4/4 red, fix 10/10 green.
|
🦞👀 Pull request received. I will update this pull request when review starts. ClawSweeper review completeClawSweeper finished reviewing this revision. The review result is being finalized. |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Codex review: needs changes before merge. What this changesThis PR checks recursive watch roots before registration and makes observer tests independent of Node’s internal watch calls. Example: On Linux Node 24, create /home/alex/workspace/skills after the Gateway starts.
Review scores
ProductKind: Bug fix · Worth it: Yes · Fix scope: Complete Regression provenancePossible regression — suspected (reviewed change). No predecessor PR is attributed. Merge readiness⛔ Needs changes before merge - 1 item remains Keep open: this PR repairs a current-main watcher admission bug with a focused change, and review found no actionable code defect. Priority: P2 Before merge
FindingsNone. Tests
Agent review detailsHow this fits togetherSkills discovery subscribes to the shared directory observer, which turns native filesystem events into invalidations for the existing discovery worker. flowchart LR
A[Skill directory changes] --> B[Native watch adapter]
B --> C[Shared directory observer]
C --> D[Skills refresh scheduler]
D --> E[Discovery worker]
E --> F[Published skill snapshot]
Technical reviewBest possible solution: Keep synchronous recursive-root admission in the existing adapter while preserving ancestor fallback, native invalidation, and safety reconciliation. Do we have a high-confidence way to reproduce the issue? The observer’s throw-dependent ancestor fallback and Node’s confirmed swallowed-error defect establish the failure path; no target code was executed during this review. Is this the best way to solve the issue? The patch repairs admission at the shared adapter, and the test changes isolate OpenClaw registrations without adding production scaffolding or duplicate policy. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning medium; reviewed against eb54f49633be. Provenance checked
TestingProof path: unit tests only. SecurityNone. EvidenceWhat I checked:
PR surfaceSource +6, Tests +21. Total +27 across 2 files. Added test files: 0. View PR surface stats
LabelsLabel changes:
Label justifications:
Rating scale6/6 🦀 challenger crab · 5/6 🦞 diamond lobster · 4/6 🐚 platinum hermit · 3/6 🦐 gold shrimp · 2/6 🦪 silver shellfish · 1/6 🧂 unranked krab. Overall follows the weaker of proof and patch quality; ✨ marks media proof (a screenshot, video, or linked artifact) that directly shows the changed behavior. WorkflowClawSweeper edits this one comment on every review. Comment Reviewed October 20, 2026, 1:39 AM ET / 05:39 UTC. |
…with Node 24 (openclaw#178095) * fix(infra): admit missing recursive watch roots on Linux Node 24 Node 24's Linux recursive fs.watch fallback inverts its throwIfNoEntry check and swallows setup errors (nodejs/node#65635), so watching a missing or unreadable root returns a watch that never reports instead of throwing. observeOwnedDirectory only falls back to the nearest existing ancestor on a synchronous throw, so a skills folder created after Gateway start kept that inert handle and was only refreshed by the 30-minute safety pass. watchDirectory now admits recursive roots before fs.watch, matching the admission its macOS helper path already performs. Node also builds Linux recursive watches from nested public fs.watch calls, which made the owned-directory sharing tests count Node internals. Recursive cases now stub the native handle the way Node 24 behaves, so counts cover only the observer's registrations and the missing-ancestor case fails on the original defect on every runtime. Proof: Linux Testbox, Node 24.19.0 and 26.12.0, fresh process per run: main 10/10 red, new tests with the old adapter 4/4 red, fix 10/10 green. * test(infra): capture the native fs.watch without an unbound method

What Problem This Solves
Fixes: on Linux under Node 24, a skills folder that does not exist when the Gateway starts (for example a new
<workspace>/skills) never gets a working watch, so creating it and editing skills inside it is only picked up by the 30-minute safety refresh.The same defect made
src/infra/owned-directory-observation.test.tsfail every Linux Node run (reported as a flake from a Crabbox AWS gate at762750c0). It is deterministic; daily CI runs Linux tests on Bun, so only full validation's Node arm sees it.User Impact
User impact: on Linux with Node 24, skill folders created after the Gateway starts are watched natively again, so new and edited skills refresh within the normal debounce instead of up to 30 minutes later. No config or behavior change on macOS, Windows, Bun, or Node 26.
Why This Change Was Made
observeOwnedDirectorydepends on a native watch of a missing directory throwing synchronously, so it can watch the nearest existing ancestor and re-attach when the directory appears. Node 24's Linux recursivefs.watchfallback (Linux has no native recursive inotify) inverts itsthrowIfNoEntrycheck and returns a watch that never reports for a missing root; upstream fixed it in nodejs/node#65635, which also notes that other setup errors such as EACCES were swallowed, and Node 26.12 throws as expected. The observer then keeps that inert handle forever: while the directory is missing the recovery pass re-opens another inert handle, and once it exists the handle is "healthy" by every check.watchDirectoryalready owns synchronous admission for the macOS helper path. Its in-process branch now admits recursive roots the same way (fs.accessSync(root, R_OK), which also rejects an unreadable root) before callingfs.watch.The test file had a second, test-only defect: two sharing cases counted
fs.watchcalls to prove one native registration, but Node builds a Linux recursive watch from nestedfs.watchcalls of its own (1 + one per directory, plus one per file on Node 24), so the spy saw 2+ calls for one OpenClaw registration. The recursive cases now stub the recursive native handle the way Node 24 behaves (a watch that never reports, even for a missing root). The spy counts only the observer's registrations on every runtime, and the missing-ancestor case fails on the original defect everywhere instead of only on Linux Node 24.Accepted edge cases: a root deleted between admission and
fs.watchon Node 24 Linux still gets an inert handle until a later rename or recovery pass re-admits it; Node 24's fallback also swallows ENOSPC (inotify limit). Both are rare and covered by the existing safety refresh; the access check goes away with Node 24.Evidence
Reproduced first, on Linux. The original gate evidence (Crabbox AWS, source
762750c0) holds exactly one Node 24.19.0 run of this file, and that run failed. The gate then stopped, so there were no further Node iterations; Bun passed. On currentmain(a860374e) the failure is deterministic.Linux Blacksmith Testbox through
scripts/crabbox-wrapper.mjs, focused profile (ci-check-focused-testbox.yml, 2 vCPU, leasetbx_01m5bvnbrgb1t6btq8fvm2kdhm, run). Each iteration ranOPENCLAW_VITEST_RUNTIME=node OPENCLAW_VITEST_FS_MODULE_CACHE=0 node scripts/test-projects.mts src/infra/owned-directory-observation.test.ts src/infra/directory-watch-host.test.ts --maxWorkers=1 --no-cache, as a fresh process per iteration:mainwatchcalled 2 times, called 5 times, ancestor path mismatchmainadapterStandalone probe on the same Linux box (spy-equivalent wrapper around
fs.watch):Local macOS: the adjusted missing-ancestor test fails against the
mainadapter under both Node and Bun (Received: .../owned-directory-ancestor-*/agents/work, the same shape as the CI failure) and passes with the fix.oxlintandoxfmtare clean on both files, andtsgo -p test/tsconfig/tsconfig.core.test.infra.jsonpasses.Test cost (
pnpm test src/infra/owned-directory-observation.test.ts --maxWorkers=1): Vitest duration 6.48s on Bun and 9.78s on Node, about 9–13% of it tests and the rest mostly transform, measured on a heavily loaded Mac (load average about 38). On the idle 2-vCPU Linux box, this file plusdirectory-watch-host.test.tstook 2.1–3.0s per run, against 1.9–2.5s onmain. No new timers, sleeps or subprocesses. CI seconds will follow from PR CI.Not covered: the macOS helper-forwarding case in
directory-watch-host.test.tstimes out locally on this Mac while fseventsd is saturated (unchanged path; it passes 10/10 on Linux in the table above). Windows is unchanged by design: the admission check isfs.accessSync(R_OK), which is safe for directories there, and the native recursive watch already throws on Windows.Review: Codex autoreview of the local diff (P0–P2) came back scoped-clean. Its caveat is accurate: with the stub, the sharing cases count registrations and don't exercise native recursive event delivery. They never did, because they already invoked the registered listener directly.