diff --git a/CHANGELOG.md b/CHANGELOG.md index 00611fb3a0..a7f6567311 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,10 +17,23 @@ This release closes that milestone with **301 merged pull requests**. support bounded automatic launches and per-occurrence goals, and let a scheduled agent request stopping its own schedule. Unmet goals and automatic pause use the existing notification outbox; explicit notes and authorized previous-run - references carry context forward without changing the goal lifecycle. ([#6229]) + references carry context forward without changing the goal lifecycle. + Independent of these tools, existing scheduled, webhook and autonomous goal + runs may now accept disclosed low-risk, reversible assumptions, recorded as + `relied_on_assumption`; interactive goal evaluation stays strict. Goal + evaluator calls and tokens now count toward run usage. ([#6229]) #### Scheduler +- **scheduler:** The tasks page shows the per-run goal and end conditions of + conversation-created tasks. Run history shows whether a goal was met, + including when it relied on stated assumptions; an unmet run shows a readable + reason in neutral styling instead of a raw code in the error style; and the + run whose agent asked to stop the schedule is marked. Agent-stop, + auto-pause and unmet values in a task's last error appear as text in the UI + language, and goal-unmet/auto-pause IM notices state the reason in words. + Tasks without a goal render as before; no API change. ([#6326]) + - **scheduler:** Scheduled tasks can be searched by title or prompt. Finding a task previously meant scanning every title or opening details to read its prompt. A search field above the existing filters matches a literal, trimmed, @@ -7775,3 +7788,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#6306]: https://github.com/bytedance/deer-flow/pull/6306 [#6307]: https://github.com/bytedance/deer-flow/pull/6307 [#6319]: https://github.com/bytedance/deer-flow/pull/6319 +[#6326]: https://github.com/bytedance/deer-flow/pull/6326 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index f03cdf552f..8ebfb00f19 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -16,10 +16,19 @@ - **调度器:** 按需启用对话工具创建及管理属主绑定的定时任务,支持自动启动上限、 每次执行的目标评估,以及 Agent 请求停止自身调度。目标未达成与自动暂停复用 - 现有通知 outbox;明确备注和获授权的上次执行引用延续上下文,不增加 goal 状态。([#6229]) + 现有通知 outbox;明确备注和获授权的上次执行引用延续上下文,不增加 goal 状态。 + 与这些工具是否启用无关:已有的定时、webhook 和 autonomous goal 运行现在可以接受 + 已声明的低风险、可逆假设,并记录为 `relied_on_assumption`;交互式 goal 评估仍保持 + 严格。goal 评估器的调用和 token 现在计入运行用量。([#6229]) #### 调度器 +- **调度器:** 任务页显示对话创建任务的每次执行目标和结束条件。执行记录显示目标 + 是否达成(含依赖已声明假设的情况);未达成的执行以中性样式显示可读的原因,不再 + 按执行错误标红显示原始代码;请求停止调度的那次执行会被标出。任务详情里由 Agent + 停止、自动暂停和目标未达成写入的信息按界面语言显示;目标未达成和自动暂停的 IM + 通知改为用文字说明原因。没有目标的任务显示不变;没有 API 变更。([#6326]) + - **调度器:** 定时任务现在可以按标题或 prompt 搜索。此前找一个任 务只能逐个扫标题或打开详情读 prompt。现有过滤器上方新增的搜索框 会匹配两个字段中任一的字面子串(先去首尾空白、大小写不敏感,包括 @@ -6484,3 +6493,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#6306]: https://github.com/bytedance/deer-flow/pull/6306 [#6307]: https://github.com/bytedance/deer-flow/pull/6307 [#6319]: https://github.com/bytedance/deer-flow/pull/6319 +[#6326]: https://github.com/bytedance/deer-flow/pull/6326 diff --git a/README.md b/README.md index d23398fc93..15c6cee522 100644 --- a/README.md +++ b/README.md @@ -2396,7 +2396,7 @@ Current MVP capabilities: - Pause, resume, trigger, inspect history, and delete tasks - Search task titles or prompts, combined with status/type filters and the current thread scope. - Execute scheduled work through the normal DeerFlow run lifecycle -- When `channel_connections.enabled: true`, push a summary to the task owner's connected IM identities when a scheduled run finishes as success or failed (outbox + delivery worker). Manual "run now" and interrupts stay silent, and so do occurrences that end without a finished run (launch error, queue timeout, restart recovery). Channel/transport outages park deliveries without exhausting retries, for up to about a day; platform rejections retry for roughly 15 minutes before settling as `failed`. An identity you disconnect while a delivery is waiting is never pushed to: the row is dropped as `failed`. Proactive push is currently implemented for WeCom; other connected providers enqueue but fail until they grow a `send_notification` path. +- When `channel_connections.enabled: true`, push a summary to the task owner's connected IM identities when a scheduled run finishes as success or failed (outbox + delivery worker). Goal tasks send a goal-unmet notice for an `unmet` occurrence instead, plus an auto-pause notice when three unmet occurrences pause the task. Manual "run now" and interrupts stay silent, and so do occurrences that end without a finished run (launch error, queue timeout, restart recovery). Channel/transport outages park deliveries without exhausting retries, for up to about a day; platform rejections retry for roughly 15 minutes before settling as `failed`. An identity you disconnect while a delivery is waiting is never pushed to: the row is dropped as `failed`. Proactive push is currently implemented for WeCom; other connected providers enqueue but fail until they grow a `send_notification` path. - Browse execution history in pages of 50; older pages pause automatic refresh, with an explicit return to the latest runs. Counts appear only after a successful read; loading and failed reads are not reported as zero runs. **Filter execution history through the API** diff --git a/README_pt.md b/README_pt.md index f486528e7e..d6f389f975 100644 --- a/README_pt.md +++ b/README_pt.md @@ -1506,7 +1506,7 @@ Capacidades atuais do MVP: - Pausar, retomar, disparar, inspecionar o histórico e excluir tarefas - Buscar em títulos ou prompts de tarefas, em combinação com filtros de status/tipo e com o escopo da thread atual. - Executar o trabalho agendado pelo ciclo de vida normal de runs do DeerFlow -- Com `channel_connections.enabled: true`, enviar um resumo às identidades de IM conectadas do dono da tarefa quando um run agendado termina como sucesso ou falha (outbox + worker de entrega). O "run now" manual e as interrupções ficam em silêncio, assim como as ocorrências que terminam sem um run concluído (erro de lançamento, timeout de fila, recuperação após reinício). Indisponibilidades de canal/transporte deixam as entregas estacionadas sem esgotar as tentativas, por até cerca de um dia; rejeições da plataforma são tentadas de novo por aproximadamente 15 minutos antes de se fixarem como `failed`. Uma identidade que você desconecta enquanto uma entrega está em espera nunca recebe o envio: a linha é descartada como `failed`. O push proativo está implementado hoje para o WeCom; outros provedores conectados enfileiram, mas falham até ganharem um caminho `send_notification`. +- Com `channel_connections.enabled: true`, enviar um resumo às identidades de IM conectadas do dono da tarefa quando um run agendado termina como sucesso ou falha (outbox + worker de entrega). Tarefas com goal enviam, em vez disso, um aviso de goal não atingido para uma ocorrência `unmet`, além de um aviso de pausa automática quando três ocorrências não atingidas pausam a tarefa. O "run now" manual e as interrupções ficam em silêncio, assim como as ocorrências que terminam sem um run concluído (erro de lançamento, timeout de fila, recuperação após reinício). Indisponibilidades de canal/transporte deixam as entregas estacionadas sem esgotar as tentativas, por até cerca de um dia; rejeições da plataforma são tentadas de novo por aproximadamente 15 minutos antes de se fixarem como `failed`. Uma identidade que você desconecta enquanto uma entrega está em espera nunca recebe o envio: a linha é descartada como `failed`. O push proativo está implementado hoje para o WeCom; outros provedores conectados enfileiram, mas falham até ganharem um caminho `send_notification`. - Navegar pelo histórico de execuções em páginas de 50; páginas mais antigas pausam a atualização automática, com um retorno explícito às execuções mais recentes. As contagens só aparecem depois de uma leitura bem-sucedida; carregamento e leituras com falha não são reportados como zero execuções. **Filtrar o histórico de execuções pela API** diff --git a/README_zh.md b/README_zh.md index 252ef60c58..bb5b746925 100644 --- a/README_zh.md +++ b/README_zh.md @@ -925,7 +925,7 @@ DeerFlow 现在在 workspace 里内置了一个一等的定时任务(scheduled - 当某次执行处于 `queued`、`launching` 或 `running` 时冻结任务定义,避免持久化的执行意外换用新的 prompt、thread 或调度;将任务切换为暂停或删除任务会取消已在等待的执行,而 `launching`/`running` 执行结束后才能重试这些变更;显式手动触发在调度已暂停时仍可等待并执行,且不会自动恢复调度 - 支持暂停、恢复、手动触发、查看历史和删除任务 - 定时任务通过正常的 DeerFlow run 生命周期执行 -- 当 `channel_connections.enabled: true` 时,定时运行以成功或失败结束时,会把摘要推送到任务所有者已绑定的 IM 身份(outbox + 投递 worker)。手动「立即运行」和中断不推送;没有跑完一次运行就结束的执行记录(启动失败、排队超时、重启恢复)也不推送。渠道/传输宕机时会停车且不耗尽重试,最长约一天;平台拒绝大约重试 15 分钟后落为 `failed`。等待期间被你解绑的身份不会再收到推送:该条投递直接落为 `failed`。目前主动推送由企业微信实现;其它已绑定渠道会入队,但在实现 `send_notification` 前会失败。 +- 当 `channel_connections.enabled: true` 时,定时运行以成功或失败结束时,会把摘要推送到任务所有者已绑定的 IM 身份(outbox + 投递 worker)。带目标的任务在某次执行为 `unmet` 时改发“目标未达成”通知;连续三次未达成导致任务自动暂停时,再发一条自动暂停通知。手动「立即运行」和中断不推送;没有跑完一次运行就结束的执行记录(启动失败、排队超时、重启恢复)也不推送。渠道/传输宕机时会停车且不耗尽重试,最长约一天;平台拒绝大约重试 15 分钟后落为 `failed`。等待期间被你解绑的身份不会再收到推送:该条投递直接落为 `failed`。目前主动推送由企业微信实现;其它已绑定渠道会入队,但在实现 `send_notification` 前会失败。 - 按每页 50 条浏览执行历史;历史页暂停自动刷新,可随时返回最新记录。 仅在读取成功后显示条数,加载中或失败不会误显示为零条。 **通过 API 筛选执行历史** diff --git a/backend/app/scheduler/notification_delivery.py b/backend/app/scheduler/notification_delivery.py index 3de4d187b5..a7d6560b6a 100644 --- a/backend/app/scheduler/notification_delivery.py +++ b/backend/app/scheduler/notification_delivery.py @@ -86,6 +86,26 @@ def redact_egress_text(text: str) -> str: return redacted +# Readable text for host-defined goal reason codes (``blocked:`` prefix +# removed). Keys mirror the goal blockers and stand-down reasons. +_GOAL_REASON_TEXT = { + "missing_evidence": "the goal check found evidence missing", + "needs_user_input": "it needs your input", + "run_failed": "the goal check found the work unfinished", + "external_wait": "it is waiting on something external", + "goal_not_met_yet": "the goal is not met yet", + "no_verdict": "no goal verdict was recorded", + "consecutive_unmet": "3 scheduled runs in a row did not meet the goal", + "evaluator_failed": "the goal check could not run", + "max_continuations_reached": "the continuation limit was reached", + "no_progress_detected": "no progress was made between turns", + "token_capped": "the token budget was reached", + "no_durable_end_of_turn": "no final reply was saved", + "thread_changed_after_evaluation": "the conversation changed during the goal check", + "thread_changed_before_continuation": "the conversation changed during the goal check", +} + + def render_notification_text(delivery: dict[str, Any]) -> str: """Render a bounded markdown summary of the run outcome for IM push.""" payload = delivery.get("payload") or {} @@ -94,15 +114,12 @@ def render_notification_text(delivery: dict[str, Any]) -> str: if event in {"run_unmet", "task_paused"}: label = "Scheduled task goal was not met" if event == "run_unmet" else "Scheduled task automatically paused" lines = [f"**{label}**", f"Task: `{task_label}`"] - # These are host-defined result codes, never model/provider error text. + # Only host-defined result codes are translated; model/provider error + # text and unknown codes are never forwarded. reason = payload.get("reason_code") - blockers = {"missing_evidence", "needs_user_input", "run_failed", "external_wait", "goal_not_met_yet"} - allowed = ( - blockers - | {f"blocked:{blocker}" for blocker in blockers} - | {"no_verdict", "consecutive_unmet", "evaluator_failed", "max_continuations_reached", "no_progress_detected", "token_capped", "no_durable_end_of_turn", "thread_changed_after_evaluation", "thread_changed_before_continuation"} - ) - lines.append(f"Reason: `{reason if isinstance(reason, str) and reason in allowed else 'unknown'}`") + if isinstance(reason, str): + reason = reason.removeprefix("blocked:") + lines.append(f"Reason: {_GOAL_REASON_TEXT.get(reason, 'unknown') if isinstance(reason, str) else 'unknown'}.") lines.append("See the DeerFlow workspace for the result and next steps.") elif event == "run_failed": lines = ["**Scheduled task failed**", f"Task: `{task_label}`"] diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index 0c89eb0e3d..2bac50f0ad 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -492,6 +492,7 @@ The scheduled-task MVP adds a scheduler section to `config.yaml`: ```yaml scheduler: enabled: false + tool_enabled: false multi_instance: false poll_interval_seconds: 5 lease_seconds: 120 @@ -504,6 +505,7 @@ scheduler: Notes: - `enabled: false` keeps background polling off by default. +- `tool_enabled: false` keeps conversation schedule tools off. Set it together with `enabled: true` and restart Gateway to offer `schedule_task` in authorized interactive conversations and `stop_scheduled_task` in their scheduled runs (see "Create schedules in a conversation" in the README). - `multi_instance: true` opts into lease-aware scheduler recovery across Gateway instances. It requires Postgres, `run_ownership.heartbeat_enabled: true`, and `run_events.backend: db`; otherwise startup fails fast. Leave it false for the default single-instance scheduler. - `max_concurrent_runs` is a shared global execution cap in multi-instance mode. Waiting `queued` rows do not consume capacity; an atomic `queued` → `launching` claim counts `launching`/`running` rows under a Postgres advisory lock so concurrent Pods cannot exceed the cap. - `queue_timeout_seconds` limits how long a persisted occurrence may wait for capacity or a reused thread to become available. Expired occurrences are marked `failed`; queued rows otherwise survive Gateway restarts. @@ -522,7 +524,7 @@ Notes: - Create/update accept `once`, `cron`, and `interval`. Interval uses `schedule_spec.every_seconds` (UTC `now + N`, no missed-beat catch-up). N is at least `min_once_delay_seconds` (default 60) and at most 30 days. - Manual trigger uses the same scheduled-task resource and run lifecycle. - Scheduled task definitions and task-run history are persisted in the application database. -- With `channel_connections.enabled: true`, the scheduler enqueues IM outcome notifications for the task owner's connected identities. A delivery worker (same poll cadence as the scheduler) pushes them. Only runs that finish as success or failed notify: manual triggers and interrupts stay silent, and so do occurrences that end without a finished run (launch error, queue timeout, restart recovery). Channel/transport outages park rows without consuming the retry budget, for up to about a day; platform rejections exhaust ~15 minutes of counted retries then settle `failed`. The worker re-checks the binding right before sending: a target the owner has disconnected since enqueue is dropped as `failed`, never pushed. Only WeCom currently implements proactive `send_notification`. +- With `channel_connections.enabled: true`, the scheduler enqueues IM outcome notifications for the task owner's connected identities. A delivery worker (same poll cadence as the scheduler) pushes them. Scheduled runs that finish as success or failed notify. For goal tasks, an `unmet` scheduled occurrence sends a goal-unmet notice instead, and an automatic pause after three unmet occurrences sends an auto-pause notice; a stop requested by the agent adds no separate notice. Manual triggers and interrupts stay silent, and so do occurrences that end without a finished run (launch error, queue timeout, restart recovery). Channel/transport outages park rows without consuming the retry budget, for up to about a day; platform rejections exhaust ~15 minutes of counted retries then settle `failed`. The worker re-checks the binding right before sending: a target the owner has disconnected since enqueue is dropped as `failed`, never pushed. Only WeCom currently implements proactive `send_notification`. ### Agent Storage diff --git a/backend/packages/harness/deerflow/persistence/scheduled_task_runs/finalization.py b/backend/packages/harness/deerflow/persistence/scheduled_task_runs/finalization.py index c78b91cadd..0fcc0c1dba 100644 --- a/backend/packages/harness/deerflow/persistence/scheduled_task_runs/finalization.py +++ b/backend/packages/harness/deerflow/persistence/scheduled_task_runs/finalization.py @@ -19,6 +19,11 @@ if TYPE_CHECKING: FinalizationObserver = Callable[..., Awaitable[None]] +# Host-written task ``last_error`` values that the tasks page translates; +# contracts/scheduled_goal_notes_contract.json pins them for the frontend. +AGENT_STOP_LAST_ERROR_PREFIX = "stopped by the agent in run " +AUTO_PAUSE_LAST_ERROR = "paused after 3 unmet scheduled goal runs" + def utc(value: datetime) -> datetime: return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) @@ -117,11 +122,11 @@ async def finalize_occurrence( task.next_run_at = None elif occurrence.stop_requested_run_id is not None and occurrence.stop_requested_run_id == run_id: task.status = "paused" - task.last_error = f"stopped by the agent in run {run_id}" + task.last_error = f"{AGENT_STOP_LAST_ERROR_PREFIX}{run_id}" elif occurrence.goal_objective is not None and await _unmet_streak(session, occurrence): was_paused = task.status == "paused" task.status = "paused" - task.last_error = "paused after 3 unmet scheduled goal runs" + task.last_error = AUTO_PAUSE_LAST_ERROR if not was_paused: events += ("task_paused",) task.updated_at = finished_at diff --git a/backend/tests/test_scheduled_goal_notes_contract.py b/backend/tests/test_scheduled_goal_notes_contract.py new file mode 100644 index 0000000000..b5f7277e7d --- /dev/null +++ b/backend/tests/test_scheduled_goal_notes_contract.py @@ -0,0 +1,57 @@ +"""Contract tests for host-written scheduled goal strings. + +The tasks page (``frontend/src/core/scheduled-tasks/goal-outcome.ts``) and the +IM notice renderer translate these values instead of showing them raw, so the +backend must keep producing exactly what ``contracts/scheduled_goal_notes_contract.json`` +lists. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +import pytest + +from app.scheduler.notification_delivery import render_notification_text +from deerflow.persistence.scheduled_task_runs import finalization +from deerflow.runtime.goal import DEFAULT_MAX_GOAL_CONTINUATIONS, DEFAULT_MAX_NO_PROGRESS_CONTINUATIONS, GOAL_BLOCKERS +from deerflow.runtime.runs import worker + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_CONTRACT = json.loads((_REPO_ROOT / "contracts" / "scheduled_goal_notes_contract.json").read_text(encoding="utf-8")) +_CODES = set(_CONTRACT["unmet_reason_codes"]) + + +def test_last_error_strings_match_contract(): + assert finalization.AGENT_STOP_LAST_ERROR_PREFIX == _CONTRACT["agent_stop_last_error_prefix"] + assert finalization.AUTO_PAUSE_LAST_ERROR == _CONTRACT["auto_pause_last_error"] + + +def test_blocker_stand_down_codes_are_in_contract(): + # An unsatisfied verdict never carries the "none" blocker. + assert {f"blocked:{blocker}" for blocker in GOAL_BLOCKERS - {"none"}} <= _CODES + + +def test_cap_stand_down_reasons_are_in_contract(): + evaluation = {"satisfied": False, "blocker": "goal_not_met_yet", "reason": ""} + at_cap = {"continuation_count": DEFAULT_MAX_GOAL_CONTINUATIONS} + assert worker._stand_down_reason(at_cap, evaluation, 0) in _CODES + assert worker._stand_down_reason({"continuation_count": 0}, evaluation, DEFAULT_MAX_NO_PROGRESS_CONTINUATIONS) in _CODES + + +def test_literal_stand_down_reasons_and_no_verdict_are_in_contract(): + source = Path(worker.__file__).read_text(encoding="utf-8") + literals = set(re.findall(r'stand_down_reason\s*=\s*"([a-z_:]+)"', source)) + assert literals, "pattern no longer matches worker.py; update this test" + assert literals <= _CODES + assert '"no_verdict"' in Path(finalization.__file__).read_text(encoding="utf-8") + assert "no_verdict" in _CODES + + +@pytest.mark.parametrize("code", sorted(_CODES)) +def test_im_notice_translates_every_contract_code(code): + text = render_notification_text({"event": "run_unmet", "task_id": "task-a", "payload": {"reason_code": code}}) + assert "Reason: unknown." not in text + assert code not in text diff --git a/backend/tests/test_scheduler_agent_service.py b/backend/tests/test_scheduler_agent_service.py index e277a054c4..ad85357822 100644 --- a/backend/tests/test_scheduler_agent_service.py +++ b/backend/tests/test_scheduler_agent_service.py @@ -141,9 +141,12 @@ def test_unmet_and_pause_notifications_have_distinct_safe_text(): unmet = render_notification_text({"event": "run_unmet", "task_id": "task-a", "payload": {"reason_code": "blocked:needs_user_input", "error": "private raw failure"}}) paused = render_notification_text({"event": "task_paused", "task_id": "task-a", "payload": {"reason_code": "consecutive_unmet"}}) assert "goal was not met" in unmet - assert "needs_user_input" in unmet + assert "Reason: it needs your input." in unmet + assert "needs_user_input" not in unmet assert "private raw failure" not in unmet assert "automatically paused" in paused + assert "Reason: 3 scheduled runs in a row did not meet the goal." in paused + assert "consecutive_unmet" not in paused assert "completed" not in unmet + paused @@ -186,7 +189,25 @@ async def test_external_wait_does_not_advance_unmet_streak(scheduler, outcomes): assert status == ("paused" if index == 3 else "enabled") -@pytest.mark.parametrize("reason", ["token_capped", "no_durable_end_of_turn", "thread_changed_after_evaluation", "thread_changed_before_continuation"]) -def test_known_host_stand_down_reasons_are_reported(reason): +@pytest.mark.parametrize( + ("reason", "readable"), + [ + ("token_capped", "the token budget was reached"), + ("no_durable_end_of_turn", "no final reply was saved"), + ("thread_changed_after_evaluation", "the conversation changed during the goal check"), + ("thread_changed_before_continuation", "the conversation changed during the goal check"), + ("blocked:missing_evidence", "the goal check found evidence missing"), + ("missing_evidence", "the goal check found evidence missing"), + ("no_verdict", "no goal verdict was recorded"), + ], +) +def test_known_host_stand_down_reasons_are_reported_readably(reason, readable): text = render_notification_text({"event": "run_unmet", "task_id": "task-a", "payload": {"reason_code": reason}}) - assert f"Reason: `{reason}`" in text + assert f"Reason: {readable}." in text + assert reason not in text + + +def test_unknown_reason_code_is_not_forwarded(): + text = render_notification_text({"event": "run_unmet", "task_id": "task-a", "payload": {"reason_code": "provider said: secret"}}) + assert "Reason: unknown." in text + assert "secret" not in text diff --git a/contracts/scheduled_goal_notes_contract.json b/contracts/scheduled_goal_notes_contract.json new file mode 100644 index 0000000000..b876389e44 --- /dev/null +++ b/contracts/scheduled_goal_notes_contract.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "description": "Cross-language contract fixture for the host-written scheduled-task strings the tasks page and IM notices translate. The backend writes these values to scheduled_task_runs.error and scheduled_tasks.last_error during goal and stop finalization; the frontend (core/scheduled-tasks/goal-outcome.ts) and the IM notice renderer map them to readable text, so neither shows the raw value.", + "agent_stop_last_error_prefix": "stopped by the agent in run ", + "auto_pause_last_error": "paused after 3 unmet scheduled goal runs", + "unmet_reason_codes": [ + "blocked:missing_evidence", + "blocked:needs_user_input", + "blocked:run_failed", + "blocked:external_wait", + "blocked:goal_not_met_yet", + "max_continuations_reached", + "no_progress_detected", + "token_capped", + "evaluator_failed", + "no_durable_end_of_turn", + "thread_changed_after_evaluation", + "thread_changed_before_continuation", + "no_verdict" + ] +} diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 479f1ee7de..72f3f62141 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -54,6 +54,7 @@ page zero polls or refreshes on focus/reconnect. Task switches reset to page zer and consumed AbortSignals cancel obsolete reads. Live offsets are not snapshots; explicit mutations or navigation may observe newly inserted runs. Run status `unmet` identifies a finished occurrence whose scheduled goal was not satisfied; keep it distinct from execution failure. +`core/scheduled-tasks/goal-outcome.ts` maps goal verdicts and host reason codes for run history; show known codes as localized labels (raw code only in the tooltip), unknown codes verbatim, and leave runs without a goal unchanged. `contracts/scheduled_goal_notes_contract.json` pins the host strings it matches. ## Architecture diff --git a/frontend/src/app/workspace/scheduled-tasks/page.tsx b/frontend/src/app/workspace/scheduled-tasks/page.tsx index df8c10fc96..227cae1bcc 100644 --- a/frontend/src/app/workspace/scheduled-tasks/page.tsx +++ b/frontend/src/app/workspace/scheduled-tasks/page.tsx @@ -37,6 +37,11 @@ import { listAgents } from "@/core/agents/api"; import { useAgentsApiEnabled } from "@/core/agents/hooks"; import { useI18n } from "@/core/i18n/hooks"; import { hasScheduleSpec } from "@/core/scheduled-tasks/cron"; +import { + describeGoalOutcome, + describeTaskLastError, + requestedScheduleStop, +} from "@/core/scheduled-tasks/goal-outcome"; import { useCreateScheduledTask, useUpdateScheduledTask, @@ -193,6 +198,7 @@ export default function ScheduledTasksPage() { const selectedTask = filteredData.find((task) => task.id === selectedTaskId) ?? filteredData[0]; const taskRunsQuery = useScheduledTaskRunHistory(selectedTask?.id); + const lastNote = describeTaskLastError(selectedTask?.last_error ?? null); const createTask = useCreateScheduledTask(); const updateTask = useUpdateScheduledTask(selectedTask?.id ?? ""); const pauseTask = usePauseScheduledTask(); @@ -630,6 +636,28 @@ export default function ScheduledTasksPage() { {st.detail.schedule}:{" "} {scheduleTypeLabel(selectedTask.schedule_type)} + {selectedTask.goal_objective && ( +