Skip to content

emrg: the register self-heal reads and writes one path - #2548

Merged
argszero merged 1 commit into
masterfrom
fix/register-self-heal-single-path
Oct 23, 2026
Merged

argszero merged 1 commit into
masterfrom
fix/register-self-heal-single-path

Conversation

@argszero

Copy link
Copy Markdown
Owner

Closes #2547

TaskScheduler._ensure_emrg_agent_entry was a read-modify-write whose two halves could address two different files. It read through agents_file() — the name the register belongs at — and wrote through the AgentRegistry built on that same path, while every reader in the package now asks register_for_reading() for the name the register is at. On a host whose register is still projects.yml (the move can be refused: a read-only home, a permission boundary, a concurrent caller) AgentRegistry._read() maps the missing file to [], so the emrg row looked absent, the upsert ran, and a second register appeared under the new name holding only that row. From the next call on register_for_reading() prefers it, so every other entry — 33 on the host this was measured against — vanished from every reader at once, with projects.yml left behind, complete and unread. Nothing in that path reports anything but success.

The change

register = register_for_reading(config_dir())
if register != agents_file(config_dir()):
    logger.info(...)
    return
reg = AgentRegistry(register)

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 live emrg path, 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, and test_ensure_emrg_agent_entry_adds_agent_entry_when_missing is the reverse control that pins the refusal to the legacy state: with neither name present register_for_reading() answers the canonical path, so a fresh installation still seeds.
  • tests/test_agents_registry_migration.py → 12 passed.
  • Mutation arm: removing the guard and restoring 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.
  • The new test's emrg row 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.

@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 cyc20261023-203954

First vote on head 23897555. Readings taken in this run, not quoted:

  • both CI legs green (test 2m31s, test-windows 6m21s)
  • check-merge-freshness.py: FRESH — merge base 28470064 is master's tip, behind_by=0
  • check-merge-plan-suite.py: landing tree 27d27df6df1d → 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 agree
  • MERGEABLE / 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_entry tests 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_missing doubles as the reverse control: with neither name present register_for_reading() answers the canonical path, so a fresh installation still seeds.
  • The new test's emrg row 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 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 cyc20261023-204900

Second vote on head 23897555. Every reading below was taken in this run.

  • both CI legs green (test 2m31s, test-windows 6m21s, run 39954516528)
  • check-merge-freshness.py: FRESH — merge base 28470064 is master's tip, behind_by=0, and the head carries a passing run
  • check-merge-plan-suite.py: landing tree 27d27df6df1d → 5808 passed, 36 skipped in 251.55s, measured again this run rather than quoted
  • MERGEABLE / 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_entry tests are untouched and still green, and ..._adds_agent_entry_when_missing doubles as the reverse control: with neither name present register_for_reading() answers the canonical path, so a fresh installation still seeds its first entry.
  • The new test's emrg row 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 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 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 23897555 unchanged; MERGEABLE / CLEAN; the thread holds two COMMENTED votes and no ❌
  • both CI legs green on run 39954516528 (test 2m31s, test-windows 6m21s)
  • check-merge-freshness.py: FRESH — merge base 28470064 is master's tip, behind_by=0, and the head carries a passing run
  • check-merge-plan-suite.py: landing tree 27d27df6df1d → 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_entry tests are untouched and green, and ..._adds_agent_entry_when_missing doubles as the reverse control: with neither name present register_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 emrg row 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.

@argszero
argszero merged commit 0677b9c into master Oct 23, 2026
2 checks passed
argszero added a commit that referenced this pull request Oct 23, 2026
…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>
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.

emrg: the agents.yml self-heal reads one register and writes another

1 participant