Skip to content

Commit 25dc830

Browse files
fix: preserve conversation updated_at across server restarts
save_meta() was unconditionally stamping updated_at = utc_now() on every call. On startup ConversationService.__aenter__ restores every persisted conversation by calling _start_event_service → event_service.save_meta(), so all conversations ended up with updated_at set to the restart time. Fix: - Remove the updated_at mutation from save_meta(); it should only persist the current in-memory state. - Explicitly set updated_at in update_conversation(), which is the one place that makes a genuine metadata change (the implicit side-effect via save_meta() was the only thing keeping it working before). updated_at continues to be stamped in _EventSubscriber.__call__ whenever a conversation event is emitted, which is the correct behaviour. Co-authored-by: openhands <openhands@all-hands.dev>
1 parent 40e5c52 commit 25dc830

File tree

4 files changed

+61
-3
lines changed

4 files changed

+61
-3
lines changed

openhands-agent-server/openhands/agent_server/conversation_service.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,8 +348,9 @@ async def update_conversation(
348348
if event_service is None:
349349
return False
350350

351-
# Update the title in stored conversation
351+
# Update the title and timestamp in stored conversation
352352
event_service.stored.title = request.title.strip()
353+
event_service.stored.updated_at = utc_now()
353354
# Save the updated metadata to disk
354355
await event_service.save_meta()
355356

openhands-agent-server/openhands/agent_server/event_service.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
StoredConversation,
1212
)
1313
from openhands.agent_server.pub_sub import PubSub, Subscriber
14-
from openhands.agent_server.utils import utc_now
1514
from openhands.sdk import LLM, Agent, AgentBase, Event, Message, get_logger
1615
from openhands.sdk.conversation.impl.local_conversation import LocalConversation
1716
from openhands.sdk.conversation.secret_registry import SecretValue
@@ -62,7 +61,6 @@ async def load_meta(self):
6261
)
6362

6463
async def save_meta(self):
65-
self.stored.updated_at = utc_now()
6664
meta_file = self.conversation_dir / "meta.json"
6765
meta_file.write_text(
6866
self.stored.model_dump_json(

tests/agent_server/test_conversation_service.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1033,6 +1033,37 @@ async def test_update_conversation_multiple_times(
10331033
# Verify save_meta was called three times
10341034
assert mock_service.save_meta.call_count == 3
10351035

1036+
@pytest.mark.asyncio
1037+
async def test_update_conversation_sets_updated_at(
1038+
self, conversation_service, sample_stored_conversation
1039+
):
1040+
"""Test that update_conversation advances updated_at.
1041+
1042+
Renaming a conversation is a meaningful change; the timestamp must
1043+
reflect when it happened rather than staying at the value set at
1044+
conversation creation time.
1045+
"""
1046+
mock_service = AsyncMock(spec=EventService)
1047+
mock_service.stored = sample_stored_conversation
1048+
mock_state = ConversationState(
1049+
id=sample_stored_conversation.id,
1050+
agent=sample_stored_conversation.agent,
1051+
workspace=sample_stored_conversation.workspace,
1052+
execution_status=ConversationExecutionStatus.IDLE,
1053+
confirmation_policy=sample_stored_conversation.confirmation_policy,
1054+
)
1055+
mock_service.get_state.return_value = mock_state
1056+
1057+
conversation_id = sample_stored_conversation.id
1058+
conversation_service._event_services[conversation_id] = mock_service
1059+
1060+
original_updated_at = mock_service.stored.updated_at
1061+
1062+
request = UpdateConversationRequest(title="New Title")
1063+
await conversation_service.update_conversation(conversation_id, request)
1064+
1065+
assert mock_service.stored.updated_at > original_updated_at
1066+
10361067

10371068
class TestConversationServiceDeleteConversation:
10381069
"""Test cases for ConversationService.delete_conversation method."""

tests/agent_server/test_event_service.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1172,6 +1172,34 @@ async def test_run_publishes_state_update_on_error(self, event_service):
11721172
event_service._publish_state_update.assert_called()
11731173

11741174

1175+
class TestEventServiceSaveMeta:
1176+
"""Test cases for EventService.save_meta method."""
1177+
1178+
@pytest.mark.asyncio
1179+
async def test_save_meta_preserves_updated_at(self, event_service, tmp_path):
1180+
"""Test that save_meta does not modify updated_at.
1181+
1182+
On server restart every conversation's save_meta is called. Before the
1183+
fix, save_meta stamped updated_at = utc_now(), so all conversations
1184+
appeared to have been updated at restart time.
1185+
"""
1186+
original_updated_at = datetime(2025, 1, 1, 12, 30, 0, tzinfo=UTC)
1187+
event_service.stored.updated_at = original_updated_at
1188+
event_service.conversations_dir = tmp_path
1189+
conv_dir = tmp_path / event_service.stored.id.hex
1190+
conv_dir.mkdir(parents=True, exist_ok=True)
1191+
1192+
await event_service.save_meta()
1193+
1194+
# In-memory value must be unchanged
1195+
assert event_service.stored.updated_at == original_updated_at
1196+
1197+
# Persisted value must also match
1198+
meta_file = conv_dir / "meta.json"
1199+
loaded = StoredConversation.model_validate_json(meta_file.read_text())
1200+
assert loaded.updated_at == original_updated_at
1201+
1202+
11751203
class TestEventServiceStartWithRunningStatus:
11761204
"""Test cases for EventService.start handling of RUNNING execution status."""
11771205

0 commit comments

Comments
 (0)