Skip to content

emrg: the abort-run store and the skill publisher ask their subject's kind - #2102

Merged
argszero merged 3 commits into
masterfrom
fix/abort-runs-and-installer-ask-the-kind
Oct 11, 2026
Merged

argszero merged 3 commits into
masterfrom
fix/abort-runs-and-installer-ask-the-kind

Conversation

@argszero

Copy link
Copy Markdown
Owner

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 reader

The subject is ~/.emrg/logs/abort-runs.json. read_text on a FIFO waits at the open for a writer
and raises nothing, so neither except FileNotFoundError nor except (OSError, ValueError) could
ever 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 retry
ladder is spent — as the argument of the logger.error that reports the run, with the _broadcast
of 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: _save goes through
atomic_write_bytes (mkstemp + os.replace), which renames over the destination instead of
opening it, so there is no block to move onto the writer.

(b) emrg/skills/installer.py::_publish_skill — the writer

Path.write_text is open(path, "w"), and opening a FIFO for writing waits for a reader — the
opposite peer to (a). The target is ~/.emrg/skills/<name>.md, the directory #2084 and #2094
guarded on the read side; skills_install runs on the daemon's event loop.

Refused, not degraded — NotARegularFile is an OSError, so it lands in the except OSError
already written for a failed write and comes back as {"error": "cannot write skill file: … <kind> …"},
the reading install_skill already returns to the tool. It carries the FileNotFoundError branch
first, because a writer's normal case is a destination that does not exist yet.

Measured on master 3f18f6a6

One child process per arm under an 8 s cap:

arm subject reading
AbortRuns(path).snapshot() a regular file RETURNED 0.0 s
AbortRuns(path).snapshot() a mkfifo DID NOT RETURN within 8 s
AbortRuns(path).note("k", "s") a mkfifo DID NOT RETURN within 8 s
_publish_skill(entry, runner) a regular probe.md RETURNED 0.0 s, {"ok": True, …}
_publish_skill(entry, runner) a mkfifo at probe.md DID NOT RETURN within 8 s

The regular-file arms are the controls: the kind of the path is the axis, not absence.

Tests

tests/test_abort_runs.py::TestTheStateFileIsOpenedByKind (5) and
tests/test_skills_registry.py::TestThePublisherAsksTheKindBeforeOpening (2).

FIFO subjects carry @pytest.mark.skipif(not hasattr(os, "mkfifo"), …) and every control is
FIFO-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):

  • kind ask removed from _read → 2 failed, each
    the read did not return within 5.0s — a path was opened without asking its kind first;
  • kind ask removed from _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 ✓
  • Guards rc 0: check-undefined-names · check-doc-count · check-citation-resolves ·
    check_nonlocal · check_unbound_reads · check-extension-load; actionlint rc 0 (no workflow
    touched).

CI: pending at submission.

@pm25coder pm25coder left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

✅ 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 tree a1539514e332, 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 in diff(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._read degrades ({}, 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 — and note() is called from the content-filter path as the argument of the logger.error that explains a failed turn, with the client's error frame broadcast after it.
  • installer._publish_skill refuses, and the refusal costs nothing to wire: NotARegularFile is an OSError, so it lands in the except OSError already there and becomes the reading that function already gives a failed write. The FileNotFoundError branch beside it is load-bearing — a writer's normal case is a destination that does not exist yet.
  • Both import non_regular_kind rather 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.py with 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 pm25coder left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

❌ 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.py and emrg/skills/installer.py to 60541e39 → 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.mkfifo gate; 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.

EMRG Evolution added 2 commits October 11, 2026 17:08
Pure refresh: the head was two commits behind, so a reviewer measuring the tree a merge
would produce was reading a stale base. No conflict; nothing in this PR's four paths is
touched by what landed (#2098, #2100).
…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 argszero left a comment

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.

✅ 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.py and emrg/skills/installer.py reverted 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 pm25coder left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

✅ 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 argszero left a comment

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.

✅ 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 → base 6fe9101b, final tree bd34a26abb6b:
    4783 passed, 17 skipped (19:08).
  • scripts/check-merge-landing-diff.py 2102 → merges 4 paths on 6fe9101b:
    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 that diff(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).

@argszero
argszero merged commit 42b9a04 into master Oct 11, 2026
2 checks passed
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.

the abort-run store and the skill publisher open their subject without asking its kind — a FIFO wedges the abort path and the install path

2 participants