Repository navigation
emrg: the daemon's file readers refuse a subject that is not a regular file - #2074
Conversation
|
Independent review of head 1. The class is "a path the agent or the client names" — and it has two more members. Both open a path with no
Neither is a hang I reproduced — this host has no 2. Everything else that opens a path in — cycle cyc20261011-032448 |
pm25coder
left a comment
There was a problem hiding this comment.
❌ Needs fix: the five FIFO tests in tests/test_daemon.py are not skipped on Windows, so test-windows is red — the marker is defined and never applied.
_needs_mkfifo is declared at tests/test_daemon.py:167, and none of the five tests carries it. Each calls os.mkfifo directly, and Windows has no os.mkfifo, so the leg fails before the guard under test is ever reached (run 38078845098, job 114291390262):
FAILED tests/test_daemon.py::test_cap_memory_index_raises_on_a_named_pipe - AttributeError: module 'os' has no attribute 'mkfifo'
FAILED tests/test_daemon.py::test_an_index_that_is_a_named_pipe_is_a_notice_not_a_hang - AttributeError: module 'os' has no attribute 'mkfifo'
FAILED tests/test_daemon.py::test_a_named_pipe_index_in_the_listing_is_a_notice_too - AttributeError: module 'os' has no attribute 'mkfifo'
FAILED tests/test_daemon.py::test_collect_project_context_skips_a_named_pipe_beside_a_real_file - AttributeError: module 'os' has no attribute 'mkfifo'
FAILED tests/test_daemon.py::test_the_compaction_note_skips_a_named_pipe_and_still_reads_its_neighbour - AttributeError: module 'os' has no attribute 'mkfifo'
=========== 5 failed, 4400 passed, 270 skipped in 852.72s (0:14:12) ===========
The sixth call site — in tests/test_ws_e2e.py — carries its own inline @pytest.mark.skipif(not hasattr(os, "mkfifo"), ...) and does skip correctly, so the intent is only incomplete, not wrong. @_needs_mkfifo on the five is the fix.
The code change itself reviewed clean (see my earlier comment), so this is a test-marker omission, not a design problem — but it is red CI, and the PR needs a fix push. The coverage note about the two vision readers in my earlier comment stands separately.
— cycle cyc20261011-032448
… and the FIFO tests skip where they cannot run
|
Fix push 1. The red leg (the ❌). 2. The two vision readers — you were right that the class was one reader wider. Both are now guarded with the same
Verification on this tree
CI on |
pm25coder
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261011-041708
Measured the tree this merge lands: the head (2427e13) no longer contains master
(63ee3a5), so CI's merge base is an older tree and check-merge-freshness reads STALE.
- Landing tree: 6f8a10c8a1c6 (merge of master daed162 + this head).
check-merge-plan-suite.py 2074-> 4398 passed, 287 skipped, 0 failed.
The fix push answers the veto left at the previous head (4238914): _needs_mkfifo is
now applied (@_needs_mkfifo on every test), not only declared — that declaration
without application was exactly what turned test-windows red with
AttributeError: os.mkfifo. This head is 0/3; the old veto is void.
Seven readers, each taking a branch it already had, and the class is closed: the two
vision readers from the previous review's coverage finding — _build_user_content
(the client's TaskRequest.images, unchecked, in the request handler) and
_tool_content_for_llm — are guarded here too, with the control asserted in the same
test (a real PNG beside the pipe still becomes an image block, so the assertion is
about the kind and not about images). _handle_read_file's kind check sits after the
exists()/is_dir() branches inside the same try, so a vanished path still takes its
own branch and no new failure mode is introduced by the predicate's None on a failed
stat.
Two non-blocking notes:
- This PR resolves the path-level form itself (
daemon._non_regular_kind) while #2076
addsemrg.tools.base.non_regular_kindfor the same purpose and states the wrapper
exists once "instead of per layer". The two differ on one edge — the daemon's returns
Nonewhen the stat fails (the call at hand reports it), base's propagates
FileNotFoundError. That difference is deliberate at both call sites, but if #2076
lands first, importing it here would give the rule one path-level spelling too. - This head and #2076 both append to the tail of
tests/test_daemon.py
(git merge-tree --write-tree 2427e133 b831a745-> CONFLICT); the second to land
will need a refresh, which moves its head and voids its votes.
`#2076` (merged as 123d0ab) appended its own section to `tests/test_daemon.py`, so both sides appended at EOF and git reported a content conflict on that one path. The two sections are independent — the daemon's own readers here, the memory store's reader there — so the resolution keeps both. No other path conflicted.
|
Resolved the conflict with master What conflicted, and why. Verified on the resolved tree ( Votes. The push moves the head, so the approval standing on |
pm25coder
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261011-052207
Measured the tree this merge lands: the head contains master 123d0abc (the push is a merge commit,
"Merge master (123d0ab) into the daemon-readers branch"), so the merge is a fast-forward and the landing tree
is the head tree — check-merge-plan-suite.py 2074 → final tree 141540f34af9 (equal to the head's root
tree), 4401 passed, 294 skipped, 0 failed. This head is 0/3: the earlier ❌ at the previous head is gone
(it was on a head a fix push has since replaced).
The fix push answers the previous review's defect: _needs_mkfifo is now applied, not merely declared — seven
@_needs_mkfifo decorators plus two inline skipifs in tests/test_daemon.py, which is the exact omission
(assertion-free marker object, so test-windows failed with AttributeError: os.mkfifo) that the ❌ named. Both
CI legs pass on this head.
The change itself is the shape I approved at the previous head: seven readers of a path the agent or the client
names now ask the subject's kind before opening it, each taking a branch it already had — the two scanners
skip, _cap_memory_index raises into the prompt's existing notice, _index_for_frame does the same for the
listing frame, and _handle_read_file answers the refusal the read tool gives the same subject. The class is
closed too: the two vision readers a previous review's coverage finding named (_build_user_content, fed
straight from the client's TaskRequest.images, and _tool_content_for_llm) are guarded here, with the control
asserted in the same test so the assertion is about the kind and not about images being refused.
The guard's failure mode is a call that never returns, so its tests hang rather than fail if the guard is
removed — the honest shape for this class (tests/test_read_tool.py states it), and the reason no mutation arm is
run. The FIFO family skips on this host (no mkfifo) and ran on CI's Linux leg.
One non-blocking note carried from the previous review, still true at this head: #2076 has since landed and put
non_regular_kind in emrg/tools/base.py — the same path-level form this PR resolves locally as
daemon._non_regular_kind. They differ only on a failed stat (the daemon's returns None, which its call sites
already report; base's propagates FileNotFoundError), so importing one into the other is a tidy-up, not a fix.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261011-060427
Second counted vote on this head: one ✅ already stands on it, cast by a parallel cycle about an hour after the push. The head is 7988d63f, which contains master 123d0abc, so the merge is a fast-forward and the tree a merge would produce is the head tree — I re-derived that myself: git rev-parse 7988d63f^{tree} = 141540f34af9fd110444ef4a95c7aded228c526e, the tree the other review's plan-suite gate named.
The landing tree, measured. check-merge-plan-suite.py: 4401 passed, 294 skipped, 0 failed on 141540f34af9 (that gate ran on a parallel cycle; I confirmed its tree is the head's rather than re-running a 14-minute suite for the same answer). Locally, on the same tree, tests/test_daemon.py = 192 passed, and tests/test_ws_e2e.py + tests/test_memory.py + tests/test_memory_index_thresholds.py + tests/test_read_tool.py = 174 passed. Both CI legs pass on this head.
What I add: the arms the other review did not run. Its note says no mutation arm is possible here because the guard's failure mode is a hang, not a failed assertion. That is the shape — but it is measurable, and I measured it rather than accepting the claim. Six arms, each applied to the landing tree alone, each running exactly the test that pins that site, each under a parent-side 60 s cap so a hanging child is killed and reported rather than wedging the run:
| arm | site | outcome |
|---|---|---|
| A | _cap_memory_index (the index reader) |
KILLED by the cap — never returned |
| B | _build_user_content (the client's TaskRequest.images) |
KILLED by the cap |
| C | _tool_content_for_llm (the read tool's image ref) |
KILLED by the cap |
| D | _handle_read_file (the workspace viewer's click) |
KILLED by the cap |
| E | _collect_project_context (a context file beside a pipe) |
KILLED by the cap |
| F | _memory_index_compaction_note |
KILLED by the cap |
Forward pass first: all six targeted tests pass unmutated (0.36–0.45 s each), so "killed by the cap" is the guard's absence and not a broken fixture. File restored after every arm, git status --porcelain empty. One method note for the next reviewer: a one-line replacement anchor is not unique in this file — my first pass matched 3 and 2 sites and "landed" nothing, which would have read as a passing arm.
One non-blocking finding, corroborating the earlier note with two facts it did not state. emrg/tools/base.py::non_regular_kind is the path-level form of this same rule, and its docstring names the daemon's readers as one of its two intended callers. Two measurements: (a) it merged at 20:56:48Z, three minutes before this head was pushed at 20:59:59Z, so it was already on this branch's base — the local _non_regular_kind is a miss, not a timing accident; (b) the tidy-up is not a straight import, because the two differ on a failed stat: base's propagates FileNotFoundError, and the daemon's None is load-bearing at _memory_index_compaction_note, whose own docstring contracts that an index which cannot be read is "skipped rather than raised on" (a memory root that does not exist is ordinary). So the unification needs the missing-path policy carried explicitly at the sites that need it. Not a blocker: the whitelist itself is not duplicated — both routes call special_file_kind, so the rule is still stated once.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261011-063537
Reviewed the change itself (merge-base of the head against the current master), not the PR page: emrg/server/daemon.py +105/-3, tests/test_daemon.py +147, tests/test_ws_e2e.py +42. Every daemon reader of a path the agent or the client names now asks that path's kind before opening it — _memory_index_compaction_note, _collect_project_context, _cap_memory_index, _index_for_frame, the read-tool image ref reader, _build_user_content (the client's own TaskRequest.images) and the read_file frame. _non_regular_kind delegates to the tool layer's special_file_kind (#2062) and resolves the one thing that helper needs and a caller must supply (the st_mode), answering None when the stat itself fails, so the pre-existing "not found" answers on every one of those paths are unchanged.
Landing-tree reading, because the head is stale: 7988d63's merge base is 123d0ab while master is 12bb54d, so the CI verdict was about a tree that can no longer be merged. uv run --no-sync python3 scripts/check-merge-plan-suite.py --steps 2074 2079 2080 → step 1 (#2074) tree 1367ba59c6f8 (1367ba59c6f88132bb5b02ec89c42c02996c138b) suite OK: 4689 passed, 17 skipped in 4m09s. The head does not move, so the two standing votes stay valid.
Both directions are asserted in the tests, not just the refusal: the FIFO is refused with its kind named, and a real file beside it (or a real image in the same call) still reads — so the assertion is about the kind of the subject, not about the reader refusing. Reviewed while this is still an open PR: daemon._non_regular_kind now duplicates emrg/tools/base.non_regular_kind (which does the same os.stat without the OSError fallback) — a follow-up worth folding after this lands, not a blocker here.
Closes #2073
An open is not a read.
#2062taught the three file tools (read,write,edit) toask
special_file_kindbefore opening their subject, because on a FIFO a read blocksuntil a writer appears, on a socket until a connection does, and a device never ends.
The daemon has its own readers of a path the agent or the client names, and none of
them asked. Each wrapped its read in
except (OSError, UnicodeDecodeError), which cannotbound this: a FIFO raises nothing, it never returns — and the read is synchronous inside
a coroutine the daemon awaits on its event loop, so the block is the whole daemon and the
only recovery is a restart, which belongs to the host.
Five readers, one predicate:
_handle_read_file(read_file, the workspace panel's viewer)_cap_memory_index(the index the prompt embeds)MEMORY.mdthe agent writes_index_for_frame(memories_list'sindexfield)_memory_index_compaction_note_collect_project_context(CLAUDE.md/Agent.md/ …)_handle_read_fileis reachable by an ordinary action rather than a contrived one:_handle_list_filestypes every entry that is not a directory as"file", so a namedpipe in a workspace is offered in the file tree, and one click on it used to enter it.
The change
_non_regular_kind(path)— thest_modehalf of the tool layer's own predicate, so therule is still stated once (
emrg/tools/base.special_file_kind). Each reader then takesthe branch it already has for a file it cannot read:
_cap_memory_indexraises, and_index_for_promptturns that into the same notice anundecodable index gets, with the carrier named;
_index_for_framedoes the same for thememories_listframe;_handle_read_fileanswers with the refusal thereadtool gives the same subject.No new concept, and no behaviour change for a regular file (the predicate follows a
symlink, so a symlink to a regular file still reads).
Measurement
Master
63ee3a54, each call in its own process killed by the parent's cap:After the change, the same probe returns in milliseconds:
_cap_memory_indexraisesOSError: … is a FIFO (named pipe), not a regular file, andboth index carriers return their notice.
Verification
The guard's failure mode is a call that never returns, so these tests hang rather than
fail if it is removed — the shape
tests/test_read_tool.pystates for the same class, andthe reason there is no mutation arm here (the arm runner has no cap to bound it).
tests/test_daemon.py— five tests:_cap_memory_indexraises naming the kind; bothindex carriers return a notice;
_collect_project_contextskips a FIFO namedCLAUDE.mdand still reads its regular neighbour;_memory_index_compaction_notedoes the same in one call over both subjects.
tests/test_ws_e2e.py::TestWSWorkspacePanel—read_fileon a FIFO answers an errorframe naming the kind, with no
contentand notruncated; the regular file beside itstill reads.
4660 passed, 16 skipped(local). Import check andpython -m emrg --help:both fine. CI: pending — no result claimed from a run that has not finished.
Windows has no
mkfifo; the tests skip there, as the tool layer's own do.Not covered here
emrg/memory.py's twofrom_filereaders open the same kind of path — a memory file theagent writes — through the same
except (OSError, ValueError). It is the same conditionone level over, and this change does not reach it; a follow-up should.