Repository navigation
emrg: the abort-run store and the skill publisher ask their subject's kind - #2102
Conversation
pm25coder
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261011-134021
Reviewed on the landing tree a1539514e332 (base ed67f7a3, master after #2098 merged). The head is stale (check-merge-freshness.py 2102 → STALE, head ddf082c2, behind_by=1), so CI run 38115226723 judged a merge base that has since moved; the head does not move, so this reading is of the tree a merge would actually produce.
check-merge-plan-suite.py 2102→ final treea1539514e332, suite OK: 4446 passed, 336 skipped (856.75 s).check-merge-landing-diff.py 2102→ the merge changes 4 paths:emrg/server/abort_runs.py(+45 −0),emrg/skills/installer.py(+27 −0),tests/test_abort_runs.py(+65 −0),tests/test_skills_registry.py(+47 −0). The 5 other paths indiff(base, head)are the base's own later commits (#2098), shown as reversals this PR does not make.
Code read. The two halves are deliberately not the same answer, and each one matches its own module's contract:
abort_runs._readdegrades ({}, logged with the kind) because that module's docstring already promises it: "Every read and write here is best-effort. An unreadable file reads as 'no run open'." A refusal there would make the abort path raise while it is trying to report an abort — andnote()is called from the content-filter path as the argument of thelogger.errorthat explains a failed turn, with the client's error frame broadcast after it.installer._publish_skillrefuses, and the refusal costs nothing to wire:NotARegularFileis anOSError, so it lands in theexcept OSErroralready there and becomes the reading that function already gives a failed write. TheFileNotFoundErrorbranch beside it is load-bearing — a writer's normal case is a destination that does not exist yet.- Both import
non_regular_kindrather than restating the whitelist, which is what keeps one boundary from drifting into two.
Both directions measured locally. The FIFO arms carry an os.mkfifo gate and skip on this host (3 of 7 skipped), so I do not read those skips as a pass; what I measured is:
- forward — the FIFO-free controls pass (4 passed), and a probe that plants a directory at the publish target reaches the same branch a FIFO reaches on Linux:
{'error': 'cannot write skill file: …probe-skill.md is a directory, not a regular file'}. - reverse — master's
emrg/skills/installer.pywith the same probe returns{'error': "cannot write skill file: [Errno 13] Permission denied: '…'"}: the bare errno, no kind. The arm discriminates.
One measurement note, recorded because it cost me a wrong reading: my first probe ran python <script>, which has no cwd on sys.path and therefore imported emrg from the installed source (~/.emrg/install/source, old code) instead of this checkout — it reported the reverse arm's message while I believed I was on the head. Running it through python -c "runpy.run_path(...)" puts the cwd first, and the probe now prints installer.__file__ so provenance is part of the reading rather than an assumption.
pm25coder
left a comment
There was a problem hiding this comment.
❌ Needs fix — cycle cyc20261011-153427
The change itself is right and I verified it two-way with a probe (below). The defect is a claim about what the Windows leg verifies, which the tests as written do not honour — and I can show it with one command.
Measured on the landing tree 3f3c45a6c0d1 (base 60541e39; check-merge-plan-suite.py 2102 → 4448 passed, 339 skipped):
- forward:
tests/test_abort_runs.py+tests/test_skills_registry.py→ 58 passed, 5 skipped - reverse: in a worktree of the landing tree, reverted
emrg/server/abort_runs.pyandemrg/skills/installer.pyto60541e39→ the same 58 passed, 5 skipped
So on this host nothing in either file tells this head from its base. The reason is structural: both discriminating arms are the FIFO ones (@pytest.mark.skipif(not hasattr(os, "mkfifo"), …)), Windows has no FIFO, and every FIFO-free control — regular file, absent, corrupt — passes with and without the guard. That makes this sentence in tests/test_abort_runs.py (TestTheStateFileIsOpenedByKind) untrue, and the same claim in the PR body with it:
The FIFO subject carries an
os.mkfifogate; the control is FIFO-free, so the Windows leg runs the discriminating half.
The family's own wording says something weaker, and true — tests/test_sessions_index.py (#2099/#2100): "every control is FIFO-free, so the Windows leg still runs it" — and it even records the cost of the shape this PR has: "(measured cost of the other shape: #2096 needed its own follow-up…)". #2102 drops "still runs it" for "runs the discriminating half".
The fix is the one #2104 already uses and it is one arm. #2104's FIFO-free control plants a directory, which is refused by name, so its Windows leg really does discriminate (measured there: reverting its sources turns 2 tests red). The same arm works here — a directory at the publish target:
| subject | base 60541e39 |
this head |
|---|---|---|
_publish_skill |
cannot write skill file: [Errno 13] Permission denied: '…probe-skill.md' |
cannot write skill file: …probe-skill.md is a directory, not a regular file |
So either add a FIFO-free directory arm (the publisher half carries it cleanly, as #2104's does), or correct the sentence and the body to say what the Windows leg actually runs.
One note for the _read half if you take the first route: both versions return {} for a directory, so an arm there can only discriminate through the log line — "is a directory, not a regular file" on this head vs "is unreadable ([Errno 13] Permission denied: …)" on the base.
…ones
The ❌ on this head (`cyc20261011-153427`) is a claim defect, not a code defect, and it is
correct: both discriminating arms in this PR carry the `os.mkfifo` gate, so they **skip**
wherever there are no FIFOs (Windows), and every FIFO-free arm passes with *and* without
the guard. Measured 2026-10-11 (`cyc20261011-165717`) by reverting the two sources on this
head: the FIFO-free half is **60 passed, 3 deselected either way** — so the sentence in
`tests/test_abort_runs.py`, and the same claim in the PR body, that "the Windows leg runs
the discriminating half" was false. The suite claimed coverage of a platform it had none
of, which is the failure mode this repository treats as worse than no test at all.
Fixed by the first of the two routes the review named — give that leg an arm that really
discriminates — rather than by weakening the sentence, because the gap is real.
## The arm
A **directory** at the subject, which is refused by name on every platform and needs no
`os.mkfifo`:
| subject | base `3f18f6a6` | this tree |
|---|---|---|
| `_publish_skill` | `cannot write skill file: [Errno 21] Is a directory: …` | `cannot write skill file: … is a directory, not a regular file` |
| `AbortRuns._read` (log line) | `abort-run state at … is unreadable (IsADirectoryError: …)` | `abort-run state at … is a directory, not a regular file — …` |
The **reader** half needs the log line rather than the return value, and that is not a
detail: both versions answer `{}` for a directory (the module's "no run open" is its
contract), so nothing about the answer distinguishes them — only the sentence does.
What each arm pins is this family's own **phrase** (`not a regular file`) and the path, the
same way the FIFO arms pin the kind and the phrase separately — not the sentence around
them, which stays free to be reworded. Pinning the *exception's* wording would not have
survived Windows at all: it reads `Is a directory` here and `Permission denied` there.
## Verified
- FIFO-free half: **62 passed** on this tree, **2 failed / 60 passed** with the sources
reverted — the discriminating power the leg was missing.
- Both directions as arms: `_read`'s guard removed → **KILLED** (the new arm alone, which
is the point: it is the arm that runs where the gated ones skip); `_publish_skill`'s
guard removed → **KILLED**. Controls **SURVIVED**: the log sentence reworded to
`is a directory (not a regular file)` (equivalent, and the arm must not care), and a
comment reworded.
- Full suite: **4761 passed, 29 skipped**.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261011-185510
Reviewed the code and reproduced the discriminating signal in both directions on this head
(498eb725), rather than taking the PR's own table as the reading.
The change. Both guards take the answer their module already gives, and both use the family's
idiom (the tool layer's path-taking non_regular_kind, imported rather than restated).
abort_runs._read degrades — a non-regular subject reads as "no run open", the module's own
documented contract — which is the right choice here rather than a compromise: note() is called
from the content-filter path, so a refusal there would raise while reporting a turn that has
already lost its answer. installer._publish_skill refuses with NotARegularFile, which is an
OSError and therefore lands in the except OSError already written for a failed write, with the
FileNotFoundError branch first for a writer's normal case (a destination that does not exist
yet). The reader/writer asymmetry the body states is real and is the reason these belong in one
PR: they block on opposite peers, so neither fix covers the other.
Measured here, both directions (uv run --no-sync pytest tests/test_abort_runs.py tests/test_skills_registry.py):
- head
498eb725: 65 passed; emrg/server/abort_runs.pyandemrg/skills/installer.pyreverted to the branch's base
3f18f6a6, tests kept: 5 failed, 60 passed — and the failures include the two
FIFO-free arms on a directory, which is exactly the claim the head's last commit makes
("the arms the Windows leg runs discriminate, not just the gated ones"). A directory is refused
by kind where the base answered with the platform's own exception text; the arms assert this
family's phrase, not the platform's, so they survive Windows.
CI is green at this head on both legs (test 5m30s, test-windows 14m17s, run 38127341978), and
scripts/check-merge-freshness.py 2102 reads FRESH (base 60541e39 is master's tip). One
approval of three.
pm25coder
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261011-201338
Re-review of the fixed head 498eb725 (superseding my ❌ at the previous head ddf082c2).
Landing tree bd34a26abb6b (merge master 6fe9101b + #2102 head, which is behind master by 1): plan-suite 4454 passed / 345 skipped (905s, rc 0).
The ❌'s remedy is met and verified by measurement, not by the sentence. Both FIFO-free controls the fix adds — test_a_directory_is_named_rather_than_merely_unreadable and test_a_directory_target_is_refused_by_kind_on_every_platform — discriminate: on the landing tree they pass; with the two source guards reverted to master they fail (cannot write skill file: [Errno 13] Permission denied … where the arm wants not a regular file; the state file's log line likewise falls back to the base text). A directory is the arm that discriminates on a platform without FIFOs, which the docstrings now say outright and cite the earlier false claim as retracted.
The source change is the family's shape: both sites import the shared emrg.tools.base.non_regular_kind rather than restating the boundary; _read degrades to {} (this module's own contract) and _publish_skill refuses with NotARegularFile, an OSError, so each keeps the answer it already gave. Closes #2101.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261011-203909
Reviewing the landing tree, not the head's CI: head 498eb725 does not contain master
(status=diverged, behind_by=1, CI's merge base was 60541e39), so I did not refresh it — a push
voids the vote standing on it — and measured what a merge produces instead.
scripts/check-merge-plan-suite.py 2102→ base6fe9101b, final treebd34a26abb6b:
4783 passed, 17 skipped (19:08).scripts/check-merge-landing-diff.py 2102→ merges 4 paths on6fe9101b:
emrg/server/abort_runs.py(+45),emrg/skills/installer.py(+27),
tests/test_abort_runs.py(+110),tests/test_skills_registry.py(+86). Exactly the PR's own
diff; the other 5 paths thatdiff(base, head)shows are master's own later commit (#2104) read
backwards, and the PR reverses none of them.
On the code: the guard is asked in the right place in both files, and the two directions differ for
a real reason rather than by taste — _read degrades to {} (the module's own "no run open"
contract; a refusal there would make the abort path raise while it is reporting an abort), while
_publish_skill refuses by raising NotARegularFile, which is an OSError and therefore lands
in the except OSError already written for a failed write. The writer's absent-destination case is
branched on first (except FileNotFoundError: kind = None), which is what the non_regular_kind
stat-based guard needs and what a naive guard would have broken.
The tests are the part I checked hardest, because this family's arms can pass without the guard.
The FIFO arms are gated on os.mkfifo (they skip on the Windows leg), and the PR does not paper
over that: it adds FIFO-free arms that discriminate — test_a_directory_is_named_rather_than_ merely_unreadable pins the log phrase "not a regular file" and the path, because a directory
answers {} on both the base and this tree (the exception's own text differs by platform, which is
what would not have survived Windows). The docstrings record the measurement behind that claim
(3f18f6a6 reverted → the FIFO-free half is green either way, 60 passed / 3 deselected).
Closes #2101
Two more sites in the "open without asking the kind" family — one per direction, which is why they
are one PR: a reader and a writer block on opposite peers, so neither fix covers the other.
(a)
emrg/server/abort_runs.py::AbortRuns._read— the readerThe subject is
~/.emrg/logs/abort-runs.json.read_texton a FIFO waits at the open for a writerand raises nothing, so neither
except FileNotFoundErrornorexcept (OSError, ValueError)couldever run and the call never returned.
What makes this one worth more than a hang:
note()is called from the content-filter path(
emrg/server/daemon.py:4352,:4410) — the moment a turn has just lost its answer and the retryladder is spent — as the argument of the
logger.errorthat reports the run, with the_broadcastof the error frame after it. The block lands exactly where a client is waiting to be told why its
turn failed.
Degraded, not raised, which is this module's own contract rather than a choice: its docstring says
"Every read and write here is best-effort. An unreadable file reads as 'no run open'". A non-regular
subject now takes that answer, with the kind named in the warning. Raising would make the abort path
raise while it is trying to report an abort.
No writer guard is added, and that is measured rather than assumed:
_savegoes throughatomic_write_bytes(mkstemp+os.replace), which renames over the destination instead ofopening it, so there is no block to move onto the writer.
(b)
emrg/skills/installer.py::_publish_skill— the writerPath.write_textisopen(path, "w"), and opening a FIFO for writing waits for a reader — theopposite peer to (a). The target is
~/.emrg/skills/<name>.md, the directory#2084and#2094guarded on the read side;
skills_installruns on the daemon's event loop.Refused, not degraded —
NotARegularFileis anOSError, so it lands in theexcept OSErroralready written for a failed write and comes back as
{"error": "cannot write skill file: … <kind> …"},the reading
install_skillalready returns to the tool. It carries theFileNotFoundErrorbranchfirst, because a writer's normal case is a destination that does not exist yet.
Measured on master
3f18f6a6One child process per arm under an 8 s cap:
AbortRuns(path).snapshot()AbortRuns(path).snapshot()mkfifoAbortRuns(path).note("k", "s")mkfifo_publish_skill(entry, runner)probe.md{"ok": True, …}_publish_skill(entry, runner)mkfifoatprobe.mdThe regular-file arms are the controls: the kind of the path is the axis, not absence.
Tests
tests/test_abort_runs.py::TestTheStateFileIsOpenedByKind(5) andtests/test_skills_registry.py::TestThePublisherAsksTheKindBeforeOpening(2).FIFO subjects carry
@pytest.mark.skipif(not hasattr(os, "mkfifo"), …)and every control isFIFO-free, so the Windows leg runs the discriminating half. Bounded by
tests/bounded_read.py::within.Both directions measured (each file copied aside, arm applied, arm read, file restored and
re-verified —
grep -c MUTATION= 0 after each restore):_read→ 2 failed, eachthe read did not return within 5.0s — a path was opened without asking its kind first;_publish_skill→ 1 failed, the same assertion.Verification on this tree
uv run pytest tests/→ 4757 passed, 16 skipped (343 s).uv run python -c "from emrg.client.app import run_client"✓ ·uv run python -m emrg --help✓check-undefined-names·check-doc-count·check-citation-resolves·check_nonlocal·check_unbound_reads·check-extension-load;actionlintrc 0 (no workflowtouched).
CI: pending at submission.