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 && ( +
+ {st.goal.objective}: {selectedTask.goal_objective} +
+ )} + {selectedTask.max_runs != null && ( +
+ {st.goal.maxRuns.replace( + "{count}", + String(selectedTask.max_runs), + )} +
+ )} + {selectedTask.end_at && ( +
+ {st.goal.endAt}:{" "} + {formatTimestamp(selectedTask.end_at, locale)} +
+ )}
{st.detail.nextRun}:{" "} {formatTimestamp(selectedTask.next_run_at, locale)} @@ -641,8 +669,20 @@ export default function ScheduledTasksPage() {
{st.detail.lastRunId}: {selectedTask.last_run_id ?? NONE}
-
- {st.detail.lastError}: {selectedTask.last_error ?? NONE} +
+ {lastNote === null + ? `${st.detail.lastError}: ${selectedTask.last_error ?? NONE}` + : lastNote.kind === "goalUnmet" + ? `${st.goal.lastUnmet}: ${st.goal.reasons[lastNote.reasonKey]}` + : `${st.goal.lastPause}: ${lastNote.kind === "agentStop" ? st.goal.agentStopped : st.goal.autoPaused}`}
{editing ? (
@@ -826,25 +866,58 @@ export default function ScheduledTasksPage() { data-testid="scheduled-task-run-list" > {(taskRunsQuery.data ?? []).length > 0 ? ( - (taskRunsQuery.data ?? []).map((run) => ( -
-
{runSummary(run)}
-
- {run.run_id ?? NONE} -
-
- {formatTimestamp(run.scheduled_for, locale)} -
- {run.error && ( -
- {run.error} + (taskRunsQuery.data ?? []).map((run) => { + const goal = describeGoalOutcome(run); + return ( +
+
{runSummary(run)}
+
+ {run.run_id ?? NONE}
- )} -
- )) +
+ {formatTimestamp(run.scheduled_for, locale)} +
+ {goal?.kind === "met" && ( +
+ {goal.reliedOnAssumption + ? st.goal.metAssumed + : st.goal.met} +
+ )} + {goal?.kind === "unmet" + ? goal.code && ( +
+ {goal.reasonKey + ? st.goal.reasons[goal.reasonKey] + : goal.code} +
+ ) + : run.error && ( +
+ {run.error} +
+ )} + {requestedScheduleStop(run) && ( +
+ {st.goal.stopRequested} +
+ )} +
+ ); + }) ) : !taskRunsQuery.isPending && !taskRunsQuery.isError ? (
{st.detail.noRuns} diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index b45870d850..03ea225d2e 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -944,6 +944,32 @@ export const enUS: Translations = { skipped: "Skipped", interrupted: "Interrupted", }, + goal: { + objective: "Goal per run", + maxRuns: "Automatic runs: up to {count}", + endAt: "Ends at", + met: "Goal met", + metAssumed: "Goal met, relying on stated assumptions", + stopRequested: "This run asked to stop the schedule", + lastPause: "Last pause reason", + agentStopped: "The agent stopped its own schedule", + autoPaused: "3 scheduled runs in a row did not meet the goal", + lastUnmet: "Last unmet reason", + reasons: { + missingEvidence: "Goal check: evidence missing", + needsUserInput: "Needs your input", + externalWait: "Goal check: waiting on something external", + runFailed: "Goal check: the run did not finish the work", + goalNotMetYet: "Goal check: not met yet", + maxContinuations: "Continuation limit reached", + noProgress: "No progress between turns", + tokenCapped: "Token budget reached", + evaluatorFailed: "Goal check could not run", + noDurableEndOfTurn: "No final reply was saved", + threadChanged: "Conversation changed during the goal check", + noVerdict: "No goal verdict", + }, + }, recipes: { label: "Quick create", trending: { diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index c9261aa6ce..425ab8b0a2 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -780,6 +780,32 @@ export interface Translations { skipped: string; interrupted: string; }; + goal: { + objective: string; + maxRuns: string; + endAt: string; + met: string; + metAssumed: string; + stopRequested: string; + lastPause: string; + agentStopped: string; + autoPaused: string; + lastUnmet: string; + reasons: { + missingEvidence: string; + needsUserInput: string; + externalWait: string; + runFailed: string; + goalNotMetYet: string; + maxContinuations: string; + noProgress: string; + tokenCapped: string; + evaluatorFailed: string; + noDurableEndOfTurn: string; + threadChanged: string; + noVerdict: string; + }; + }; recipes: { label: string; trending: { title: string; desc: string }; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 4eba00ae76..c954cd5411 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -885,6 +885,32 @@ export const zhCN: Translations = { skipped: "跳过", interrupted: "已中断", }, + goal: { + objective: "每次执行的目标", + maxRuns: "自动执行上限:{count} 次", + endAt: "截止时间", + met: "目标已达成", + metAssumed: "目标已达成(基于已声明的假设)", + stopRequested: "本次执行请求停止该定时任务", + lastPause: "上次暂停原因", + agentStopped: "Agent 主动停止了该定时任务", + autoPaused: "连续 3 次定时执行未达成目标", + lastUnmet: "上次未达成原因", + reasons: { + missingEvidence: "目标检查:缺少证据", + needsUserInput: "需要你补充信息", + externalWait: "目标检查:正在等待外部条件", + runFailed: "目标检查:本次执行未完成任务", + goalNotMetYet: "目标检查:尚未达成", + maxContinuations: "已达到续跑次数上限", + noProgress: "连续多轮没有进展", + tokenCapped: "已达到 token 预算", + evaluatorFailed: "目标检查未能完成", + noDurableEndOfTurn: "没有保存最终回复", + threadChanged: "目标检查期间对话发生了变化", + noVerdict: "未得到目标判定", + }, + }, recipes: { label: "快速创建", trending: { diff --git a/frontend/src/core/scheduled-tasks/goal-outcome.ts b/frontend/src/core/scheduled-tasks/goal-outcome.ts new file mode 100644 index 0000000000..5c90407c2d --- /dev/null +++ b/frontend/src/core/scheduled-tasks/goal-outcome.ts @@ -0,0 +1,100 @@ +import type { ScheduledTaskRun } from "./types"; + +export type GoalReasonKey = + | "missingEvidence" + | "needsUserInput" + | "externalWait" + | "runFailed" + | "goalNotMetYet" + | "maxContinuations" + | "noProgress" + | "tokenCapped" + | "evaluatorFailed" + | "noDurableEndOfTurn" + | "threadChanged" + | "noVerdict"; + +// Host-defined codes an `unmet` occurrence stores in `error`, keyed without +// the `blocked:` prefix like the IM notice map (`_GOAL_REASON_TEXT`). +// contracts/scheduled_goal_notes_contract.json pins the codes the backend +// writes; unknown codes stay visible verbatim. +const REASON_KEYS: Record = { + missing_evidence: "missingEvidence", + needs_user_input: "needsUserInput", + external_wait: "externalWait", + run_failed: "runFailed", + goal_not_met_yet: "goalNotMetYet", + max_continuations_reached: "maxContinuations", + no_progress_detected: "noProgress", + token_capped: "tokenCapped", + evaluator_failed: "evaluatorFailed", + no_durable_end_of_turn: "noDurableEndOfTurn", + thread_changed_after_evaluation: "threadChanged", + thread_changed_before_continuation: "threadChanged", + no_verdict: "noVerdict", +}; + +// Host-written task `last_error` values, pinned by the same contract. +const AGENT_STOP_LAST_ERROR_PREFIX = "stopped by the agent in run "; +const AUTO_PAUSE_LAST_ERROR = "paused after 3 unmet scheduled goal runs"; + +function reasonKeyOf(code: string): GoalReasonKey | null { + return REASON_KEYS[code.replace(/^blocked:/, "")] ?? null; +} + +export type GoalOutcome = + | { kind: "met"; reliedOnAssumption: boolean } + | { kind: "unmet"; code: string | null; reasonKey: GoalReasonKey | null }; + +/** Goal result of a finished occurrence; null when the run had no goal outcome. */ +export function describeGoalOutcome( + run: Pick, +): GoalOutcome | null { + if (run.status === "unmet") { + const code = run.error ?? null; + return { kind: "unmet", code, reasonKey: code ? reasonKeyOf(code) : null }; + } + if (run.status === "success" && run.goal_verdict?.satisfied === true) { + return { + kind: "met", + reliedOnAssumption: run.goal_verdict.relied_on_assumption === true, + }; + } + return null; +} + +/** True for the occurrence whose agent asked to stop its own schedule. */ +export function requestedScheduleStop( + run: Pick, +): boolean { + return Boolean(run.run_id) && run.stop_requested_run_id === run.run_id; +} + +export type TaskLastNote = + | { kind: "agentStop" } + | { kind: "autoPause" } + | { kind: "goalUnmet"; reasonKey: GoalReasonKey }; + +/** + * Recognize the host-written `last_error` values from goal and stop + * finalization (backend `scheduled_task_runs/finalization.py`). Anything else + * is a real error message and returns null. + */ +export function describeTaskLastError( + lastError: string | null, +): TaskLastNote | null { + if (!lastError) { + return null; + } + if ( + lastError.startsWith(AGENT_STOP_LAST_ERROR_PREFIX) && + /^\S+$/.test(lastError.slice(AGENT_STOP_LAST_ERROR_PREFIX.length)) + ) { + return { kind: "agentStop" }; + } + if (lastError === AUTO_PAUSE_LAST_ERROR) { + return { kind: "autoPause" }; + } + const reasonKey = reasonKeyOf(lastError); + return reasonKey ? { kind: "goalUnmet", reasonKey } : null; +} diff --git a/frontend/src/core/scheduled-tasks/types.ts b/frontend/src/core/scheduled-tasks/types.ts index 052339be78..3ec2b853d9 100644 --- a/frontend/src/core/scheduled-tasks/types.ts +++ b/frontend/src/core/scheduled-tasks/types.ts @@ -21,10 +21,23 @@ export type ScheduledTask = { last_thread_id: string | null; last_error: string | null; run_count: number; + // Only conversation-created tasks set these; other tasks omit them or send null. + goal_objective?: string | null; + max_runs?: number | null; + end_at?: string | null; created_at: string; updated_at: string; }; +// The verdict an occurrence finished with: from the goal evaluator, or from +// the host when it stood the goal down (see `stand_down_reason`). +export type ScheduledGoalVerdict = { + satisfied?: boolean; + blocker?: string; + relied_on_assumption?: boolean; + stand_down_reason?: string; +}; + export type ScheduledTaskRun = { id: string; task_id: string; @@ -42,6 +55,9 @@ export type ScheduledTaskRun = { | "skipped" | "interrupted"; error: string | null; + goal_objective?: string | null; + goal_verdict?: ScheduledGoalVerdict | null; + stop_requested_run_id?: string | null; attempt_count: number; started_at: string | null; finished_at: string | null; diff --git a/frontend/tests/e2e/scheduled-goal-history.spec.ts b/frontend/tests/e2e/scheduled-goal-history.spec.ts new file mode 100644 index 0000000000..3097862290 --- /dev/null +++ b/frontend/tests/e2e/scheduled-goal-history.spec.ts @@ -0,0 +1,173 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { mockLangGraphAPI } from "./utils/mock-api"; + +const baseTask = { + thread_id: null, + context_mode: "fresh_thread_per_run" as const, + prompt: "Check the release checklist", + schedule_type: "interval" as const, + schedule_spec: { every_seconds: 3600 }, + timezone: "UTC", + status: "paused" as const, + next_run_at: null, + last_run_at: null, + last_run_id: null, + last_error: null, + run_count: 5, + created_at: "2026-10-01T00:00:00Z", + updated_at: "2026-10-01T00:00:00Z", +}; +const goalTask = { + ...baseTask, + id: "goal-task", + title: "Goal task", + goal_objective: "status.md lists every unchecked item", + max_runs: 5, + end_at: null, + last_error: "stopped by the agent in run execution-stop", +}; +const plainTask = { + ...baseTask, + id: "plain-task", + title: "Plain task", + last_error: "boom", +}; + +const run = ( + id: string, + overrides: Record, +): Record => ({ + id, + task_id: "goal-task", + thread_id: `thread-${id}`, + run_id: `execution-${id}`, + scheduled_for: "2026-10-01T00:00:00Z", + trigger: "scheduled", + status: "success", + error: null, + attempt_count: 1, + started_at: null, + finished_at: null, + created_at: "2026-10-01T00:00:00Z", + ...overrides, +}); +const goal = { goal_objective: goalTask.goal_objective }; +const goalRuns = [ + run("met", { ...goal, goal_verdict: { satisfied: true } }), + run("assumed", { + ...goal, + goal_verdict: { satisfied: true, relied_on_assumption: true }, + }), + run("unmet", { + ...goal, + status: "unmet", + error: "blocked:missing_evidence", + goal_verdict: { + satisfied: false, + blocker: "missing_evidence", + stand_down_reason: "blocked:missing_evidence", + }, + }), + run("stop", { + ...goal, + goal_verdict: { satisfied: true }, + stop_requested_run_id: "execution-stop", + }), + run("unknown", { ...goal, status: "unmet", error: "future_reason" }), + run("failed", { ...goal, status: "failed", error: "boom" }), +]; +const plainRuns = [ + run("plain-ok", { task_id: "plain-task" }), + run("plain-failed", { + task_id: "plain-task", + status: "failed", + error: "boom", + }), +]; + +async function openTask( + page: Page, + task: typeof goalTask | typeof plainTask, + runs: Record[], + locale?: "zh-CN", +) { + mockLangGraphAPI(page, { threads: [], scheduledTasks: [task] }); + await page.route(/\/api\/scheduled-tasks\/[^/]+\/runs(?:\?|$)/, (route) => + route.fulfill({ json: runs }), + ); + await page.goto("/workspace/scheduled-tasks"); + if (locale) { + await page.evaluate((value) => { + document.cookie = `locale=${value}; path=/; SameSite=Lax`; + }, locale); + await page.reload(); + } + return page.getByTestId("scheduled-task-run-list"); +} + +const rowOf = (list: ReturnType, runId: string) => + list.locator("div.rounded-md").filter({ hasText: runId }); + +test("goal runs show their outcome and the unmet reason is not styled as an error", async ({ + page, +}) => { + const list = await openTask(page, goalTask, goalRuns); + const detail = page.getByTestId("scheduled-task-detail"); + await expect(page.getByTestId("scheduled-task-goal")).toHaveText( + "Goal per run: status.md lists every unchecked item", + ); + await expect(detail.getByText("Automatic runs: up to 5")).toBeVisible(); + await expect(page.getByTestId("scheduled-task-last-error")).toHaveText( + "Last pause reason: The agent stopped its own schedule", + ); + + await expect(rowOf(list, "execution-met")).toContainText("Goal met"); + await expect(rowOf(list, "execution-assumed")).toContainText( + "Goal met, relying on stated assumptions", + ); + const unmet = rowOf(list, "execution-unmet").getByTestId( + "scheduled-run-goal", + ); + await expect(unmet).toHaveText("Goal check: evidence missing"); + await expect(unmet).toHaveAttribute("title", "blocked:missing_evidence"); + await expect(unmet).not.toHaveClass(/text-destructive/); + await expect( + rowOf(list, "execution-unknown").getByTestId("scheduled-run-goal"), + ).toHaveText("future_reason"); + await expect( + rowOf(list, "execution-stop").getByTestId("scheduled-run-stop-requested"), + ).toHaveText("This run asked to stop the schedule"); + await expect(list.getByTestId("scheduled-run-stop-requested")).toHaveCount(1); + await expect( + rowOf(list, "execution-failed").locator(".text-destructive"), + ).toHaveText("boom"); +}); + +test("tasks without a goal render their runs as before", async ({ page }) => { + const list = await openTask(page, plainTask, plainRuns); + await expect(list.getByText("execution-plain-ok")).toBeVisible(); + await expect(page.getByTestId("scheduled-task-goal")).toHaveCount(0); + await expect(page.getByTestId("scheduled-task-last-error")).toHaveText( + "Last error: boom", + ); + await expect(list.getByTestId("scheduled-run-goal")).toHaveCount(0); + await expect(list.getByTestId("scheduled-run-stop-requested")).toHaveCount(0); + await expect( + rowOf(list, "execution-plain-failed").locator(".text-destructive"), + ).toHaveText("boom"); +}); + +test("goal outcomes use the Chinese copy", async ({ page }) => { + const list = await openTask(page, goalTask, goalRuns, "zh-CN"); + await expect(page.getByTestId("scheduled-task-goal")).toHaveText( + "每次执行的目标: status.md lists every unchecked item", + ); + await expect(rowOf(list, "execution-met")).toContainText("目标已达成"); + await expect(page.getByTestId("scheduled-task-last-error")).toHaveText( + "上次暂停原因: Agent 主动停止了该定时任务", + ); + await expect( + rowOf(list, "execution-unmet").getByTestId("scheduled-run-goal"), + ).toHaveText("目标检查:缺少证据"); +}); diff --git a/frontend/tests/unit/core/scheduled-tasks/goal-outcome.test.ts b/frontend/tests/unit/core/scheduled-tasks/goal-outcome.test.ts new file mode 100644 index 0000000000..7055ac9429 --- /dev/null +++ b/frontend/tests/unit/core/scheduled-tasks/goal-outcome.test.ts @@ -0,0 +1,158 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { expect, test } from "@rstest/core"; + +import { + describeGoalOutcome, + describeTaskLastError, + requestedScheduleStop, +} from "@/core/scheduled-tasks/goal-outcome"; +import type { ScheduledTaskRun } from "@/core/scheduled-tasks/types"; + +type OutcomeInput = Pick; + +type ContractFile = { + agent_stop_last_error_prefix: string; + auto_pause_last_error: string; + unmet_reason_codes: string[]; +}; + +const CONTRACT = JSON.parse( + readFileSync( + resolve( + __dirname, + "../../../../../contracts/scheduled_goal_notes_contract.json", + ), + "utf-8", + ), +) as ContractFile; + +const run = (overrides: Partial): OutcomeInput => ({ + status: "success", + error: null, + ...overrides, +}); + +test("a run without a goal verdict has no goal outcome", () => { + expect(describeGoalOutcome(run({}))).toBeNull(); + expect(describeGoalOutcome(run({ goal_verdict: null }))).toBeNull(); + expect( + describeGoalOutcome(run({ status: "failed", error: "boom" })), + ).toBeNull(); +}); + +test("a satisfied verdict is met, with the assumption flag kept", () => { + expect( + describeGoalOutcome(run({ goal_verdict: { satisfied: true } })), + ).toEqual({ kind: "met", reliedOnAssumption: false }); + expect( + describeGoalOutcome( + run({ + goal_verdict: { satisfied: true, relied_on_assumption: true }, + }), + ), + ).toEqual({ kind: "met", reliedOnAssumption: true }); +}); + +test("a success with an unsatisfied verdict is not reported as met", () => { + expect( + describeGoalOutcome(run({ goal_verdict: { satisfied: false } })), + ).toBeNull(); +}); + +test.each([ + ["blocked:missing_evidence", "missingEvidence"], + ["blocked:needs_user_input", "needsUserInput"], + ["blocked:external_wait", "externalWait"], + ["blocked:run_failed", "runFailed"], + ["blocked:goal_not_met_yet", "goalNotMetYet"], + ["max_continuations_reached", "maxContinuations"], + ["no_progress_detected", "noProgress"], + ["token_capped", "tokenCapped"], + ["evaluator_failed", "evaluatorFailed"], + ["no_durable_end_of_turn", "noDurableEndOfTurn"], + ["thread_changed_after_evaluation", "threadChanged"], + ["thread_changed_before_continuation", "threadChanged"], + ["no_verdict", "noVerdict"], +])("unmet reason %s maps to %s", (code, reasonKey) => { + expect(describeGoalOutcome(run({ status: "unmet", error: code }))).toEqual({ + kind: "unmet", + code, + reasonKey, + }); +}); + +test("an unknown unmet code stays visible without a label", () => { + expect( + describeGoalOutcome(run({ status: "unmet", error: "future_reason" })), + ).toEqual({ kind: "unmet", code: "future_reason", reasonKey: null }); + expect(describeGoalOutcome(run({ status: "unmet", error: null }))).toEqual({ + kind: "unmet", + code: null, + reasonKey: null, + }); +}); + +test.each([ + ["run-2", "run-2", true], + ["run-2", "run-1", false], + ["run-2", null, false], + [null, null, false], +])( + "run %s with stop request %s reports a stop request: %s", + (runId, stopRequestedRunId, expected) => { + expect( + requestedScheduleStop({ + run_id: runId, + stop_requested_run_id: stopRequestedRunId, + }), + ).toBe(expected); + }, +); + +test.each([ + [ + "stopped by the agent in run 3f2a9c1e-8b47-4d2a-9e61-5c0b7a1d4e93", + { kind: "agentStop" }, + ], + ["paused after 3 unmet scheduled goal runs", { kind: "autoPause" }], + [ + "blocked:missing_evidence", + { kind: "goalUnmet", reasonKey: "missingEvidence" }, + ], + ["no_verdict", { kind: "goalUnmet", reasonKey: "noVerdict" }], +])("host-written last_error %s is recognized", (lastError, expected) => { + expect(describeTaskLastError(lastError)).toEqual(expected); +}); + +test.each([ + [null], + [""], + ["Connection reset by peer"], + ["stopped by the agent in run "], + ["Paused after 3 unmet scheduled goal runs"], + ["stopped by the agent in run abc; then failed"], +])("other last_error %s stays a plain error message", (lastError) => { + expect(describeTaskLastError(lastError)).toBeNull(); +}); + +test("every contract reason code has a label in run history and task detail", () => { + for (const code of CONTRACT.unmet_reason_codes) { + const outcome = describeGoalOutcome(run({ status: "unmet", error: code })); + expect(outcome?.kind).toBe("unmet"); + expect(outcome?.kind === "unmet" ? outcome.reasonKey : null).not.toBeNull(); + expect(describeTaskLastError(code)?.kind).toBe("goalUnmet"); + } +}); + +test("contract last_error strings are recognized", () => { + expect( + describeTaskLastError( + `${CONTRACT.agent_stop_last_error_prefix}3f2a9c1e-8b47-4d2a-9e61-5c0b7a1d4e93`, + ), + ).toEqual({ kind: "agentStop" }); + expect(describeTaskLastError(CONTRACT.auto_pause_last_error)).toEqual({ + kind: "autoPause", + }); +});