Skip to content

Commit ebb97d9

Browse files
grp06Siddhant-K-codeona-agent
authored
Fix latest session selection by start time (#80)
* Fix latest session selection by start time * chore: bump version to 0.38.1 Co-authored-by: Ona <no-reply@ona.com> --------- Co-authored-by: Siddhant Khare <Siddhantkhare2694@gmail.com> Co-authored-by: Ona <no-reply@ona.com>
1 parent d9a7488 commit ebb97d9

6 files changed

Lines changed: 87 additions & 11 deletions

File tree

ADRs/0002-ndjson-file-storage-no-database.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,5 @@ The default trace directory is `.agent-traces/` relative to the working director
2828
- **Zero dependencies** — NDJSON is readable with `cat`, `grep`, `jq`, or any JSON parser in any language.
2929
- **Append-safe** — file appends are atomic at the OS level for writes smaller than the filesystem block size (~4KB). A single NDJSON line is always well under this limit.
3030
- **No locking required** — Claude Code fires hooks sequentially, so concurrent writes from the same session are not expected.
31-
- **No indexing** — session listing is O(n) directory iteration. Prefix matching for session IDs is O(n) over sessions. Acceptable for tens to hundreds of sessions.
32-
- **Session listing is sorted by directory name** (reverse-alphabetical), not by `started_at` timestamp. UUID hex strings are not time-ordered, so the "latest" session is determined by sort order, not creation time. This is a known simplification.
31+
- **No indexing** — session listing is O(n) directory iteration. Loaded metadata is sorted newest-first by `started_at`, with descending session ID as a deterministic tie-breaker. Prefix matching for session IDs remains O(n) over sessions. Acceptable for tens to hundreds of sessions.
3332
- **No compaction or rotation** — traces grow indefinitely. Cleanup is manual.

src/agent_trace/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""agent-trace: strace for AI agents."""
22

3-
__version__ = "0.38.0"
3+
__version__ = "0.38.1"

src/agent_trace/freshness.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,7 @@ def analyse_freshness(
172172
) -> FreshnessReport:
173173
"""Compute context freshness relative to the last session."""
174174
# Find last session timestamp
175-
all_metas = store.list_sessions()
176-
last_meta = all_metas[-1] if all_metas else None
175+
last_meta = store.get_latest_session()
177176
last_ts = last_meta.started_at if last_meta else None
178177
last_sid = last_meta.session_id if last_meta else ""
179178

src/agent_trace/store.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,23 +57,36 @@ def load_events(self, session_id: str) -> list[TraceEvent]:
5757
return events
5858

5959
def list_sessions(self) -> list[SessionMeta]:
60+
"""Return valid sessions sorted newest first by started_at, then descending session ID."""
6061
if not self.base_dir.exists():
6162
return []
6263
sessions = []
63-
for d in sorted(self.base_dir.iterdir(), reverse=True):
64+
for d in self.base_dir.iterdir():
6465
meta_file = d / "meta.json"
6566
if meta_file.exists():
6667
try:
6768
sessions.append(SessionMeta.from_json(meta_file.read_text()))
6869
except (json.JSONDecodeError, TypeError):
6970
continue
70-
return sessions
71-
72-
def get_latest_session_id(self) -> str | None:
71+
return sorted(
72+
sessions,
73+
key=lambda meta: (meta.started_at, meta.session_id),
74+
reverse=True,
75+
)
76+
77+
def get_latest_session(self) -> SessionMeta | None:
78+
"""Return the newest session metadata, or None when the store is empty."""
7379
sessions = self.list_sessions()
7480
if not sessions:
7581
return None
76-
return sessions[0].session_id
82+
return sessions[0]
83+
84+
def get_latest_session_id(self) -> str | None:
85+
"""Return the newest session ID, or None when the store is empty."""
86+
latest = self.get_latest_session()
87+
if not latest:
88+
return None
89+
return latest.session_id
7790

7891
def session_exists(self, session_id: str) -> bool:
7992
return (self._session_dir(session_id) / "meta.json").exists()

tests/test_freshness.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,18 @@ def test_analyse_freshness_with_session(self):
7272
self.assertGreaterEqual(report.freshness_score, 0)
7373
self.assertLessEqual(report.freshness_score, 100)
7474

75+
def test_analyse_freshness_uses_newest_session_by_started_at(self):
76+
from agent_trace.freshness import analyse_freshness
77+
store = _make_store(self._tmp)
78+
old = SessionMeta(session_id="aa-old", started_at=1.0)
79+
new = SessionMeta(session_id="zz-new", started_at=2.0)
80+
store.create_session(old)
81+
store.create_session(new)
82+
83+
report = analyse_freshness(store, repo=self._tmp)
84+
self.assertEqual(report.last_session_id, "zz-new")
85+
self.assertEqual(report.last_session_ts, 2.0)
86+
7587
def test_freshness_score_100_when_no_changes(self):
7688
from agent_trace.freshness import analyse_freshness
7789
store = _make_store(self._tmp)
@@ -104,4 +116,3 @@ def test_cli_has_freshness_command(self):
104116
self.assertEqual(args.since, "2026-01-01")
105117
self.assertEqual(args.scope, "src/**")
106118

107-

tests/test_store.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,60 @@ def test_list_sessions(self):
5959
sessions = self.store.list_sessions()
6060
self.assertEqual(len(sessions), 2)
6161

62+
def test_list_sessions_sorted_newest_first_by_started_at(self):
63+
old = SessionMeta(session_id="zz-old", started_at=1.0)
64+
new = SessionMeta(session_id="aa-new", started_at=2.0)
65+
self.store.create_session(old)
66+
self.store.create_session(new)
67+
68+
sessions = self.store.list_sessions()
69+
self.assertEqual(
70+
[(m.session_id, m.started_at) for m in sessions],
71+
[("aa-new", 2.0), ("zz-old", 1.0)],
72+
)
73+
74+
def test_list_sessions_uses_session_id_tiebreaker(self):
75+
lower = SessionMeta(session_id="aa-same", started_at=1.0)
76+
higher = SessionMeta(session_id="zz-same", started_at=1.0)
77+
self.store.create_session(lower)
78+
self.store.create_session(higher)
79+
80+
sessions = self.store.list_sessions()
81+
self.assertEqual([m.session_id for m in sessions], ["zz-same", "aa-same"])
82+
83+
def test_list_sessions_skips_malformed_metadata(self):
84+
valid = SessionMeta(session_id="valid", started_at=1.0)
85+
self.store.create_session(valid)
86+
87+
malformed_dir = os.path.join(self.tmpdir, "malformed")
88+
os.makedirs(malformed_dir)
89+
with open(os.path.join(malformed_dir, "meta.json"), "w") as f:
90+
f.write("{not json")
91+
with open(os.path.join(self.tmpdir, "loose-file"), "w") as f:
92+
f.write("ignored")
93+
94+
sessions = self.store.list_sessions()
95+
self.assertEqual([m.session_id for m in sessions], ["valid"])
96+
97+
def test_get_latest_session_returns_newest_meta(self):
98+
old = SessionMeta(session_id="zz-old", started_at=1.0)
99+
new = SessionMeta(session_id="aa-new", started_at=2.0)
100+
self.store.create_session(old)
101+
self.store.create_session(new)
102+
103+
latest = self.store.get_latest_session()
104+
self.assertIsNotNone(latest)
105+
self.assertEqual(latest.session_id, "aa-new")
106+
self.assertEqual(latest.started_at, 2.0)
107+
108+
def test_get_latest_session_id_uses_started_at_not_session_id(self):
109+
old = SessionMeta(session_id="zz-old", started_at=1.0)
110+
new = SessionMeta(session_id="aa-new", started_at=2.0)
111+
self.store.create_session(old)
112+
self.store.create_session(new)
113+
114+
self.assertEqual(self.store.get_latest_session_id(), "aa-new")
115+
62116
def test_find_session_by_prefix(self):
63117
meta = SessionMeta()
64118
self.store.create_session(meta)

0 commit comments

Comments
 (0)