fix(scheduled-tasks): honor explicitly null assistant_id (#6185)

Co-authored-by: lakers-abing <236384465+lakers-abing@users.noreply.github.com>
This commit is contained in:
lakers-abing 2026-10-02 23:02:18 +08:00 committed by GitHub
parent 9ff47cfae5
commit fafe904106
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 51 additions and 4 deletions

View File

@ -2249,6 +2249,7 @@ Current MVP capabilities:
- One-time task forms reject local times skipped by daylight-saving transitions; select another time before creating or saving the task.
- Choose whether each scheduled task reuses a thread and its conversation history or creates a fresh thread per run
- Pin each task to `lead_agent` (default) or a custom agent the owner already has; unknown names are rejected
- Sending `assistant_id: null` in a scheduled-task PATCH resets the task to `lead_agent`; omitting `assistant_id` preserves the current agent, including when that custom agent has since been deleted
- Duplicate an existing task into the create form as an editable draft without copying its run history
- Support `once`, `cron`, and `interval` schedules
- Editing or duplicating an interval task preserves its saved cadence until the interval is explicitly changed, including sub-minute intervals allowed by the operator's scheduler configuration

View File

@ -207,7 +207,7 @@ startup gate rejects process-local memory and JSONL event stores when
- Thread-scoped Gateway runs evaluate an active `ThreadState.goal` after the visible turn completes. `runtime/goal.py` asks a non-thinking evaluator model to judge only visible conversation evidence and return a typed blocker; the evaluator model is created once per run and reused across hidden continuation checks. The evaluator runs after the graph root's tracing scope has already closed, so `create_goal_evaluator_model`/`evaluate_goal_completion` attach their own model-level tracing callbacks (`attach_tracing=True`) and inject Langfuse trace metadata (`thread_id`/`user_id`/`deerflow_trace_id`) directly onto the `ainvoke` call — the same standalone-caller pattern as `oneshot_llm.run_oneshot_llm` and `MemoryUpdater` (see Tracing System below). Satisfied goals are cleared; every non-satisfied evaluation — continuable or stand-down — is persisted with `last_evaluation` (the blocker, reason, and evidence summary; outcomes that stop the loop additionally record a `stand_down_reason` for observability), but only `goal_not_met_yet` evaluations are streamed as hidden `HumanMessage` continuations, and only when the turn did not end on an unanswered Human Input Card (`ask_clarification` or the sandbox network prompt; the question is still open, so the goal stands down with `blocker: "needs_user_input"` without calling the evaluator), a durable assistant end-of-turn checkpoint exists, the run has not been aborted, the run has not hit its token-budget hard stop (`stop_reason == "token_capped"`; the goal stands down with `stand_down_reason: "token_capped"`), the thread did not change during evaluation, and the no-progress breaker has not fired. The continuation cap is 8 — a hard maximum in the `0`–`8` range; callers requesting more are clamped (`set_goal`/TUI) or rejected with 422 (`PUT /goal`). The no-progress breaker keys on the latest visible assistant evidence (not the evaluator's free-text reason, which an LLM rewords every turn), so two consecutive continuations that add no new visible assistant output stop the loop after 2 attempts. Model-response cleanup helpers such as think-block stripping and code-fence stripping live in `deerflow.utils.llm_text` so `runtime/goal.py` and Gateway suggestion parsing share the same JSON-prep behavior.
- Run event stream changes must keep producer code, `deerflow/constants.py`, `runtime/events/catalog.py`, `contracts/run_event_stream_contract.json`, `backend/docs/RUN_EVENT_STREAM.md`, and `tests/test_run_event_stream_contract.py` in sync. The dependency-free constants module owns the persisted envelope limits (`event_type` 32 characters, `category` 16) and cross-layer workspace event identity; the catalog owns validated runtime definitions and categories. Dynamic middleware tags are limited to 21 characters after the `middleware:` prefix. The JSON contract owns payload schemas, backend-specific storage semantics, legacy aliases, and compatibility rules; conformance tests require both views and all producer groups to agree. `run.end.content` remains opaque and may retain nested Python values in memory while JSONL/database stores stringify non-JSON nested values, so consumers must not assume backend-identical nested output representations.
Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runtime, all other `/api/*` → Gateway REST APIs.
Scheduled-task PATCH: omitted `assistant_id` preserves the pin; explicit null resets to `lead_agent`.
**Thread lifecycle**: Before changing branching, regeneration, edit replay, or
archive/search behavior, read [Thread lifecycle invariants](../../docs/THREAD_LIFECYCLE.md).

View File

@ -277,9 +277,9 @@ async def update_scheduled_task(task_id: str, request: Request, body: ScheduledT
await _ensure_task_mutable(existing, repo)
updates = body.model_dump(exclude_none=True)
if "assistant_id" in updates:
if "assistant_id" in body.model_fields_set:
updates["assistant_id"] = await resolve_scheduled_task_assistant_id(
updates["assistant_id"],
body.assistant_id,
user_id=str(user.id),
)
if "context_mode" in updates:

View File

@ -1076,7 +1076,7 @@ def _create_request(**overrides):
return scheduled_tasks.ScheduledTaskCreateRequest(**kwargs)
async def _seed_task(repo: _Repo, **overrides):
async def _seed_task(repo: _Repo | ScheduledTaskRepository, **overrides):
kwargs = {
"task_id": "task-1",
"user_id": "user-1",
@ -1354,6 +1354,52 @@ async def test_update_invalid_assistant_id_is_rejected():
assert "Invalid assistant_id" in exc_info.value.detail
@pytest.mark.asyncio
async def test_update_explicit_null_assistant_id_resets_to_lead_agent():
repo = _Repo()
task = await _seed_task(repo, assistant_id="research-bot")
with patch(
"app.gateway.routers.scheduled_tasks.load_agent_config",
side_effect=FileNotFoundError("missing"),
) as loader:
updated = await _call_update(
repo,
task["id"],
scheduled_tasks.ScheduledTaskUpdateRequest(assistant_id=None),
)
loader.assert_not_called()
assert updated["assistant_id"] == "lead_agent"
assert repo.items[task["id"]]["assistant_id"] == "lead_agent"
@pytest.mark.asyncio
@pytest.mark.parametrize("payload,expected_assistant", [({"assistant_id": None}, "lead_agent"), ({}, "research-bot")], ids=["explicit-null", "omitted"])
async def test_update_assistant_id_through_the_sql_repository(tmp_path, payload, expected_assistant):
await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path)))
try:
sf = get_session_factory()
assert sf is not None
repo = ScheduledTaskRepository(sf)
task = await _seed_task(repo, assistant_id="research-bot")
with patch(
"app.gateway.routers.scheduled_tasks.load_agent_config",
side_effect=FileNotFoundError("missing"),
) as loader:
updated = await _call_update(
repo,
task["id"],
scheduled_tasks.ScheduledTaskUpdateRequest.model_validate({"title": "Renamed", **payload}),
)
loader.assert_not_called()
assert updated["assistant_id"] == expected_assistant
stored = await repo.get(task["id"], user_id="user-1")
assert stored is not None
assert stored["assistant_id"] == expected_assistant
assert stored["title"] == "Renamed"
finally:
await close_engine()
@pytest.mark.asyncio
async def test_update_omitting_assistant_id_keeps_existing_even_if_agent_is_gone():
# Unrelated PATCH (rename, reschedule) must not re-resolve assistant_id.