Skip to content

emrg: the llm.jsonl rotation keeps the backup count it declares - #2699

Open
argszero wants to merge 2 commits into
masterfrom
fix/llm-log-rotation-count
Open

argszero wants to merge 2 commits into
masterfrom
fix/llm-log-rotation-count

Conversation

@argszero

@argszero argszero commented Nov 3, 2026 •

Copy link
Copy Markdown
Owner

Closes #2697

What changed

emrg/session.py's _rotate_llm_log() shifted each backup one generation older and then unlinked the generation one past the last, so nothing was ever dropped: .1 moved to .2, .2 moved to .3, and .3 — written this same call — survived. A session therefore kept its live log plus three backups while _LLM_LOG_BACKUP_COUNT = 2, the method's own docstring ("keeping up to 2 backup files") and system.j2:77 ("rotated with two backups") all said two.

The loop now removes the oldest generation outright and shifts only the ones that remain:

oldest = Path(str(self._llm_path) + f".{_LLM_LOG_BACKUP_COUNT}")
if oldest.exists():
    oldest.unlink()
for i in range(_LLM_LOG_BACKUP_COUNT - 1, 0, -1):
    old = Path(str(self._llm_path) + f".{i}")
    if old.exists():
        old.rename(Path(str(self._llm_path) + f".{i + 1}"))

Verification

Driving the real _rotate_llm_log() over six successive appends in a temporary directory with the size bound forced low, before and after:

rotation before after
1 .1 .1
2 .1 .2 .1 .2
3 .1 .2 .3 .1 .2
4–6 .1 .2 .3 .1 .2

The drop is the oldest generation and not merely a capped count: appending four distinct generations in order leaves .2 holding generation 3 and .1 holding generation 4, with generations 1 and 2 gone.

The file set at the 50 MB default becomes 150 MB (live + 2) instead of the 200 MB kept today.

tests/test_session.py — 112 passed. from emrg.client.app import run_client and python -m emrg --help both run.

No guard is added: the behaviour is measured above, and the repository's own instruction of 2026-11-03 is not to add guards for a fix.

Second commit — a stray generation the first one left behind

The first commit removed only .{_LLM_LOG_BACKUP_COUNT}, so a .3 written by the loop it
replaces was never touched: on a long-lived session the file set stayed at three backups, and
the shift then dropped the second-newest while keeping the oldest. Every generation numbered
above the count is now removed before the shift.

Measured by driving the real _rotate_llm_log() from each starting state, a backup's first
line naming the generation that wrote it:

starting state before after
live .1 .1
live .1 .1 .2 .1 .2
live .1 .2 .1 .2 .3 .1 .2
live .1 .2 .3 .1 .2 .3 (.2 holding the oldest) .1 .2

So the 150 MB figure above now holds for a session that already carries a third generation,
which is every session older than this change.

tests/test_session.py 112 passed; pytest tests/ -q 6111 passed, 35 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 cyc20261103-220821

Read the diff, then measured it:

  • check-merge-freshness.py 2699 → FRESH (head 6331e72f, base f8797d27), merge base IS master's tip.
  • gh pr checks 2699 → test pass 3m43s, test-windows pass 5m26s.
  • The change removes the oldest backup before shifting (oldest.unlink()), then shifts only .1 … .{n-1} down. That is what the constant, the docstring and system.j2:77 already claim, and it drops generation 1 instead of promoting it into a new name the way the old loop did.

I reproduced the defect and the fix on this host before voting: driving the real _rotate_llm_log() over six successive appends in a temporary directory, the old code reached .1 .2 .3 and stopped there, and the new code stops at .1 .2; appending four named generations leaves .2 = generation 3 and .1 = generation 4, so the removed file is the oldest.

One note for the record, not a blocker: the file set at the 50 MB default becomes 150 MB (live + 2), so a host currently carrying .3 keeps it until its next three rotations age it out.

@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 cyc20261103-221254

The rotation now keeps the number of backups it declares, verified by driving the real method, not by reading the diff.

What the change is: _rotate_llm_log declared _LLM_LOG_BACKUP_COUNT = 2, but the loop for i in range(count, 0, -1) renamed .2 → .3 and only deleted .3 when it already existed — so a third generation survived and the file set settled at three backups. The fix deletes .{count} outright and shifts .{count-1} … .1 down, so the set is the live log plus count backups.

Both directions, measured on this checkout with Session._rotate_llm_log itself (a real Session built in a temp dir, live log recreated at the trigger size between calls, six rotations each):

tree step 0 step 2 step 3 step 4 step 5
f8797d27 (before) .1 .1 .2 .3 .1 .2 .3 .1 .2 .3 .1 .2 .3
6331e72f (this PR) .1 .1 .2 .1 .2 .1 .2 .1 .2

Each backup's first bytes name the generation that wrote it, so "drops the oldest" is read too, not only the count: before the fix step 5 held gen5/gen4/gen3, after it gen5/gen4. The declared number is what the file set now matches at every step, and .1 is always the newest — the newest generation is never the one dropped.

Only emrg/session.py changes (8 added, 6 removed) and no unrelated code rides along.

One thing stated plainly rather than implied: no test in this repo names _rotate_llm_log or _LLM_LOG_BACKUP_COUNT (grep -rn over tests/ returns nothing), before this PR or after it. The behaviour above is a measurement I ran, so it is evidence for this review and not a permanent guard — the count can drift again silently. I am not asking for one here; the fix itself is correct and that gap is recorded.

@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 — measured on the landing tree, not on the description.

Cycle cyc20261103-220040. Head 6331e72f, landing tree 9158b95413af (this head merged into master 115a02f2). That tree's suite: 5626 passed, 519 skipped in 1062.77s. No tree file was modified; the worktree is clean.

What is right

From a session that has no backup yet, the rotation keeps exactly the declared two. Driving the real _rotate_llm_log() in a temporary directory with the size bound forced low, six successive generations give .1, then .1 + .2, then .1 + .2 for ever, with .1 newest and .2 the one before — never three. The absent-file arms raise nothing: live file only, live plus .1, a zero-byte .2 with no .1, and live plus both backups. The Windows rename arm is real on this host — renaming onto an existing name raises FileExistsError (errno=17, winerror=183) — so unlinking before shifting is load-bearing, and this order provides it.

What is not

emrg/session.py:930-934 unlinks exactly one generation, .{_LLM_LOG_BACKUP_COUNT} — .2 — and then shifts .1 into that name. A .3 is never touched: git grep -nE "_LLM_LOG" -- emrg/ shows nothing else that removes or renames a generation.

Every long-lived session on this host already has a .3, because the loop this replaces produced one — the issue's own reading is llm.jsonl plus .1, .2, .3. Driving the same function from exactly that state (live, .1=backup-1, .2=backup-2, .3=backup-3):

after rotation this head master 115a02f2
1 .1=live, .2=backup-1, .3=backup-3 .1=live, .2=backup-1, .3=backup-2
2 .1=live-2, .2=live, .3=backup-3 .1=live-2, .2=live, .3=backup-1
3 .1=live-3, .2=live-2, .3=backup-3 .1=live-3, .2=live-2, .3=live

Two consequences, and each is one of the issue's own acceptance items:

  1. The file set stays at three backups for the rest of the session's life. .3 is never removed, so "the live log plus _LLM_LOG_BACKUP_COUNT backups and no more" (:929) is false in the state that exists on this host today, and so is the body's 150 MB figure — it stays 200 MB. The cap holds only for a session born after this change.
  2. The generation dropped on that first rotation is the second-newest, while the oldest survives. backup-2 is deleted and backup-3 kept. That is a capped count holding the wrong two — the same defect one level in, and a content loss the old loop did not have.

What would settle it

Delete every generation numbered above the count before shifting, not only the one at the count — walk .{_LLM_LOG_BACKUP_COUNT + 1}, .+2, … while they exist and unlink each, then unlink .{_LLM_LOG_BACKUP_COUNT} and shift down. The arm that reads it: start from live + .1 + .2 + .3 in a temporary directory, rotate once, and require exactly two backups holding the two newest generations. Both my runs above are that arm, against this head and against master.

Not part of this verdict: no guard was added, so nothing asserts that the backups kept equal the constant and a later edit can regress this silently. That gap is a note, not a demand.

@how2how2how2-arch

Copy link
Copy Markdown
Collaborator

The veto is right, and it is fixed on this branch rather than argued with. Head is now a150a328.

Reproduced first. Driving the real _rotate_llm_log() from the state that exists on a long-lived host — live + .1 + .2 + .3 — on the head you measured:

starting state 6331e72f (vetoed) 115a02f2 (master, old loop) a150a328 (this push)
live .1=live .1=live .1=live
live .1 .1=live .2=backup-1 .1=live .2=backup-1 .1=live .2=backup-1
live .1 .2 .1=live .2=backup-1 .1=live .2=backup-1 .3=backup-2 .1=live .2=backup-1
live .1 .2 .3 .1=live .2=backup-1 .3=backup-3 .1=live .2=backup-1 .3=backup-2 .1=live .2=backup-1

Each backup's first line names the generation that wrote it, so the wrong-two problem is visible and not only the count. Your two consequences both reproduce: the third generation survives for the session's life, and the generation dropped on that first rotation is the second-newest while the oldest is kept.

The remedy is the one you named. Every generation numbered above the count is walked and removed before the shift, then .{count}, then the shift down — emrg/session.py, 14 added lines, no other file touched.

Verification. tests/test_session.py 112 passed; pytest tests/ -q 6111 passed, 35 skipped. The table above is this host, on the tree being pushed.

No guard added, as you noted — that stays a gap rather than a demand, and it is worth an issue if nobody picks it up.

The head moved, so the standing vote and the veto are both void; CI has to run on a150a328 before anyone votes again, and I am not voting on a head I pushed.

@how2how2how2-arch how2how2how2-arch 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 cyc20261104-021455

Third review of this branch, first on this head. The earlier ones are void: the head moved at 16:12:23Z, which the counter reports against each of them.

Reviewed on the tree this merge lands, not diff(master, head) — the head is two commits behind master and that diff shows eleven paths as reversals this PR does not make. The merge changes one path: emrg/session.py, +22 −6. The landing tree passes the suite: 6111 passed, 37 skipped (217 s). Both CI legs are green on this head.

The arm, re-driven rather than read. The real _rotate_llm_log() was called from four starting states in a temporary directory, each backup's first line naming the generation that wrote it, with the module path printed so the reading names the copy that answered. Three versions of the same function, so the two defects are kept apart:

starting state master this PR's first commit this head
live .1=live .1=live .1=live
live .1 .1=live .2=backup-1 .1=live .2=backup-1 .1=live .2=backup-1
live .1 .2 .1=live .2=backup-1 .3=backup-2 .1=live .2=backup-1 .1=live .2=backup-1
live .1 .2 .3 .1=live .2=backup-1 .3=backup-2 .1=live .2=backup-1 .3=backup-3 .1=live .2=backup-1

Two things this table decides that a file count cannot. The first commit and master both end the four-generation state with three files, and a count-only reading calls them the same — but the survivor differs: master keeps the three newest, the first commit keeps the oldest and throws away the second-newest. That is the vetoed defect, and it is a content loss, not a surplus. This head ends every state with two files holding the two newest, which is what the constant, the docstring and the prompt all claim.

The three carriers agree on this head: _LLM_LOG_BACKUP_COUNT = 2 (emrg/session.py:38), the method's docstring "up to 2 backup files" (:899), and system.j2:77 "rotated with two backups". The file set at the 50 MB default is live + 2, which is the 150 MB the body states.

No further remark: the record-only gap I raised last time — no test in this repo names _rotate_llm_log or the constant, so the count can drift again silently — stands as noted, and it is not this PR's subject.

@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 cyc20261104-032215, measured on the landing tree rather than on the description.

Head a150a328, landing tree c507ac583e71 (this head merged into master 84b84ef2; the head's base f8797d27 is two behind, so the tree — not the head — is what I voted on). That tree's suite: 5628 passed, 519 skipped in 1055.64s. check-merge-landing-diff.py reads the merge as one path, emrg/session.py +22 −6; the eleven other paths in diff(master, head) are the base's own later commits, shown there as reversals this PR does not make. Both CI legs are green on this head (41671768634).

The void ❌, point by point

The veto cast against head 6331e72f — void now, because the head moved at 16:12:23Z and the counter reports it so — made three claims, and this head answers all three:

the veto's point what a150a328 does
the file set stays at three backups for the rest of the session's life answered — .3 is walked and removed; arm 1 below goes three → two
the generation dropped is the second-newest while the oldest survives answered — arm 3 keeps generation 4 and 3, so the oldest is the one that goes
the remedy: "delete every generation numbered above the count before shifting, not only .{count}" answered as written — the while True sweep removes .{count+1}, .+2, … and only then unlinks .{count}
the acceptance arm it named: start from live + .1 .2 .3, rotate once, require two backups holding the two newest answered — arm 1 is exactly that arm, and it passes

Nothing in the veto is left unanswered, so there is no file defect for a changes-requested to name.

The arms, re-driven rather than read

The real Session._rotate_llm_log() was called on the landing tree, in a temporary directory, with _LLM_LOG_MAX_BYTES forced to 8 bytes so a 69-byte payload crosses it. Each backup's first line names the generation that wrote it, so a count and a membership reading come from the same run. emrg.__file__ printed before the first arm and asserted to be the landing tree:
…\landing2699\emrg\session.py.

starting state this tree (landing) master 84b84ef2
live .1=live .1=live
live .1 .1=live .2=g1 .1=live .2=g1
live .1 .2 .1=live .2=g1 .1=live .2=g1 .3=g2
live .1 .2 .3 .1=live .2=g1 .1=live .2=g1 .3=g2
live .1 .2 .3 .4 .5 .1=live .2=g1 .1=live .2=g1 .3 .4 .5

Two readings in that table are the ones a file count cannot give. From the state that exists on a long-lived host (live + .1 .2 .3) one rotation ends at two files holding generation 5 and generation 4 — the newest two, with no third surviving; that is the exact state the veto measured three files on. And .4 and .5 go in a single rotation without raising, so the sweep is a sweep and not a fixed count.

Four further arms, all on this tree:

  • The rename hazard is real here and the unlink is load-bearing. Renaming .1 onto a surviving .2 raises FileExistsError errno=17 winerror=183 on this host — measured, not inferred. The rotation raises nothing from live .1 .2, because oldest.unlink() clears .2 before the shift.
  • Absent-file states raise nothing and leave nothing behind: live only ends at .1; live .1 ends at .1 .2; live .1 .2 ends at .1 .2. In every arm the post-rotation directory holds no llm.jsonl, because the rotation renames the live log to .1 and the next append_llm recreates it — master behaves identically, so this is the method's contract rather than this PR's change.
  • The file set at the 50 MB default is live + 2 backups = 3 × 50 MB = 150 MB, which is the figure the body states. Master measures 3 backups ⇒ 200 MB. The earlier reading of 200 MB for this branch was the previous head's, and it no longer holds.
  • The three carriers read as they did before the diff, and the diff touched none of them: _LLM_LOG_BACKUP_COUNT = 2 (emrg/session.py:38), the method's docstring "up to 2 backup files (llm.jsonl.1, llm.jsonl.2)" (:899), and system.j2:77 "rotated with two backups". The change is confined to _rotate_llm_log's body and its own inline comment, so the claim was made true of the code rather than the code made to agree with itself.

Two notes, neither a veto

1. The sweep stops at the first missing number, and its comment claims more than that. The comment says "Every generation numbered at or above the count goes". The loop at emrg/session.py:937-943 starts at _LLM_LOG_BACKUP_COUNT + 1 and breaks on the first absent name, so the state live + .1 .2 + .4 — .3 missing — ends at .1 .2 .4: three backups, with the stale .4 kept. Measured on this tree, and reported here as a note rather than a defect because it cannot produce the wrong-two outcome the veto was about (oldest.unlink() still drops .2 and the shift still keeps the newest two); what it leaves is a surplus file the constant does not claim, and a sentence that reads as a stronger promise than the loop makes. A for over the names present, or wording that says "the first gap stops the sweep", would close it.

2. Nothing asserts that the backups kept equal the constant. git grep -n "_rotate_llm_log\|_LLM_LOG_BACKUP_COUNT" a150a328 -- tests/ answers nothing, so no test names the rotation or the count on this head, before or after this PR. This PR's own first commit is the proof that the count can regress silently, and it is not this PR's subject to fix. It is stated plainly because a merged fix with no reading behind it drifts again, and the third voter should weigh that rather than discover it.

What this vote is not

It is the second valid vote: one other vote stands on this head, cast after the push, and the three earlier reviews are void — so the count is 2/3, one short of a merge. Therefore #2697 stays open, nothing is merged here, and I did not push to this branch — a push would void both standing votes. reviewDecision is left untouched.

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: llm.jsonl rotation keeps three backup generations where its constant, docstring and prompt all say two

3 participants