refactor(scheduler): move manual runs to console controls

This commit is contained in:
AaronZ345
2026-07-17 16:44:30 +08:00
parent f7a0184ea2
commit 209f1f0a6f
10 changed files with 199 additions and 53 deletions

View File

@@ -24,7 +24,6 @@ class SchedulerTool(BaseTool):
"⚠️ 重要:仅当需要「定时/提醒/每天/每周/X分钟后/X点」等延迟或周期执行时才使用此工具。"
"使用方法:\n"
"- 创建action='create', name='任务名', message/ai_task='内容', schedule_type='once/interval/cron', schedule_value='...'\n"
"- 立即执行action='run', task_id='任务ID'(不改变原调度时间)\n"
"- 查询action='list' / action='get', task_id='任务ID'\n"
"- 管理action='delete/enable/disable', task_id='任务ID'\n\n"
"调度类型:\n"
@@ -38,12 +37,12 @@ class SchedulerTool(BaseTool):
"properties": {
"action": {
"type": "string",
"enum": ["create", "run", "list", "get", "delete", "enable", "disable"],
"description": "操作类型: create(创建), run(立即执行), list(列表), get(查询), delete(删除), enable(启用), disable(禁用)"
"enum": ["create", "list", "get", "delete", "enable", "disable"],
"description": "操作类型: create(创建), list(列表), get(查询), delete(删除), enable(启用), disable(禁用)"
},
"task_id": {
"type": "string",
"description": "任务ID (用于 run/get/delete/enable/disable 操作)"
"description": "任务ID (用于 get/delete/enable/disable 操作)"
},
"name": {
"type": "string",
@@ -89,7 +88,7 @@ class SchedulerTool(BaseTool):
Args:
params: Dictionary containing:
- action: Operation type (create/run/list/get/delete/enable/disable)
- action: Operation type (create/list/get/delete/enable/disable)
- Other parameters depending on action
Returns:
@@ -106,9 +105,6 @@ class SchedulerTool(BaseTool):
if action == "create":
result = self._create_task(**kwargs)
return ToolResult.success(result)
elif action == "run":
result = self._run_task(**kwargs)
return ToolResult.success(result)
elif action == "list":
result = self._list_tasks(**kwargs)
return ToolResult.success(result)
@@ -308,24 +304,6 @@ class SchedulerTool(BaseTool):
self.task_store.delete_task(task_id)
return f"✅ 任务 '{task['name']}' ({task_id}) 已删除"
def _run_task(self, **kwargs) -> str:
"""Queue an existing task for immediate execution."""
task_id = kwargs.get("task_id")
if not task_id:
return "错误: 缺少任务ID (task_id)"
task = self.task_store.get_task(task_id)
if not task:
return f"错误: 任务 '{task_id}' 不存在"
from agent.tools.scheduler.integration import get_scheduler_service
service = get_scheduler_service()
if not service:
return "错误: 定时任务系统未运行"
service.run_task_now(task_id)
return f"✅ 任务 '{task['name']}' ({task_id}) 已开始立即执行,原调度时间保持不变"
def _enable_task(self, **kwargs) -> str:
"""Enable a task"""
task_id = kwargs.get("task_id")

View File

@@ -217,6 +217,11 @@ const I18N = {
task_delete_btn: '删除任务',
task_delete_confirm_title: '删除定时任务',
task_delete_confirm_msg: '确定删除该定时任务吗?此操作无法撤销。',
task_run_now: '立即执行',
task_run_confirm_title: '立即执行任务',
task_run_confirm_msg: '该任务会立即向已配置的通道和接收者发送内容。是否继续?',
task_run_started: '已开始执行',
task_run_failed: '执行失败',
logs_title: '日志', logs_desc: '实时日志输出 (run.log)',
logs_live: '实时', logs_coming_msg: '日志流即将在此提供。将连接 run.log 实现类似 tail -f 的实时输出。',
new_chat: '新对话',
@@ -464,6 +469,11 @@ const I18N = {
task_delete_btn: '刪除任務',
task_delete_confirm_title: '刪除定時任務',
task_delete_confirm_msg: '確定刪除該定時任務嗎?此操作無法撤銷。',
task_run_now: '立即執行',
task_run_confirm_title: '立即執行任務',
task_run_confirm_msg: '該任務會立即向已設定的通道和接收者傳送內容。是否繼續?',
task_run_started: '已開始執行',
task_run_failed: '執行失敗',
logs_title: '日誌', logs_desc: '實時日誌輸出 (run.log)',
logs_live: '實時', logs_coming_msg: '日誌流即將在此提供。將連線 run.log 實現類似 tail -f 的實時輸出。',
new_chat: '新對話',
@@ -710,6 +720,11 @@ const I18N = {
task_delete_btn: 'Delete Task',
task_delete_confirm_title: 'Delete Task',
task_delete_confirm_msg: 'Delete this scheduled task? This action cannot be undone.',
task_run_now: 'Run now',
task_run_confirm_title: 'Run task now',
task_run_confirm_msg: 'This task will immediately send to its configured channel and receiver. Continue?',
task_run_started: 'Run started',
task_run_failed: 'Run failed',
logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)',
logs_live: 'Live', logs_coming_msg: 'Log streaming will be available here. Connects to run.log for real-time output similar to tail -f.',
new_chat: 'New Chat',
@@ -7935,6 +7950,38 @@ function refreshTasksView() {
btn.disabled = false;
}, 500);
}
function runTaskNow(task, button) {
showConfirmDialog({
title: t('task_run_confirm_title'),
message: `${task.name || task.id}: ${t('task_run_confirm_msg')}`,
okText: t('task_run_now'),
onConfirm: () => {
const originalHtml = button.innerHTML;
button.disabled = true;
button.innerHTML = `<i class="fas fa-spinner fa-spin mr-1"></i>${t('task_run_now')}`;
fetch('/api/scheduler/run', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({task_id: task.id})
}).then(r => r.json()).then(res => {
if (res.status !== 'success') throw new Error(res.message || t('task_run_failed'));
button.innerHTML = `<i class="fas fa-check mr-1"></i>${t('task_run_started')}`;
setTimeout(() => {
button.innerHTML = originalHtml;
button.disabled = false;
}, 1500);
}).catch(() => {
button.innerHTML = `<i class="fas fa-triangle-exclamation mr-1"></i>${t('task_run_failed')}`;
setTimeout(() => {
button.innerHTML = originalHtml;
button.disabled = false;
}, 2000);
});
}
});
}
function loadTasksView() {
if (tasksLoaded) return;
fetch('/api/scheduler').then(r => r.json()).then(data => {
@@ -7996,11 +8043,19 @@ function loadTasksView() {
<div class="flex items-center gap-4 text-xs text-slate-400 dark:text-slate-500">
<span><i class="fas fa-clock mr-1"></i>${currentLang === 'zh' ? '下次执行' : 'Next run'}: ${nextRun}</span>
<div class="flex-1"></div>
<button type="button" class="task-run-now px-2 py-1 rounded-md text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-500/10 transition-colors">
<i class="fas fa-play mr-1"></i>${t('task_run_now')}
</button>
<label class="relative inline-flex items-center cursor-pointer" for="${toggleId}">
<input type="checkbox" id="${toggleId}" class="sr-only peer" ${isEnabled ? 'checked' : ''}>
<div class="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary-500 dark:bg-slate-600 dark:peer-checked:bg-primary-500"></div>
</label>
</div>`;
const runButton = card.querySelector('.task-run-now');
runButton.addEventListener('click', function(e) {
e.stopPropagation();
runTaskNow(task, runButton);
});
const checkbox = card.querySelector('#' + toggleId);
checkbox.addEventListener('change', function() {
const newEnabled = this.checked;

View File

@@ -1309,6 +1309,7 @@ class WebChannel(ChatChannel):
'/api/knowledge/action', 'KnowledgeActionHandler',
'/api/knowledge/import', 'KnowledgeImportHandler',
'/api/scheduler', 'SchedulerHandler',
'/api/scheduler/run', 'SchedulerRunHandler',
'/api/scheduler/toggle', 'SchedulerToggleHandler',
'/api/scheduler/update', 'SchedulerUpdateHandler',
'/api/scheduler/delete', 'SchedulerDeleteHandler',
@@ -4537,6 +4538,34 @@ class SchedulerHandler:
return json.dumps({"status": "error", "message": str(e)})
class SchedulerRunHandler:
def POST(self):
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
body = json.loads(web.data())
task_id = body.get("task_id")
if not task_id:
return json.dumps({"status": "error", "message": "task_id required"})
from agent.tools.scheduler.integration import get_scheduler_service
service = get_scheduler_service()
if service is None:
return json.dumps({
"status": "error",
"message": "Scheduler service is not running",
})
service.run_task_now(task_id)
return json.dumps({
"status": "success",
"message": f"Task '{task_id}' queued for immediate execution",
}, ensure_ascii=False)
except Exception as e:
logger.error(f"[WebChannel] Scheduler manual run error: {e}")
return json.dumps({"status": "error", "message": str(e)})
class SchedulerToggleHandler:
def POST(self):
_require_auth()

View File

@@ -372,6 +372,13 @@ class ApiClient {
return data.tasks
}
async runTask(taskId: string): Promise<ApiResult> {
return this.request('/api/scheduler/run', {
method: 'POST',
body: JSON.stringify({ task_id: taskId }),
})
}
async toggleTask(taskId: string, enabled: boolean): Promise<{ status: string; task: SchedulerTask }> {
return this.request('/api/scheduler/toggle', {
method: 'POST',

View File

@@ -352,6 +352,10 @@ const translations: Record<string, Record<string, string>> = {
task_delete: '删除',
task_delete_confirm: '确定删除该任务吗?此操作不可撤销。',
task_save_error: '保存失败',
task_run_now: '立即执行',
task_run_confirm: '该任务会立即向已配置的通道和接收者发送内容。是否继续?',
task_run_started: '任务已开始执行',
task_run_error: '执行失败',
logs_title: '日志',
logs_desc: '实时日志输出 (run.log)',
logs_live: '实时',
@@ -740,6 +744,10 @@ const translations: Record<string, Record<string, string>> = {
task_delete: 'Delete',
task_delete_confirm: 'Delete this task? This cannot be undone.',
task_save_error: 'Failed to save',
task_run_now: 'Run now',
task_run_confirm: 'This task will immediately send to its configured channel and receiver. Continue?',
task_run_started: 'Task run started',
task_run_error: 'Failed to run task',
logs_title: 'Logs',
logs_desc: 'Real-time log output (run.log)',
logs_live: 'Live',

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'
import { Loader2, Clock, CalendarClock } from 'lucide-react'
import { Loader2, Clock, CalendarClock, Play } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { t } from '../i18n'
import apiClient from '../api/client'
@@ -165,6 +165,8 @@ const TaskEditModal: React.FC<{
const [actionType, setActionType] = useState<TaskAction['type']>(task.action.type || 'send_message')
const [content, setContent] = useState(task.action.content || task.action.task_description || '')
const [saving, setSaving] = useState(false)
const [running, setRunning] = useState(false)
const [runStatus, setRunStatus] = useState('')
const [error, setError] = useState('')
const buildSchedule = (): TaskSchedule => {
@@ -209,6 +211,22 @@ const TaskEditModal: React.FC<{
}
}
const runNow = async () => {
if (!window.confirm(t('task_run_confirm'))) return
setRunning(true)
setRunStatus('')
setError('')
try {
const result = await apiClient.runTask(task.id)
if (result.status !== 'success') throw new Error(result.message || t('task_run_error'))
setRunStatus(t('task_run_started'))
} catch (e) {
setError(e instanceof Error ? e.message : t('task_run_error'))
} finally {
setRunning(false)
}
}
return (
<Modal
open
@@ -219,6 +237,10 @@ const TaskEditModal: React.FC<{
<Btn variant="danger" onClick={del} disabled={saving} className="mr-auto">
{t('task_delete')}
</Btn>
<Btn variant="ghost" onClick={runNow} disabled={saving || running}>
{running ? <Loader2 size={14} className="inline animate-spin mr-1" /> : <Play size={14} className="inline mr-1" />}
{t('task_run_now')}
</Btn>
<Btn variant="ghost" onClick={onClose} disabled={saving}>
{t('task_cancel')}
</Btn>
@@ -298,6 +320,7 @@ const TaskEditModal: React.FC<{
)}
<p className="text-xs text-content-tertiary">{t('task_channel_locked')}</p>
{runStatus && <p className="text-xs text-success">{runStatus}</p>}
{error && <p className="text-xs text-danger">{error}</p>}
</Modal>
)

View File

@@ -34,14 +34,17 @@ Create and manage scheduled tasks with natural language:
- "Check server status every 2 hours"
- "Remind me about the meeting tomorrow at 3 PM"
- "Show all scheduled tasks"
- "Run task a1b2c3d4 now"
## Run an existing task now
## Test-run an existing task
Use `action="run"` with a task ID to queue an immediate test run. Manual
execution works for disabled tasks and does not enable, delete, or reschedule
the task. Its original `next_run_at` remains unchanged. A task cannot be run
manually while the same task is already executing on schedule.
Open the task in the authenticated Web console or Desktop app and click **Run
now**. After you confirm the delivery, CowAgent queues one immediate execution
to the task's configured channel and receiver. This user-initiated action is not
exposed to the agent's scheduler tool.
Manual execution works for disabled tasks and does not enable, delete, or
reschedule the task. Its original `next_run_at` remains unchanged. A task cannot
be run manually while the same task is already executing on schedule.
<Frame>
<img src="https://cdn.link-ai.tech/doc/20260202195402.png" width="800" />

View File

@@ -34,11 +34,12 @@ description: 创建和管理定时任务
- "每隔 2 小时检查一下服务器状态"
- "明天下午 3 点提醒我开会"
- "查看所有定时任务"
- "立即执行任务 a1b2c3d4"
## 立即执行已有任务
## 测试执行已有任务
使用任务 ID 调用 `action="run"`,可以排队进行一次即时测试执行。禁用状态的任务也可手动执行,且不会因此被启用、删除或重新调度,原有 `next_run_at` 保持不变。同一任务正在按计划执行时,不允许重复手动执行
在经过身份认证的 Web 控制台或 Desktop 应用中打开任务,点击 **立即执行**。确认发送后CowAgent 会立即按任务已配置的通道和接收者执行一次。这个入口只允许用户手动触发,不会暴露给 Agent 的 scheduler tool
禁用状态的任务也可手动执行,且不会因此被启用、删除或重新调度,原有 `next_run_at` 保持不变。同一任务正在按计划执行时,不允许重复手动执行。
<Frame>
<img src="https://cdn.link-ai.tech/doc/20260202195402.png" width="800" />

View File

@@ -3,7 +3,6 @@
import threading
import time
from datetime import datetime, timedelta
from unittest.mock import Mock, patch
import pytest
@@ -72,18 +71,7 @@ def test_manual_run_rejects_duplicate_execution(tmp_path):
release.set()
def test_scheduler_tool_queues_existing_task():
store = Mock()
store.get_task.return_value = {"id": "task-1", "name": "refresh index"}
service = Mock()
tool = SchedulerTool()
tool.task_store = store
def test_scheduler_tool_does_not_expose_manual_execution():
actions = SchedulerTool.params["properties"]["action"]["enum"]
with patch(
"agent.tools.scheduler.integration.get_scheduler_service",
return_value=service,
):
result = tool.execute({"action": "run", "task_id": "task-1"})
assert result.status == "success"
service.run_task_now.assert_called_once_with("task-1")
assert "run" not in actions

View File

@@ -4,7 +4,8 @@ import json
import sys
import types
from datetime import datetime, timedelta
from unittest.mock import patch
from pathlib import Path
from unittest.mock import Mock, patch
from agent.tools.scheduler.task_store import TaskStore
@@ -28,7 +29,9 @@ if "web" not in sys.modules:
)
sys.modules["web"] = web_stub
from channel.web.web_channel import SchedulerUpdateHandler
from channel.web import web_channel
SchedulerUpdateHandler = web_channel.SchedulerUpdateHandler
def _store_task(tmp_path, action):
@@ -54,6 +57,57 @@ def _post_update(tmp_path, payload):
return json.loads(SchedulerUpdateHandler().POST())
def test_web_manual_run_is_authenticated_and_delegates_to_scheduler():
assert hasattr(web_channel, "SchedulerRunHandler")
service = Mock()
with patch("channel.web.web_channel._require_auth") as require_auth, \
patch("channel.web.web_channel.web.header"), \
patch("channel.web.web_channel.web.data", return_value=b'{"task_id":"task-1"}'), \
patch("agent.tools.scheduler.integration.get_scheduler_service", return_value=service):
response = json.loads(web_channel.SchedulerRunHandler().POST())
require_auth.assert_called_once_with()
service.run_task_now.assert_called_once_with("task-1")
assert response == {
"status": "success",
"message": "Task 'task-1' queued for immediate execution",
}
def test_web_manual_run_rejects_unavailable_scheduler():
assert hasattr(web_channel, "SchedulerRunHandler")
with patch("channel.web.web_channel._require_auth"), \
patch("channel.web.web_channel.web.header"), \
patch("channel.web.web_channel.web.data", return_value=b'{"task_id":"task-1"}'), \
patch("agent.tools.scheduler.integration.get_scheduler_service", return_value=None):
response = json.loads(web_channel.SchedulerRunHandler().POST())
assert response == {
"status": "error",
"message": "Scheduler service is not running",
}
def test_manual_run_is_exposed_by_explicit_web_and_desktop_controls():
root = Path(__file__).parents[1]
web_source = (root / "channel/web/web_channel.py").read_text(encoding="utf-8")
web_console = (root / "channel/web/static/js/console.js").read_text(encoding="utf-8")
desktop_client = (root / "desktop/src/renderer/src/api/client.ts").read_text(encoding="utf-8")
desktop_page = (root / "desktop/src/renderer/src/pages/TasksPage.tsx").read_text(encoding="utf-8")
assert "'/api/scheduler/run', 'SchedulerRunHandler'" in web_source
assert "function runTaskNow(task, button)" in web_console
assert "fetch('/api/scheduler/run'" in web_console
web_run = web_console[web_console.index("function runTaskNow(task, button)"):]
assert "showConfirmDialog({" in web_run[:2500]
assert "async runTask(taskId: string)" in desktop_client
assert "'/api/scheduler/run'" in desktop_client
assert "const runNow = async ()" in desktop_page
assert "window.confirm(t('task_run_confirm'))" in desktop_page
def test_web_edit_preserves_hidden_agent_action_fields(tmp_path):
store = _store_task(tmp_path, {
"type": "agent_task",