Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1037e8e3ab | |||
| 023c0944a7 | |||
| 6fc294b24b | |||
| 85c579b51f | |||
| 876a2c6916 |
@@ -5,6 +5,15 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- **`agents session list` now displays full 26-character session ULIDs** (#10970): The Rich table
|
||||
and Summary panel ("Most Recent" / "Oldest") previously showed only the first 8 characters of
|
||||
each session ULID. This made the output unusable for copy-paste into `session tell`,
|
||||
`session show`, `session delete`, and `session export`, all of which require the full
|
||||
26-character identifier. The full ULID is now displayed in all output formats (Rich, plain,
|
||||
JSON, YAML, table).
|
||||
|
||||
### Security
|
||||
|
||||
- **aiohttp upgraded to >=3.13.4 to remediate CVE-2026-34513 and CVE-2026-34515** (#1549, #1544):
|
||||
@@ -78,6 +87,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`LLMTraceRepository.save()` premature commit breaks UnitOfWork transactions** (#7505):
|
||||
Replaced the unconditional `session.commit()` in `LLMTraceRepository.save()` with a
|
||||
dual-path implementation that respects the UnitOfWork (UoW) pattern. When an external
|
||||
session is provided (UoW mode), the method now calls only `session.flush()`, leaving
|
||||
transaction control to the caller. When no session is provided (standalone mode), the
|
||||
method creates its own session, flushes, commits, and closes it to ensure durable
|
||||
persistence. This eliminates three data-integrity violations: premature commit of outer
|
||||
UoW transactions, loss of rollback capability for subsequent failures, and a mismatch
|
||||
between the class docstring ("Callers are responsible for commit") and the implementation.
|
||||
Input validation for the `trace` argument was also added. Two new BDD scenarios verify
|
||||
the session contract: `Repository save() calls flush not commit` and `LLM trace rolled
|
||||
back when UnitOfWork transaction rolls back`.
|
||||
|
||||
- **git_tools._get_base_env() TOCTOU Race Condition** (#7619): Fixed a
|
||||
Time-Of-Check-To-Time-Of-Use race condition in `git_tools._get_base_env()`
|
||||
where two concurrent threads could both observe `_BASE_ENV is None`, both
|
||||
|
||||
@@ -29,3 +29,4 @@ Below are some of the specific details of various contributions.
|
||||
* HAL 9000 has contributed the git worktree TOCTOU race condition fix (PR #8178 / issue #7507): replaced the unsafe mkdtemp() + rmdir() pattern with a parent-directory approach to eliminate the race window in concurrent git worktree operations.
|
||||
* HAL 9000 has contributed the git_tools TOCTOU race condition fix (PR #8255 / issue #7619): eliminated the Time-Of-Check-To-Time-Of-Use race in `_get_base_env()` by adding double-checked locking with a module-level `threading.Lock`, preventing concurrent threads from writing conflicting environment snapshots.
|
||||
* HAL 9000 has contributed comprehensive milestone documentation for v3.6.0 (Advanced Concepts & Deferred Features) and v3.7.0 (TUI Implementation) (PR #9903): split into sub-documents covering context strategies, LLM backends, resource types, A2A rename, container tool execution, scope chain resolution, cost/safety budgets, E2E workflow tests, code review examples, plugin architecture, TUI layout, persona system, reference/command input, session management, configuration, and TuiMaterializer integration.
|
||||
* HAL 9000 has contributed the LLMTraceRepository data-integrity fix (PR #8185 / issue #7505): replaced the unconditional `session.commit()` in `LLMTraceRepository.save()` with a dual-path implementation that respects the UnitOfWork pattern — flushing only when an external session is provided, and flushing + committing + closing when operating standalone. This eliminates premature transaction commits, loss of rollback capability, and a docstring/implementation mismatch.
|
||||
|
||||
@@ -72,8 +72,8 @@ The `rich` format renders a sessions table with columns: **ID**, **Name**, **Act
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| Total | Number of sessions |
|
||||
| Most Recent | Name or truncated ID of the most recently updated session |
|
||||
| Oldest | Name or truncated ID of the oldest session |
|
||||
| Most Recent | Name or full ULID of the most recently updated session |
|
||||
| Oldest | Name or full ULID of the oldest session |
|
||||
| Total Messages | Sum of messages across all sessions |
|
||||
| Storage | Estimated storage used |
|
||||
|
||||
@@ -85,7 +85,7 @@ Followed by a `✓ OK N sessions listed` success message.
|
||||
{
|
||||
"sessions": [
|
||||
{
|
||||
"id": "01HXYZ...",
|
||||
"id": "01HXYZ4M1Q3F0R0E5HR8K5T8A",
|
||||
"name": "my-session",
|
||||
"actor": "openai/gpt-4",
|
||||
"messages": 5,
|
||||
|
||||
@@ -296,13 +296,13 @@ $ python -m cleveragents session list
|
||||
┏━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓
|
||||
┃ ID ┃ Name ┃ Actor ┃ Messages ┃ Updated ┃
|
||||
┡━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩
|
||||
│ 01KNKK4Q │ (unnamed) │ (none) │ 0 │ 2026-04-07 09:07 │
|
||||
│ 01KNKK4Q9GZ0TRR5B0NEJYGMWH │ (unnamed) │ (none) │ 0 │ 2026-04-07 09:07 │
|
||||
└──────────┴───────────┴────────┴──────────┴──────────────────┘
|
||||
|
||||
╭────────────────────────────────── Summary ───────────────────────────────────╮
|
||||
│ Total: 1 │
|
||||
│ Most Recent: 01KNKK4Q │
|
||||
│ Oldest: 01KNKK4Q │
|
||||
│ Most Recent: 01KNKK4Q9GZ0TRR5B0NEJYGMWH │
|
||||
│ Oldest: 01KNKK4Q9GZ0TRR5B0NEJYGMWH │
|
||||
│ Total Messages: 0 │
|
||||
│ Storage: 0 KB │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
@@ -311,7 +311,7 @@ $ python -m cleveragents session list
|
||||
```
|
||||
|
||||
**What's Happening:**
|
||||
The session list shows all sessions with their truncated ID, optional name, bound actor, message count, and last update time. The summary panel provides aggregate statistics across all sessions.
|
||||
The session list shows all sessions with their full ULID, optional name, bound actor, message count, and last update time. The summary panel provides aggregate statistics across all sessions.
|
||||
|
||||
---
|
||||
|
||||
@@ -339,8 +339,8 @@ $ python -m cleveragents session list --format json
|
||||
],
|
||||
"summary": {
|
||||
"total": 1,
|
||||
"most_recent": "01KNKK4Q",
|
||||
"oldest": "01KNKK4Q",
|
||||
"most_recent": "01KNKK4Q9GZ0TRR5B0NEJYGMWH",
|
||||
"oldest": "01KNKK4Q9GZ0TRR5B0NEJYGMWH",
|
||||
"total_messages": 0,
|
||||
"storage": "0 KB"
|
||||
}
|
||||
@@ -469,7 +469,7 @@ $ python -m cleveragents session list
|
||||
┏━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓
|
||||
┃ ID ┃ Name ┃ Actor ┃ Messages ┃ Updated ┃
|
||||
┡━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩
|
||||
│ 01KNKK4Q │ (unnamed) │ (none) │ 0 │ 2026-04-07 09:07 │
|
||||
│ 01KNKK4Q9GZ0TRR5B0NEJYGMWH │ (unnamed) │ (none) │ 0 │ 2026-04-07 09:07 │
|
||||
└──────────┴───────────┴────────┴──────────┴──────────────────┘
|
||||
✓ OK 1 sessions listed
|
||||
```
|
||||
|
||||
@@ -180,14 +180,14 @@ $ python -m cleveragents session list
|
||||
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
┃ ID ┃ Name ┃ Actor ┃ Messages ┃ Updated ┃
|
||||
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
|
||||
│ 01HXYZ4M │ (unnamed) │ openai/gpt-4o │ 3 │ 2026-04-07 09:22 │
|
||||
│ 01HXYZ3K │ (unnamed) │ (none) │ 0 │ 2026-04-07 09:15 │
|
||||
│ 01HXYZ4M1Q3F0R0E5HR8K5T8A │ (unnamed) │ openai/gpt-4o │ 3 │ 2026-04-07 09:22 │
|
||||
│ 01HXYZ3K9P2E9Q9D4GQ7J4S7Z │ (unnamed) │ (none) │ 0 │ 2026-04-07 09:15 │
|
||||
└──────────┴────────────┴────────────────┴──────────┴──────────────────┘
|
||||
|
||||
╭──────────────────────────────────────────── Summary ─────────────────────────────────────────────╮
|
||||
│ Total: 2 │
|
||||
│ Most Recent: 01HXYZ4M │
|
||||
│ Oldest: 01HXYZ3K │
|
||||
│ Most Recent: 01HXYZ4M1Q3F0R0E5HR8K5T8A │
|
||||
│ Oldest: 01HXYZ3K9P2E9Q9D4GQ7J4S7Z │
|
||||
│ Total Messages: 3 │
|
||||
│ Storage: 0 KB │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
@@ -196,8 +196,8 @@ $ python -m cleveragents session list
|
||||
```
|
||||
|
||||
**What's Happening:**
|
||||
The list command renders a Rich table with five columns: truncated ID (first
|
||||
8 characters for readability), optional name, bound actor, message count, and
|
||||
The list command renders a Rich table with five columns: the full session
|
||||
ULID (26 characters), optional name, bound actor, message count, and
|
||||
last update time. The **Summary** panel below shows aggregate statistics
|
||||
including total sessions, most recent, oldest, total message count, and
|
||||
storage used.
|
||||
@@ -229,8 +229,8 @@ $ python -m cleveragents session list --format json
|
||||
],
|
||||
"summary": {
|
||||
"total": 2,
|
||||
"most_recent": "01HXYZ4M",
|
||||
"oldest": "01HXYZ3K",
|
||||
"most_recent": "01HXYZ4M1Q3F0R0E5HR8K5T8A",
|
||||
"oldest": "01HXYZ3K9P2E9Q9D4GQ7J4S7Z",
|
||||
"total_messages": 3,
|
||||
"storage": "0 KB"
|
||||
}
|
||||
@@ -773,7 +773,7 @@ $ python -m cleveragents session list
|
||||
✓ OK 2 sessions listed
|
||||
|
||||
$ python -m cleveragents session list --format json
|
||||
{"sessions": [...], "summary": {"total": 2, "most_recent": "01HXYZ4M", ...}}
|
||||
{"sessions": [...], "summary": {"total": 2, "most_recent": "01HXYZ4M1Q3F0R0E5HR8K5T8A", ...}}
|
||||
|
||||
$ python -m cleveragents session tell --session 01HXYZ4M1Q3F0R0E5HR8K5T8A "What is the capital of France?"
|
||||
user: What is the capital of France?
|
||||
|
||||
@@ -1723,8 +1723,8 @@ None.
|
||||
╭─ Sessions ───────────────────────────────────────────────────────────────────╮
|
||||
│ <span style="color: cyan; font-weight: 600;">ID</span> <span style="color: cyan; font-weight: 600;">Name</span> <span style="color: cyan; font-weight: 600;">Actor</span> <span style="color: cyan; font-weight: 600;">Messages</span> <span style="color: cyan; font-weight: 600;">Updated</span> │
|
||||
│ <span style="opacity: 0.7;">──────── ─────────────── ────────────────── ──────── ────────────────</span> │
|
||||
│ 01HXM2A6 weekly-planning local/orchestrator 6 2026-02-08 12:44 │
|
||||
│ 01HXM1F2 refactor-sprint local/orchestrator 14 2026-02-07 18:11 │
|
||||
│ 01HXM2A61MQHZ4MRBAY3MPNJTN weekly-planning local/orchestrator 6 2026-02-08 12:44 │
|
||||
│ 01HXM1F21MQHZ4MRBAY3MPNJTN refactor-sprint local/orchestrator 14 2026-02-07 18:11 │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
╭─ Summary ────────────────────╮
|
||||
@@ -1746,8 +1746,8 @@ None.
|
||||
Sessions
|
||||
ID Name Actor Messages Updated
|
||||
-------- --------------- ------------------ -------- ----------------
|
||||
01HXM2A6 weekly-planning local/orchestrator 6 2026-02-08 12:44
|
||||
01HXM1F2 refactor-sprint local/orchestrator 14 2026-02-07 18:11
|
||||
01HXM2A61MQHZ4MRBAY3MPNJTN weekly-planning local/orchestrator 6 2026-02-08 12:44
|
||||
01HXM1F21MQHZ4MRBAY3MPNJTN refactor-sprint local/orchestrator 14 2026-02-07 18:11
|
||||
|
||||
Summary
|
||||
Total: 2
|
||||
@@ -1769,14 +1769,14 @@ None.
|
||||
"data": {
|
||||
"sessions": [
|
||||
{
|
||||
"id": "01HXM2A6",
|
||||
"id": "01HXM2A61MQHZ4MRBAY3MPNJTN",
|
||||
"name": "weekly-planning",
|
||||
"actor": "local/orchestrator",
|
||||
"messages": 6,
|
||||
"updated": "2026-02-08T12:44:00Z"
|
||||
},
|
||||
{
|
||||
"id": "01HXM1F2",
|
||||
"id": "01HXM1F21MQHZ4MRBAY3MPNJTN",
|
||||
"name": "refactor-sprint",
|
||||
"actor": "local/orchestrator",
|
||||
"messages": 14,
|
||||
@@ -1804,12 +1804,12 @@ None.
|
||||
exit_code: 0
|
||||
data:
|
||||
sessions:
|
||||
- id: 01HXM2A6
|
||||
- id: 01HXM2A61MQHZ4MRBAY3MPNJTN
|
||||
name: weekly-planning
|
||||
actor: local/orchestrator
|
||||
messages: 6
|
||||
updated: "2026-02-08T12:44:00Z"
|
||||
- id: 01HXM1F2
|
||||
- id: 01HXM1F21MQHZ4MRBAY3MPNJTN
|
||||
name: refactor-sprint
|
||||
actor: local/orchestrator
|
||||
messages: 14
|
||||
|
||||
@@ -1714,6 +1714,12 @@ Feature: Consolidated Misc
|
||||
And the temporary connection should be closed afterward
|
||||
|
||||
|
||||
Scenario: get_current_revision uses check_same_thread=False for SQLite engines
|
||||
Given a migration runner configured for "sqlite:///:memory:"
|
||||
When I request the current revision from the database
|
||||
Then the SQLite engine for get_current_revision should use check_same_thread=False
|
||||
|
||||
|
||||
Scenario: File-based SQLite database directory is created if missing
|
||||
Given a migration runner configured for "sqlite:///tmp/test-db/mydb.db"
|
||||
When I initialize or upgrade a file-based SQLite database
|
||||
|
||||
@@ -44,6 +44,28 @@ Feature: Session CLI commands
|
||||
When I run session CLI list with --format json
|
||||
Then the session CLI JSON list entries should match the documented contract
|
||||
|
||||
Scenario: List sessions displays full 26-character ULIDs in Rich table
|
||||
Given there are mocked existing sessions
|
||||
When I run session CLI list
|
||||
Then the session CLI rich table should display full session ULIDs
|
||||
|
||||
Scenario: List sessions summary panel shows full ULIDs for unnamed sessions
|
||||
Given there are mocked existing sessions
|
||||
When I run session CLI list
|
||||
Then the session CLI summary panel should contain full session ULIDs
|
||||
|
||||
Scenario: List sessions summary panel shows session names for named sessions
|
||||
Given there are mocked existing named sessions
|
||||
When I run session CLI list
|
||||
Then the session CLI summary panel should show session names
|
||||
|
||||
Scenario: Full session ID from list output works with session tell
|
||||
Given there are mocked existing sessions
|
||||
When I run session CLI list
|
||||
And I capture the first session full ULID from the output
|
||||
And I run session CLI tell with the full session ID and prompt "Hello from list"
|
||||
Then the session CLI tell should succeed
|
||||
|
||||
# Show command tests
|
||||
Scenario: Show session with valid ID
|
||||
Given there is a mocked session with messages
|
||||
|
||||
@@ -809,6 +809,9 @@ class _BrokenSession:
|
||||
def rollback(self) -> None:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
def query(self, *_args: Any, **_kwargs: Any) -> Any:
|
||||
raise SQLAlchemyDatabaseError("mock", {}, Exception("broken"))
|
||||
|
||||
@@ -985,8 +988,10 @@ def step_save_with_spy(context: Context) -> None:
|
||||
object.__setattr__(real_session, "flush", spy_flush)
|
||||
object.__setattr__(real_session, "commit", spy_commit)
|
||||
|
||||
# Pass the session explicitly to test the UoW path: save() must flush
|
||||
# but must NOT commit (the caller owns the transaction boundary).
|
||||
repo = LLMTraceRepository(session_factory=lambda: real_session)
|
||||
repo.save(context.trace)
|
||||
repo.save(context.trace, session=real_session)
|
||||
# Commit so the data is visible for subsequent queries
|
||||
object.__setattr__(real_session, "commit", original_commit)
|
||||
real_session.commit()
|
||||
@@ -1030,7 +1035,9 @@ def step_save_in_uow_rollback(context: Context) -> None:
|
||||
session = context.uow_session_factory()
|
||||
repo = LLMTraceRepository(session_factory=lambda: session)
|
||||
try:
|
||||
repo.save(context.trace)
|
||||
# Pass the session explicitly to use UoW mode: save() flushes but
|
||||
# does NOT commit, so the caller's rollback can undo the change.
|
||||
repo.save(context.trace, session=session)
|
||||
# Simulate a subsequent failure that triggers rollback
|
||||
raise RuntimeError("Simulated failure after save")
|
||||
except RuntimeError:
|
||||
|
||||
@@ -316,6 +316,17 @@ def step_then_temp_connection_closed(context) -> None:
|
||||
assert context.current_rev_fake_engine.connections[0].exit_called is True
|
||||
|
||||
|
||||
@then("the SQLite engine for get_current_revision should use check_same_thread=False")
|
||||
def step_then_get_current_revision_check_same_thread(context) -> None:
|
||||
_url, kwargs = context.current_rev_create_call
|
||||
assert "connect_args" in kwargs, (
|
||||
"Expected connect_args to be passed to create_engine for SQLite"
|
||||
)
|
||||
assert kwargs["connect_args"].get("check_same_thread") is False, (
|
||||
"Expected check_same_thread=False in connect_args for SQLite engine"
|
||||
)
|
||||
|
||||
|
||||
@when("I initialize or upgrade a file-based SQLite database")
|
||||
def step_when_init_file_based_sqlite(context) -> None:
|
||||
import shutil
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
@@ -160,14 +161,34 @@ def step_existing_sessions(context: Context) -> None:
|
||||
context.mock_service.list.return_value = sessions
|
||||
|
||||
|
||||
@given("there are mocked existing named sessions")
|
||||
def step_existing_named_sessions(context: Context) -> None:
|
||||
"""Set up mocked sessions with names for Summary panel name-display test."""
|
||||
sessions = [
|
||||
_make_session(
|
||||
session_id=_SESSION_ID,
|
||||
actor_name="openai/gpt-4",
|
||||
messages=[_make_message(sequence=0)],
|
||||
),
|
||||
_make_session(session_id=_SESSION_ID_2),
|
||||
]
|
||||
sessions[0].name = "weekly-planning"
|
||||
sessions[1].name = "refactor-sprint"
|
||||
context.mock_service.list.return_value = sessions
|
||||
|
||||
|
||||
@when("I run session CLI list")
|
||||
def step_list(context: Context) -> None:
|
||||
context.result = context.runner.invoke(session_app, ["list"])
|
||||
context.result = context.runner.invoke(
|
||||
session_app, ["list"], env={"COLUMNS": "200"}
|
||||
)
|
||||
|
||||
|
||||
@when("I run session CLI list with --format json")
|
||||
def step_list_json(context: Context) -> None:
|
||||
context.result = context.runner.invoke(session_app, ["list", "--format", "json"])
|
||||
context.result = context.runner.invoke(
|
||||
session_app, ["list", "--format", "json"], env={"COLUMNS": "200"}
|
||||
)
|
||||
|
||||
|
||||
@then("the session CLI should show all sessions in a table")
|
||||
@@ -176,6 +197,125 @@ def step_list_shows_table(context: Context) -> None:
|
||||
assert "Sessions" in context.result.output
|
||||
|
||||
|
||||
@then("the session CLI rich table should display full session ULIDs")
|
||||
def step_list_rich_table_full_ulids(context: Context) -> None:
|
||||
"""Verify the Rich table displays the full 26-character session ULIDs."""
|
||||
assert context.result.exit_code == 0
|
||||
output = context.result.output
|
||||
# Restrict assertion to the table region (before the Summary panel) so
|
||||
# that a regression back to 8-char truncation is not masked by the full
|
||||
# ULID appearing elsewhere (e.g. in the Summary panel).
|
||||
summary_idx = output.find("Summary")
|
||||
table_output = output[:summary_idx] if summary_idx != -1 else output
|
||||
assert _SESSION_ID in table_output, (
|
||||
f"Full ULID {_SESSION_ID} not found in Rich table output:\n"
|
||||
f"{context.result.output}"
|
||||
)
|
||||
assert _SESSION_ID_2 in table_output, (
|
||||
f"Full ULID {_SESSION_ID_2} not found in Rich table output:\n"
|
||||
f"{context.result.output}"
|
||||
)
|
||||
# Negative guard: ensure the table contains full 26-character ULIDs,
|
||||
# not the old 8-character truncated form. A standalone 8-char prefix
|
||||
# (not as part of the full ULID) would indicate the [:8] slice was not
|
||||
# removed.
|
||||
table_without_full_ids = table_output.replace(_SESSION_ID, "").replace(
|
||||
_SESSION_ID_2, ""
|
||||
)
|
||||
assert _SESSION_ID[:8] not in table_without_full_ids, (
|
||||
f"Truncated 8-char ID {_SESSION_ID[:8]} found in Rich table output:\n"
|
||||
f"{context.result.output}"
|
||||
)
|
||||
assert _SESSION_ID_2[:8] not in table_without_full_ids, (
|
||||
f"Truncated 8-char ID {_SESSION_ID_2[:8]} found in Rich table output:\n"
|
||||
f"{context.result.output}"
|
||||
)
|
||||
|
||||
|
||||
@then("the session CLI summary panel should contain full session ULIDs")
|
||||
def step_list_summary_full_ulids(context: Context) -> None:
|
||||
"""Verify the Summary panel shows full ULIDs for unnamed sessions."""
|
||||
assert context.result.exit_code == 0
|
||||
output = context.result.output
|
||||
assert "Summary" in output, f"Summary panel not found in output:\n{output}"
|
||||
# Extract the Summary panel region to avoid a false pass from the Rich
|
||||
# table also containing the same full ULIDs.
|
||||
summary_idx = output.find("Summary")
|
||||
summary_output = output[summary_idx:]
|
||||
# Both Most Recent and Oldest entries should display full ULIDs when
|
||||
# sessions are unnamed. Asserting only one ID would allow a regression
|
||||
# that re-introduced [:8] on one fallback path while keeping the other
|
||||
# intact to pass the test undetected.
|
||||
assert _SESSION_ID in summary_output, (
|
||||
f"Full ULID {_SESSION_ID} not found in Summary panel:\n{output}"
|
||||
)
|
||||
assert _SESSION_ID_2 in summary_output, (
|
||||
f"Full ULID {_SESSION_ID_2} not found in Summary panel:\n{output}"
|
||||
)
|
||||
|
||||
|
||||
@then("the session CLI summary panel should show session names")
|
||||
def step_list_summary_shows_names(context: Context) -> None:
|
||||
"""Verify the Summary panel shows session names (not ULIDs) for named sessions."""
|
||||
assert context.result.exit_code == 0
|
||||
output = context.result.output
|
||||
summary_idx = output.find("Summary")
|
||||
assert summary_idx != -1, f"Summary panel not found in output:\n{output}"
|
||||
summary_output = output[summary_idx:]
|
||||
assert "weekly-planning" in summary_output, (
|
||||
f"'weekly-planning' not found in Summary panel:\n{output}"
|
||||
)
|
||||
assert "refactor-sprint" in summary_output, (
|
||||
f"'refactor-sprint' not found in Summary panel:\n{output}"
|
||||
)
|
||||
# The Summary should NOT show the raw ULID when session names are present
|
||||
assert _SESSION_ID not in summary_output, (
|
||||
f"Full ULID {_SESSION_ID} unexpectedly found in Summary panel:\n{output}"
|
||||
)
|
||||
assert _SESSION_ID_2 not in summary_output, (
|
||||
f"Full ULID {_SESSION_ID_2} unexpectedly found in Summary panel:\n{output}"
|
||||
)
|
||||
|
||||
|
||||
@when("I capture the first session full ULID from the output")
|
||||
def step_capture_first_ulid(context: Context) -> None:
|
||||
"""Parse the first session's full ULID from the output and store it
|
||||
for subsequent steps (round-trip tell test)."""
|
||||
assert context.result.exit_code == 0
|
||||
output = context.result.output
|
||||
# Restrict the search to the table region (before the Summary panel)
|
||||
# so that a ULID appearing in the Summary panel is not accidentally
|
||||
# captured as the "first" session ID.
|
||||
summary_idx = output.find("Summary")
|
||||
search_region = output[:summary_idx] if summary_idx != -1 else output
|
||||
match = re.search(r"[0-9A-HJKMNP-TV-Z]{26}", search_region)
|
||||
assert match is not None, (
|
||||
f"No 26-character ULID found in table output:\n{search_region}"
|
||||
)
|
||||
context.full_session_id = match.group()
|
||||
assert len(context.full_session_id) == 26, (
|
||||
f"Parsed ULID '{context.full_session_id}' is not 26 characters"
|
||||
)
|
||||
# Sanity check: the captured ID should match one of the known fixture IDs
|
||||
assert context.full_session_id in (_SESSION_ID, _SESSION_ID_2), (
|
||||
f"Captured ULID '{context.full_session_id}' does not match any fixture ID"
|
||||
)
|
||||
|
||||
|
||||
@when('I run session CLI tell with the full session ID and prompt "{prompt}"')
|
||||
def step_tell_with_stored_ulid(context: Context, prompt: str) -> None:
|
||||
"""Run session tell using the full ULID stored from session list."""
|
||||
session_id = context.full_session_id
|
||||
context.mock_service.append_message.side_effect = [
|
||||
_make_message(MessageRole.USER, prompt, 0),
|
||||
_make_message(MessageRole.ASSISTANT, f"Acknowledged: {prompt}", 1),
|
||||
]
|
||||
context.result = context.runner.invoke(
|
||||
session_app,
|
||||
["tell", "--session", session_id, prompt],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Show
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Steps for TDD Issue #10507 — get_current_revision() SQLite threading fix."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from behave import then, when
|
||||
|
||||
|
||||
def _run_get_current_revision_and_capture_kwargs(
|
||||
context: Any,
|
||||
) -> None:
|
||||
"""Shared helper: call get_current_revision() and capture create_engine kwargs.
|
||||
|
||||
Mocks ``create_engine`` and ``MigrationContext.configure`` so the call
|
||||
completes without a real database. The keyword arguments passed to
|
||||
``create_engine`` are stored on ``context.engine_creation_kwargs`` for
|
||||
subsequent assertion steps.
|
||||
"""
|
||||
fake_engine = MagicMock()
|
||||
fake_connection = MagicMock()
|
||||
fake_connection.__enter__ = MagicMock(return_value=fake_connection)
|
||||
fake_connection.__exit__ = MagicMock(return_value=False)
|
||||
fake_engine.connect.return_value = fake_connection
|
||||
|
||||
migration_ctx = MagicMock()
|
||||
migration_ctx.get_current_revision.return_value = None
|
||||
|
||||
captured_kwargs: list[dict[str, Any]] = []
|
||||
|
||||
def fake_create_engine(url: str, **kwargs: Any) -> MagicMock:
|
||||
captured_kwargs.append(kwargs)
|
||||
return fake_engine
|
||||
|
||||
with (
|
||||
patch(
|
||||
"cleveragents.infrastructure.database.migration_runner.create_engine",
|
||||
side_effect=fake_create_engine,
|
||||
),
|
||||
patch(
|
||||
"cleveragents.infrastructure.database.migration_runner.MigrationContext.configure",
|
||||
return_value=migration_ctx,
|
||||
),
|
||||
):
|
||||
context.revision_result = context.runner.get_current_revision()
|
||||
|
||||
context.engine_creation_kwargs = captured_kwargs
|
||||
|
||||
|
||||
@when("I request the current revision and capture the engine creation args")
|
||||
def step_when_capture_engine_args(context: Any) -> None:
|
||||
"""Call get_current_revision and capture the create_engine call arguments."""
|
||||
_run_get_current_revision_and_capture_kwargs(context)
|
||||
|
||||
|
||||
@then("the SQLite engine should be created with check_same_thread set to False")
|
||||
def step_then_sqlite_engine_has_check_same_thread(context: Any) -> None:
|
||||
"""Verify the SQLite engine was created with check_same_thread=False."""
|
||||
assert len(context.engine_creation_kwargs) == 1, (
|
||||
f"Expected exactly 1 create_engine call, got {len(context.engine_creation_kwargs)}"
|
||||
)
|
||||
kwargs = context.engine_creation_kwargs[0]
|
||||
assert "connect_args" in kwargs, (
|
||||
"Expected connect_args in create_engine kwargs for SQLite, "
|
||||
f"but got kwargs: {kwargs}"
|
||||
)
|
||||
assert kwargs["connect_args"].get("check_same_thread") is False, (
|
||||
"Expected check_same_thread=False in connect_args, "
|
||||
f"but got: {kwargs['connect_args']}"
|
||||
)
|
||||
|
||||
|
||||
@then("the non-SQLite engine should be created without check_same_thread")
|
||||
def step_then_non_sqlite_engine_no_check_same_thread(context: Any) -> None:
|
||||
"""Verify non-SQLite engines are not given check_same_thread."""
|
||||
assert len(context.engine_creation_kwargs) == 1, (
|
||||
f"Expected exactly 1 create_engine call, got {len(context.engine_creation_kwargs)}"
|
||||
)
|
||||
kwargs = context.engine_creation_kwargs[0]
|
||||
connect_args = kwargs.get("connect_args", {})
|
||||
assert "check_same_thread" not in connect_args, (
|
||||
"Expected check_same_thread to be absent for non-SQLite engine, "
|
||||
f"but got connect_args: {connect_args}"
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
@tdd_issue @tdd_issue_10507
|
||||
Feature: TDD Issue #10507 — get_current_revision() must pass check_same_thread=False for SQLite
|
||||
As a developer using MigrationRunner in a multi-threaded application
|
||||
I want get_current_revision() to work safely from background threads
|
||||
So that async startup flows and background migration checks do not crash
|
||||
|
||||
The root cause is that MigrationRunner.get_current_revision() calls
|
||||
create_engine(self.database_url) without connect_args={"check_same_thread": False}
|
||||
for SQLite databases. When called from a thread other than the one that
|
||||
created the engine, SQLite raises:
|
||||
ProgrammingError: SQLite objects created in a thread can only be
|
||||
used in that same thread.
|
||||
|
||||
The sibling method init_or_upgrade() already passes check_same_thread=False
|
||||
for SQLite, making this an inconsistency in the same class. Because
|
||||
get_pending_migrations() and check_migrations_needed() both delegate to
|
||||
get_current_revision(), the threading bug propagates to all three methods.
|
||||
|
||||
The fix adds connect_args={"check_same_thread": False} to the create_engine()
|
||||
call in get_current_revision() when the database URL starts with "sqlite",
|
||||
consistent with the existing pattern in init_or_upgrade().
|
||||
|
||||
Scenario: get_current_revision passes check_same_thread=False for SQLite engine
|
||||
Given a migration runner configured for "sqlite:///:memory:"
|
||||
When I request the current revision and capture the engine creation args
|
||||
Then the SQLite engine should be created with check_same_thread set to False
|
||||
|
||||
Scenario: get_current_revision does not pass check_same_thread for non-SQLite engine
|
||||
Given a migration runner configured for "postgresql://user:pass@localhost/testdb"
|
||||
When I request the current revision and capture the engine creation args
|
||||
Then the non-SQLite engine should be created without check_same_thread
|
||||
@@ -151,8 +151,8 @@ def _session_list_dict(sessions: list[Session]) -> dict[str, Any]:
|
||||
# Find most recent and oldest sessions
|
||||
if sessions:
|
||||
sorted_sessions = sorted(sessions, key=lambda x: x.updated_at, reverse=True)
|
||||
most_recent = sorted_sessions[0].name or sorted_sessions[0].session_id[:8]
|
||||
oldest = sorted_sessions[-1].name or sorted_sessions[-1].session_id[:8]
|
||||
most_recent = sorted_sessions[0].name or sorted_sessions[0].session_id
|
||||
oldest = sorted_sessions[-1].name or sorted_sessions[-1].session_id
|
||||
else:
|
||||
most_recent = None
|
||||
oldest = None
|
||||
@@ -347,7 +347,7 @@ def list_sessions(
|
||||
|
||||
for s in sessions:
|
||||
table.add_row(
|
||||
s.session_id[:8], # Truncate ID for readability
|
||||
s.session_id, # Full ULID for copy-paste compatibility with session tell
|
||||
s.name or "(unnamed)",
|
||||
s.actor_name or "(none)",
|
||||
str(s.message_count),
|
||||
|
||||
@@ -31,6 +31,17 @@ class LLMTraceRepository:
|
||||
|
||||
Uses the session-factory pattern: each public method obtains a
|
||||
session from the factory. Callers are responsible for commit.
|
||||
|
||||
When ``save()`` is called with an explicit ``session`` argument the
|
||||
repository operates in *UnitOfWork mode*: it flushes the change into
|
||||
the caller's transaction but does **not** commit or close the session.
|
||||
The caller (or the enclosing ``UnitOfWork``) is responsible for the
|
||||
final commit.
|
||||
|
||||
When ``save()`` is called without an explicit ``session`` argument the
|
||||
repository operates in *standalone mode*: it creates its own session
|
||||
from the factory, flushes, commits, and closes the session so that the
|
||||
trace is durably persisted even outside a ``UnitOfWork``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -46,16 +57,26 @@ class LLMTraceRepository:
|
||||
return self._sf()
|
||||
|
||||
@database_retry
|
||||
def save(self, trace: LLMTrace) -> None:
|
||||
def save(self, trace: LLMTrace, session: Session | None = None) -> None:
|
||||
"""Persist a single ``LLMTrace`` row.
|
||||
|
||||
Args:
|
||||
trace: The trace to persist.
|
||||
trace: The trace to persist. Must not be ``None``.
|
||||
session: Optional external SQLAlchemy session. When provided
|
||||
the repository flushes into the caller's transaction and
|
||||
does **not** commit or close the session (UnitOfWork mode).
|
||||
When omitted the repository creates its own session, commits,
|
||||
and closes it (standalone mode).
|
||||
|
||||
Raises:
|
||||
ValueError: If ``trace`` is ``None``.
|
||||
DatabaseError: On unrecoverable persistence failure.
|
||||
"""
|
||||
session = self._session()
|
||||
if trace is None:
|
||||
raise ValueError("trace must not be None")
|
||||
|
||||
own_session = session is None
|
||||
s: Session = self._session() if own_session else session
|
||||
try:
|
||||
model = LLMTraceModel(
|
||||
trace_id=trace.trace_id,
|
||||
@@ -77,11 +98,16 @@ class LLMTraceRepository:
|
||||
error=trace.error,
|
||||
timestamp=trace.timestamp.isoformat(),
|
||||
)
|
||||
session.add(model)
|
||||
session.flush()
|
||||
s.add(model)
|
||||
s.flush()
|
||||
if own_session:
|
||||
s.commit()
|
||||
except (SQLAlchemyDatabaseError, OperationalError) as exc:
|
||||
session.rollback()
|
||||
s.rollback()
|
||||
raise DatabaseError(f"Failed to save LLM trace: {exc}") from exc
|
||||
finally:
|
||||
if own_session:
|
||||
s.close()
|
||||
|
||||
@database_retry
|
||||
def get(self, trace_id: str) -> LLMTrace | None:
|
||||
|
||||
@@ -151,10 +151,22 @@ class MigrationRunner:
|
||||
def get_current_revision(self) -> str | None:
|
||||
"""Get the current migration revision of the database.
|
||||
|
||||
For SQLite databases, the engine is created with
|
||||
``connect_args={"check_same_thread": False}`` so that this method
|
||||
can be safely called from any thread — including background threads
|
||||
used in async startup flows. This is consistent with the pattern
|
||||
used in :meth:`init_or_upgrade`.
|
||||
|
||||
Returns:
|
||||
Current revision ID or None if no migrations have been applied
|
||||
"""
|
||||
engine = create_engine(self.database_url)
|
||||
if self.database_url.startswith("sqlite"):
|
||||
engine = create_engine(
|
||||
self.database_url,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
else:
|
||||
engine = create_engine(self.database_url)
|
||||
with engine.connect() as connection:
|
||||
context = MigrationContext.configure(connection)
|
||||
return context.get_current_revision()
|
||||
|
||||
Reference in New Issue
Block a user