fix(scheduler): coerce serialized task timestamps (#4785)

* fix(scheduler): coerce serialized task timestamps

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: xsfx20 <15558128926@qq.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
ming1523 2026-08-15 15:34:55 +08:00 committed by GitHub
parent 30a36bd41b
commit e593ad6c14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 60 additions and 2 deletions

View File

@ -27,6 +27,24 @@ def _lease_is_alive(lease_expires_at: datetime | None, *, now: datetime, grace_s
return lease_expires_at >= now - timedelta(seconds=grace_seconds)
def _coerce_datetime(value: datetime | str | None) -> datetime | None:
"""Convert serialized task timestamps back before binding DateTime fields."""
if value is None or isinstance(value, datetime):
return value
if isinstance(value, str):
try:
text = value
if text.endswith("Z"):
text = f"{text[:-1]}+00:00"
dt = datetime.fromisoformat(text)
except ValueError as exc:
raise ValueError(f"invalid scheduled task timestamp: {value!r}") from exc
if dt.tzinfo is None:
return dt.replace(tzinfo=UTC)
return dt.astimezone(UTC)
raise TypeError(f"scheduled task timestamp must be datetime, str, or None: {type(value).__name__}")
class ScheduledTaskRepository:
def __init__(
self,
@ -206,7 +224,7 @@ class ScheduledTaskRepository:
*,
status: str,
next_run_at: datetime | None,
last_run_at: datetime | None,
last_run_at: datetime | str | None,
last_run_id: str | None,
last_thread_id: str | None,
last_error: str | None,
@ -237,7 +255,7 @@ class ScheduledTaskRepository:
row.status = status
row.last_error = last_error
row.next_run_at = next_run_at
row.last_run_at = last_run_at
row.last_run_at = _coerce_datetime(last_run_at)
row.last_run_id = last_run_id
row.last_thread_id = last_thread_id
if increment_run_count:

View File

@ -519,6 +519,46 @@ async def test_update_after_launch_protect_terminal_keeps_hook_result(tmp_path):
await close_engine()
@pytest.mark.asyncio
async def test_update_after_launch_coerces_serialized_last_run_at(tmp_path):
"""Task rows returned by the repository serialize timestamps as ISO strings."""
await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path)))
sf = get_session_factory()
assert sf is not None
repo = ScheduledTaskRepository(sf)
await repo.create(
task_id="task-serialized-timestamp",
user_id="user-1",
thread_id=None,
context_mode="fresh_thread_per_run",
assistant_id="lead_agent",
title="serialized timestamp",
prompt="p",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=datetime(2026, 7, 2, 1, 0, tzinfo=UTC),
)
await repo.update_after_launch(
"task-serialized-timestamp",
status="enabled",
next_run_at=datetime(2026, 7, 3, 1, 0, tzinfo=UTC),
last_run_at="2026-07-02T01:00:00+00:00",
last_run_id="run-serialized",
last_thread_id="thread-serialized",
last_error=None,
increment_run_count=True,
)
task = await repo.get("task-serialized-timestamp", user_id="user-1")
assert task is not None
assert task["last_run_at"] == "2026-07-02T01:00:00+00:00"
await close_engine()
@pytest.mark.asyncio
async def test_list_by_task_paginates(tmp_path):
await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path)))