Hyeonsang Cho 787b44c74f
fix(client): count threads, not checkpoints, in list_threads (#6426)
* fix(client): count threads, not checkpoints, in list_threads

checkpointer.list(None, limit=N) returns the newest N checkpoints across
every thread, so one long conversation filled the limit and hid the rest:
the TUI thread picker and --resume <title> only saw the latest one or two
threads. List threads through their single step -1 root checkpoint
instead, then read the latest checkpoint per returned thread.

Add sort_by="updated_at" so --continue keeps resuming the most recently
active thread rather than the most recently created one.

* test(client): pin list_threads scan cost and namespace filtering; docs

* docs(client): state list_threads cost as checkpoints loaded, not rows scanned

* docs(changelog): reference #6426

* fix(client): list threads by checkpoint write order, including branches

Discovering threads by their step -1 input checkpoint dropped Gateway
branches, which are seeded through update_state (step 0), and ordering
by checkpoint ts missed goal writes, which copy the previous ts under a
fresh checkpoint id.

Enumerate threads from the checkpoint primary-key index on SQLite and
Postgres (unwrapping the delta-history cache), falling back to one
list(None) walk for any other saver, and order by checkpoint_id, the
key LangGraph itself uses for a thread's latest checkpoint.
2026-10-07 17:16:48 +08:00

104 lines
3.9 KiB
Python

"""Embedded session wiring for the TUI.
Owns construction of the ``DeerFlowClient`` (with a persistent checkpointer),
thread resolution for ``--continue`` / ``--resume`` (by id **or** title), and the
shared-persistence writer that makes terminal sessions visible in the Web UI (see
``deerflow.tui.persistence``).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING: # avoid importing the heavy client during pure planning
from deerflow.client import DeerFlowClient
from .cli import LaunchPlan
from .persistence import ThreadMetaWriter, _LoopThread
@dataclass
class Session:
client: DeerFlowClient
writer: ThreadMetaWriter | None = None
_loop: _LoopThread | None = None
def resolve_thread(self, plan: LaunchPlan) -> str | None:
"""Resolve the thread id to run against, honoring --resume / --continue."""
if plan.thread_id:
return self.resolve_ref(plan.thread_id)
if plan.continue_recent:
threads = self.client.list_threads(limit=1, sort_by="updated_at").get("thread_list", [])
if threads:
return threads[0].get("thread_id")
return None
def resolve_ref(self, ref: str) -> str:
"""Resolve a thread reference (id or title) to a thread id.
Matches an existing thread by id first, then by exact title. Falls back to
the literal ref (treated as an id) when nothing matches, so an unknown id
still continues/creates that namespace — provided it satisfies the
canonical thread ID contract.
"""
try:
threads = self.client.list_threads(limit=100).get("thread_list", [])
except Exception: # noqa: BLE001 - resolution is best-effort
return self._validated_literal_ref(ref)
if any(t.get("thread_id") == ref for t in threads):
return ref
for thread in threads:
if (thread.get("title") or "") == ref:
return thread.get("thread_id") or self._validated_literal_ref(ref)
return self._validated_literal_ref(ref)
@staticmethod
def _validated_literal_ref(ref: str) -> str:
"""Validate a literal ref before it is adopted as a thread id."""
from deerflow.utils.thread_id import validate_thread_id
try:
return validate_thread_id(ref)
except ValueError as exc:
raise ValueError(f"Thread reference {ref!r} matches no existing thread and is not a valid thread id (expected 1-64 ASCII letters, digits, hyphens, or underscores).") from exc
def recent_threads(self, limit: int = 20) -> list[dict]:
return self.client.list_threads(limit=limit).get("thread_list", [])
def close(self) -> None:
"""Stop the background DB loop and dispose the engine (best-effort)."""
loop = self._loop
if loop is None:
return
self._loop = None
try:
from deerflow.persistence.engine import close_engine
loop.run(close_engine())
except Exception: # noqa: BLE001 - teardown is best-effort
pass
loop.close()
def open_session(persistence: bool = True) -> Session:
"""Build an embedded session backed by the configured checkpointer.
``persistence`` controls the shared ``threads_meta`` writer (and its background
DB loop/engine). Headless one-shots never use the writer, so they pass
``persistence=False`` to avoid standing up an event loop + connection pool only
to discard it.
"""
from deerflow.client import DeerFlowClient
from deerflow.runtime.checkpointer.provider import get_checkpointer
checkpointer = get_checkpointer()
client = DeerFlowClient(checkpointer=checkpointer)
if not persistence:
return Session(client=client)
from .persistence import build_persistence
loop, writer = build_persistence()
return Session(client=client, writer=writer, _loop=loop)