Summary
_SESSION_MAX_BYTES (2 MB) is enforced in exactly one place — ConversationLog.append, which calls _maybe_rotate. The dashboard persists a slot by rewriting the whole transcript through atomic_write in _save_slot_to_history and never reaches that check, so a transcript written only by the dashboard grows without bound.
docs/system-specs/modules/history.md documents the cap unqualified ("Rotation at 2MB (keeps metadata + last 200 messages, atomic write)"), so the spec and the dashboard write path disagree.
Where
| Anchor |
What |
src/kiro_crew/history.py |
_SESSION_MAX_BYTES = 2 * 1024 * 1024 |
src/kiro_crew/history.py |
_maybe_rotate — the enforcement |
src/kiro_crew/history.py |
its only call site, inside append |
src/kiro_crew/dashboard/chat_persistence.py |
atomic_write(path, payload, fsync=True) — the dashboard write, with no rotation after it |
_MAX_SLOT_MESSAGES in dashboard/state.py bounds the in-memory window only; trimmed rows become frozen-prefix bytes on disk, so it is not a size bound on the file.
Reproduction
Control/experiment, so "no rotation" cannot be confused with "the cap does not work". Both arms write the same shape of data to the same kind of file; only the writer differs.
import pathlib, sys, tempfile
sys.path.insert(0, "src"); sys.path.insert(0, "test")
import kiro_crew.history as H
from kiro_crew.history import ConversationLog
BIG = "z" * 100_000 # 30 messages ~= 3 MB, over the 2 MB cap
N = 30
tmp = pathlib.Path(tempfile.mkdtemp())
# CONTROL: the append path
ctrl = ConversationLog(tmp / "ctrl")
for i in range(N):
ctrl.append("dashboard:control", "user", f"{i}:{BIG}")
print("append :", ctrl._path("dashboard:control").stat().st_size, "bytes")
# EXPERIMENT: the dashboard whole-file save path
import kiro_crew.dashboard.state as dstate
dtmp = tmp / "dash"; dtmp.mkdir(parents=True, exist_ok=True)
dstate.config_dir = lambda: dtmp
from chat_test_helpers import _make_state
from kiro_crew.dashboard.chat_persistence import _save_slot_to_history, slot_history_key
state = _make_state(dtmp); slot = state.get_or_create_slot("bigsession")
for i in range(N):
slot.append("user", f"{i}:{BIG}", "msg")
slot.drain(); _save_slot_to_history(state, slot, force=True)
path = state.conversation_log._path(slot_history_key(slot))
print("dashboard:", path.stat().st_size, "bytes")
Observed:
_SESSION_MAX_BYTES = 2097152
append : 2001763 bytes rotated_at set, under cap
dashboard: 3005449 bytes rotated_at absent, OVER cap
Taking one more ordinary save reaches 6,010,744 bytes = 2.87x the cap, and rotated_at is never set — rotation was not consulted and declined, it was never consulted. The control arm rotating under identical load is what makes this a writer gap rather than a broken cap.
Note on the "200 messages" figure
_SESSION_KEEP_LINES = 200 is the retain count applied after the byte check gates, not an independent cap. 600 small messages through append produce a 600-line file and no rotation. So the bypassed invariant here is the 2 MB byte cap only; a high message count alone does not trigger rotation on any path.
Why a fix is not a one-liner
Calling _maybe_rotate after the dashboard write is necessary but not sufficient. Rotation removes leading messages, which moves the frozen-prefix boundary the slot tracks in _disk_older_count. Left unreconciled, every later save rebuilds its payload from a prefix that is no longer on disk, resurrecting the dropped messages so rotation drops them again.
That damage is invisible in the file — rotation re-trims what was resurrected, so the transcript stays byte-identical and duplicate-free. What changes is the work: measured over 5 ordinary post-rotation saves, the unreconciled version rotates on all 5 and re-archives the same 10 lines each time (50 archived lines vs 0), turning an O(window) steady state into O(file) with unbounded archive growth.
Also noticed
src/kiro_crew/history.py's module docstring says the session cap is 512KB while the constant is 2 MB. Separate stale-doc defect, mentioned only so it is not lost.
rewrite_session is a second whole-file writer with no rotation, but it currently has no callers outside history.py, so it is latent rather than live.
Summary
_SESSION_MAX_BYTES(2 MB) is enforced in exactly one place —ConversationLog.append, which calls_maybe_rotate. The dashboard persists a slot by rewriting the whole transcript throughatomic_writein_save_slot_to_historyand never reaches that check, so a transcript written only by the dashboard grows without bound.docs/system-specs/modules/history.mddocuments the cap unqualified ("Rotation at 2MB (keeps metadata + last 200 messages, atomic write)"), so the spec and the dashboard write path disagree.Where
src/kiro_crew/history.py_SESSION_MAX_BYTES = 2 * 1024 * 1024src/kiro_crew/history.py_maybe_rotate— the enforcementsrc/kiro_crew/history.pyappendsrc/kiro_crew/dashboard/chat_persistence.pyatomic_write(path, payload, fsync=True)— the dashboard write, with no rotation after it_MAX_SLOT_MESSAGESindashboard/state.pybounds the in-memory window only; trimmed rows become frozen-prefix bytes on disk, so it is not a size bound on the file.Reproduction
Control/experiment, so "no rotation" cannot be confused with "the cap does not work". Both arms write the same shape of data to the same kind of file; only the writer differs.
Observed:
Taking one more ordinary save reaches 6,010,744 bytes = 2.87x the cap, and
rotated_atis never set — rotation was not consulted and declined, it was never consulted. The control arm rotating under identical load is what makes this a writer gap rather than a broken cap.Note on the "200 messages" figure
_SESSION_KEEP_LINES = 200is the retain count applied after the byte check gates, not an independent cap. 600 small messages throughappendproduce a 600-line file and no rotation. So the bypassed invariant here is the 2 MB byte cap only; a high message count alone does not trigger rotation on any path.Why a fix is not a one-liner
Calling
_maybe_rotateafter the dashboard write is necessary but not sufficient. Rotation removes leading messages, which moves the frozen-prefix boundary the slot tracks in_disk_older_count. Left unreconciled, every later save rebuilds its payload from a prefix that is no longer on disk, resurrecting the dropped messages so rotation drops them again.That damage is invisible in the file — rotation re-trims what was resurrected, so the transcript stays byte-identical and duplicate-free. What changes is the work: measured over 5 ordinary post-rotation saves, the unreconciled version rotates on all 5 and re-archives the same 10 lines each time (50 archived lines vs 0), turning an O(window) steady state into O(file) with unbounded archive growth.
Also noticed
src/kiro_crew/history.py's module docstring says the session cap is 512KB while the constant is 2 MB. Separate stale-doc defect, mentioned only so it is not lost.rewrite_sessionis a second whole-file writer with no rotation, but it currently has no callers outsidehistory.py, so it is latent rather than live.