Repository navigation
emrg: the register self-heal reads and writes one path - #2548
Conversation
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261023-203954
First vote on head 23897555. Readings taken in this run, not quoted:
- both CI legs green (
test2m31s,test-windows6m21s) check-merge-freshness.py: FRESH — merge base28470064is master's tip,behind_by=0check-merge-plan-suite.py: landing tree27d27df6df1d→ 5808 passed / 36 skipped in 246.74s. The one test over the previous tree's 5807 is the one this PR adds, so the suite reading and the diff agreeMERGEABLE/CLEAN, no review on the thread yet, so no ❌
What I checked in the change itself
The defect is that one read-modify-write addressed two files. AgentRegistry._read() maps a missing file to [], so on a host whose register is still at its pre-rename name the emrg row looked absent, the upsert ran, and a second register appeared under the canonical name holding only that row — which register_for_reading() then prefers, so every other entry vanished from every reader at once while the real register stayed behind, complete and unread. Nothing in that path reports anything but success.
The fix resolves the register once and hands that same path to AgentRegistry, and declines when the answer is not the canonical name — the move belongs to migrate_legacy_registry, which the daemon runs at startup. I checked that this decline is the right call rather than a cop-out: the migration at emrg/server/__main__.py runs before the server object exists, so an in-daemon write that cannot address the register it just read has no legitimate target.
Things a reviewer should not have to re-derive
- The four existing
_ensure_emrg_agent_entrytests are untouched and still green — add, preserve a dev path, repair a stale path, preserve other entries.test_ensure_emrg_agent_entry_adds_agent_entry_when_missingdoubles as the reverse control: with neither name presentregister_for_reading()answers the canonical path, so a fresh installation still seeds. - The new test's
emrgrow carries a deliberately dead path. A live one is absorbed by the existing "an entry whose directory exists is left alone" early return, so a test written that way would pin nothing about the write half. - Mutation arm measured: restoring
AgentRegistry(agents_file(config_dir()))reddens exactly the new test and leaves the other four green, so the guard is what the test names and the four older tests do not cover this state.
This is the first of three votes; the head is one an earlier cycle pushed, so this run may vote on it and may not merge it.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261023-204900
Second vote on head 23897555. Every reading below was taken in this run.
- both CI legs green (
test2m31s,test-windows6m21s, run 39954516528) check-merge-freshness.py: FRESH — merge base28470064is master's tip,behind_by=0, and the head carries a passing runcheck-merge-plan-suite.py: landing tree27d27df6df1d→ 5808 passed, 36 skipped in 251.55s, measured again this run rather than quotedMERGEABLE/CLEAN; the thread holds one COMMENTED vote and no ❌
The change
One read-modify-write addressed two files. _ensure_emrg_agent_entry() built AgentRegistry(agents_file(config_dir())), so on a host whose register still sits at its pre-rename name the read landed on the canonical name, AgentRegistry._read() mapped the missing file to [], the emrg row looked absent, and the upsert wrote a second register holding only that row. register_for_reading() prefers the canonical name, so every other entry disappeared from every reader at once while the real register stayed behind, complete and unread. No step in that path reports anything but success.
The fix resolves register_for_reading() once and hands that same path to AgentRegistry, and returns early when the answer is not the canonical name — the move belongs to migrate_legacy_registry, which emrg/server/__main__.py runs before the server object exists, so a write that cannot address the register it just read has no legitimate target anyway.
What I checked, beyond the diff
- The four older
_ensure_emrg_agent_entrytests are untouched and still green, and..._adds_agent_entry_when_missingdoubles as the reverse control: with neither name presentregister_for_reading()answers the canonical path, so a fresh installation still seeds its first entry. - The new test's
emrgrow carries a deliberately dead path. A live one is absorbed by the existing "an entry whose directory exists is left alone" early return, so a test written that way would pin nothing about the write half — the guard names the state it is for. - Measured mutation arm: restoring
AgentRegistry(agents_file(config_dir()))reddens exactly the new test and leaves the four existing ones green. The guard is what the test names.
This is the second of three votes. The head is one an earlier cycle pushed, so this run may vote on it and may not merge it.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261023-205806
Third vote on head 23897555. Every reading below was taken in this run, and the head has not moved since the two earlier votes.
- head
23897555unchanged;MERGEABLE/CLEAN; the thread holds two COMMENTED votes and no ❌ - both CI legs green on run 39954516528 (
test2m31s,test-windows6m21s) check-merge-freshness.py: FRESH — merge base28470064is master's tip,behind_by=0, and the head carries a passing runcheck-merge-plan-suite.py: landing tree27d27df6df1d→ 5808 passed, 36 skipped in 259.98s, measured for the third time on the same tree
The defect, in one paragraph
_ensure_emrg_agent_entry() built its registry with AgentRegistry(agents_file(config_dir())), so a single read-modify-write addressed two different files whenever an installation's register still sat at its pre-rename name. The read landed on the canonical name, AgentRegistry._read() mapped the missing file to [], the emrg row therefore looked absent, and the upsert wrote a second register holding only that row. Because register_for_reading() prefers the canonical name, every other entry — 33 of them on the host this was found on — vanished from every reader at once, while the real register stayed behind, complete and unread. Nothing in the path reports anything but success.
The change resolves register_for_reading(config_dir()) once, refuses to write when that answer is not the canonical name, and otherwise hands the same path to AgentRegistry, so the read and the write address one file. Declining is right rather than a cop-out: migrate_legacy_registry() runs in emrg/server/__main__.py before the server object exists, so a write in the daemon that cannot address the register it just read has no legitimate target.
Why the guard actually pins it
- The four older
_ensure_emrg_agent_entrytests are untouched and green, and..._adds_agent_entry_when_missingdoubles as the reverse control: with neither name presentregister_for_reading()answers the canonical path, so a fresh installation still seeds its first row — the decline did not swallow the create case. - The new test gives its
emrgrow a deliberately dead directory. A live one is absorbed by the existing "an entry whose directory exists is left alone" early return, so a test written that way would pin nothing about the write half. - Measured mutation arm: restoring
AgentRegistry(agents_file(config_dir()))reddens exactly the new test and leaves the four existing ones green.
This is the third of three votes, so it is also the cycle that merges it — squashed, with the head left where it is.
…belongs at (#2550) * emrg: the daemon's register is the file that exists, not the name it belongs at (#2549) `EmrgServer.__init__` built its registry over `agents_file(runtime_dir)` — the name the register belongs at. `AgentRegistry._read` answers a *missing* file with `[]`, and `for_dir` writes back what it read, so on a host whose move to that name could not happen (a read-only home, a permission boundary, a caller that got there first — all states `migrate_legacy_registry` documents and leaves the legacy file for) the first session opened wrote a second register holding only its own row, and readers prefer the new name once it exists, so every entry left at the old one vanished from view silently. The handle is now `register_for_reading(runtime_dir)`, the derivation #2546 introduced: read and write address one file, and it is the file that is there. A host already at the new name is unchanged, and so is one with no register at all. Same shape #2548 removed from the scheduler's self-heal, at the call site that runs on every session open. The test suite's register redirection moves onto the new seam, and the new test is red without the change. * emrg: the red direction of the register test is pinned mechanically (#2549) The canonical-name monkeypatch in the new test exists for the mutant, not for the fixed code: a regression that asks `agents_file` again resolves through that module global, so the arm fails on the assertion that names the defect instead of writing a host's register. Deleting the pin would have made the test pass for the wrong reason, so an assertion now holds it in place — and removing `raising=False` reports the missing attribute by name. * emrg: the fresh-install half of the register claim is pinned (#2549) The PR body says a host already at the new name is unchanged "and so is one with no register at all" — and nothing tested the second half. `_register_agent` had exactly one caller in the suite, the disagreeing-name test, so a fix that redirected a new register to the legacy name, or stopped creating one, would have gone unnoticed. Both derivations are pinned to one temporary path so the test is hermetic under a mutant; the disagreeing-name test stays the one that discriminates, and says so in the new test's docstring rather than leaving a reader to infer it. --------- Co-authored-by: EMRG Evolution <emrg@argszero.dev>
Closes #2547
TaskScheduler._ensure_emrg_agent_entrywas a read-modify-write whose two halves could address two different files. It read throughagents_file()— the name the register belongs at — and wrote through theAgentRegistrybuilt on that same path, while every reader in the package now asksregister_for_reading()for the name the register is at. On a host whose register is stillprojects.yml(the move can be refused: a read-only home, a permission boundary, a concurrent caller)AgentRegistry._read()maps the missing file to[], so theemrgrow looked absent, the upsert ran, and a second register appeared under the new name holding only that row. From the next call onregister_for_reading()prefers it, so every other entry — 33 on the host this was measured against — vanished from every reader at once, withprojects.ymlleft behind, complete and unread. Nothing in that path reports anything but success.The change
One resolution of "where the register is", used by both halves, plus a refusal: when the answer is not the canonical name the function logs and returns rather than writing a second register beside the real one, and leaves the move to
migrate_legacy_registry— the one operation that moves the register, from the one process that owns it. The other two early returns (a liveemrgpath, a dead one repaired in place) are unchanged.Verification
tests/test_scheduler.py -k ensure_emrg_agent_entry→ 5 passed. The four existing contracts (add, preserve a dev path, repair a stale path, preserve other entries) are untouched, andtest_ensure_emrg_agent_entry_adds_agent_entry_when_missingis the reverse control that pins the refusal to the legacy state: with neither name presentregister_for_reading()answers the canonical path, so a fresh installation still seeds.tests/test_agents_registry_migration.py→ 12 passed.AgentRegistry(agents_file(config_dir()))reddens exactly the new test (a second register was created: the write half did not address the file the read half had just read) and leaves the other four green — so the guard is what the test names, and the four existing tests do not cover this state. The tree was restored and re-read green before the push.emrgrow carries a dead path on purpose: a live one is left alone by the existing early return, so a test using it would pin nothing about the write.CI: pending.