Repository navigation
engine: on macOS a held chat's folder costs one watch handle, not one per file (#2471) - #2551
Merged
Merged
Conversation
… per file (#2471) The presence watch handed each held chat's session folder to fsnotify, whose kqueue backend on macOS opens a descriptor for every file in a watched folder (transcript, meta, lock, presence, card, told files). A long-lived app kept about that many descriptors per chat it had shown. Linux and Windows already use one handle per folder. The watch only needs request files to appear (takeover.json, .conversation-delete-request, .task-delete-request-*), and every writer publishes them by create or rename in that folder. On darwin a kqueue watch on the folder itself now sees those entry changes and lists the folder for requests; a removed or renamed folder is dropped and re-added by the safety pass as before. Other platforms keep fsnotify behind the same small seam. Holds, viewOpen, the idle sweep and the heartbeat clocks are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…jsonl #2478 lets a quiet session skip its doorstep pass for a minute because the watch wakes it when an answer is WRITTEN. deliverAnswer appends to answers.jsonl, and once that file exists an append does not change the folder's entries, so a folder-only kqueue watch missed it. The darwin watcher now also watches answers.jsonl while it is there: a write reports the answer, an empty just-created file reports nothing (the line comes after the create), and a drain's rename gives the handle back. A held chat costs its folder handle plus one for an answers file only while an answer waits. TestPresenceDoorC6 follows #2478's contract: a watcher error now knocks every session once as well as running the safety pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AbirAbbas
force-pushed
the
fix/2471-darwin-folder-watch
branch
from
November 9, 2026 22:48
52ad985 to
47c07cb
Compare
os.ReadDir("/dev/fd") stats every entry and fails with EBADF inside a test
binary on macOS 15.6, so TestDarwinPresenceDoorC1 failed before any watch was
made. Readdirnames only counts names (found by the Mac proof run).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes most of #2471. On macOS a held chat's session folder now costs one kernel watch handle instead of one per file in it.
Cause
internal/session/presence_watch.gogives each held chat's session folder to fsnotify. On macOS, fsnotify's kqueue backend opens a descriptor for every file in a watched folder so that it can report writes to each one: transcript, meta, lock, presence, card and told files. A long-lived app keeps that many descriptors for every chat it has shown. On Linux (inotify) and Windows it is one handle per folder.Change
The watch only needs request files to appear:
takeover.json,.conversation-delete-requestand.task-delete-request-*. Every writer publishes them by creating a file or renaming one into that folder (AskTakeover,writePresenceRequest).presence_watch_darwin.go): one kqueue, oneO_EVTONLYhandle per folder,EVFILT_VNODEwithNOTE_WRITE|NOTE_DELETE|NOTE_RENAME|NOTE_REVOKE. When the folder's entries change, it lists the folder and reports the request names present. A removed or renamed folder is dropped, and the existing safety pass adds it again. The reader blocks with no timeout;CloseandRemovewake it with anEVFILT_USERevent, so an idle engine gains no wakeups.presenceDoorstepEvent).deliverAnswerappends toanswers.jsonl, and once that file exists an append doesn't change the folder's entries. So the darwin watcher also watches that one file while it is there:presence_watch_fsnotify.go): fsnotify exactly as before, behind a smalldoorWatcherseam. The loop, safety pass and clocks are shared.viewOpen, the idle sweep, heartbeat and refresh cadence. (Let idle desktop chat views beyond the three most recent go #2472 changed holds and broke live turns started by another process, so it was reverted in Revert: let idle desktop chat views beyond the three most recent go #2473.)conversation_delete.go,task_delete.go,takeover.go).Contract → tests
TestDarwinPresenceDoorC1OneHandlePerFolderTestDarwinPresenceDoorC2PublishedRequestsWakeByEvent,TestPresenceDoorC2C3RoutesRequestEventsTestDarwinPresenceDoorC4ReplacedFolderIsWatchedAgain,TestPresenceDoorC4SafetyPassReaddsDroppedFoldersTestDarwinPresenceDoorC5ClosesHandlesAndRestarts,…CloseWithoutDrainingEvents,TestPresenceDoorC5CancelClosesOnceAndCanRestartTestPresenceDoorC6ErrorsRunBoundedSafetyPassAndKnockTestPresenceDoorC7KeepsFsnotifyDeliveryTestPresenceDoorC8IgnoresOrdinaryFileEventsTestDarwinPresenceDoorC9C10AnswersWakeAndReleaseTheirHandleThe
TestDarwin…tests only run on a Mac, and CI here doesn't run tests on macOS.Checks
Compile gates, with
GOFLAGS=-buildvcs=false:gofmt -l internal/session(empty),go vet ./internal/session/(linux and darwin/arm64),go test -c ./internal/session(linux and darwin/arm64),GOOS=windows go build ./internal/session. All clean.Linux, run locally: the 6
TestPresenceDoor*tests plus Factory: any-source intake, router and AI filter (draft, e2e first) #2478's quiet and idle presence tests (presence_quiet_test.go,presence_idle_test.go), 15 tests, all pass.codeaf-changes checkis clean.Mac proof (macOS 15.6, Apple Silicon;
codeaf appdriven over its wire in a throwaway HOME; first run at 52ad985 vs its base 32747af). The change since then: the rebase onto Factory: any-source intake, router and AI filter (draft, e2e first) #2478, the answers.jsonl watch, and a test-helper fix. Re-run at the current head 1cfcceb: see below.lsof -p | wc -l)meta.json,meta.lock, a second read-onlytranscript.jsonlandpresence.json. The last four are fsnotify's per-file kqueue handles. On head: the journal's write handle and the folder. kqueue stays at 3 on both, so there is one watcher in total, not one per chat.AskTakeoverdoes into a held chat (3 runs each): base takes it off disk in 3–4 ms and reacts on the wire in 1–2 ms; head takes 3–4 ms and 1–3 ms.C1failed inside its own fd-count helper (os.ReadDir("/dev/fd")gets EBADF on macOS). The helper now counts names, and with that all 12 passed (3/3).Mac re-run at 1cfcceb (after the rebase onto Factory: any-source intake, router and AI filter (draft, e2e first) #2478 and the answers.jsonl watch):
PresenceDoortests, C9/C10, and Factory: any-source intake, router and AI filter (draft, e2e first) #2478's quiet, idle and watcher-delivery presence tests.What stays per chat
Files a held chat keeps open for its own use stay open while it is held. Releasing them needs a "view closed" call from the desk, a separate two-repo change.
agents.closecan't serve, because it is the person's Close. The Mac lsof listing will name which files remain.🤖 Generated with Claude Code