diff --git a/.gitignore b/.gitignore index 13613a5..177b413 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,11 @@ venv/ node_modules/ edict/frontend/node_modules/ +# Local env +edict/.env + # Backups *.bak* data +backups/ +deliverables/ diff --git a/dashboard/dashboard.html b/dashboard/dashboard.html index 515f2eb..f1b46e4 100644 --- a/dashboard/dashboard.html +++ b/dashboard/dashboard.html @@ -889,7 +889,7 @@ globalScanCheckedAt = String(raw.checkedAt || ''); globalScanCount = Number(raw.count || globalScanActions.length || 0); if(st){ - const at = globalScanCheckedAt.replace('T',' ').substring(11,19); + const at = fmtActivityTime(globalScanCheckedAt); const count = globalScanCount; st.textContent = count || at ? `最近巡检: ${count} 个动作${at ? ' · ' + at : ''}` : '未巡检'; } @@ -1105,7 +1105,7 @@ ${idle} 待命 ${offline?` ${offline} 离线`:''} ${unconf?` ${unconf} 未配置`:''} - 检测于 ${(data.checkedAt||'').substring(11,19)} + 检测于 ${fmtActivityTime(data.checkedAt||'')} `; } @@ -1703,16 +1703,48 @@ } } -function fmtActivityTime(ts){ - if(!ts)return ''; +function parseDateFlexible(ts){ + if(ts===null||ts===undefined||ts==='') return null; + if(ts instanceof Date) return isNaN(ts.getTime())?null:ts; + if(typeof ts==='number'){ - const d=new Date(ts); - return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`; + // 兼容秒级与毫秒级时间戳 + const ms = ts > 1e12 ? ts : ts * 1000; + const d = new Date(ms); + return isNaN(d.getTime()) ? null : d; } - if(typeof ts==='string'&&ts.length>=19){ - return ts.substring(11,19); + + if(typeof ts!=='string') return null; + const s = ts.trim(); + if(!s) return null; + + if(/^\d+$/.test(s)){ + const n = Number(s); + if(Number.isFinite(n)){ + const ms = n > 1e12 ? n : n * 1000; + const d = new Date(ms); + return isNaN(d.getTime()) ? null : d; + } } - return String(ts).substring(0,8); + + // "YYYY-MM-DD HH:mm:ss" 视为本地时间(历史数据兼容) + const localMatch = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/); + if(localMatch){ + const [, y, m, d, h, mi, se] = localMatch; + const dt = new Date(Number(y), Number(m)-1, Number(d), Number(h), Number(mi), Number(se)); + return isNaN(dt.getTime()) ? null : dt; + } + + // ISO 8601(含 Z / +08:00)走浏览器标准解析,自动换算到本地显示 + const iso = s.includes(' ') ? s.replace(' ', 'T') : s; + const parsed = new Date(iso); + return isNaN(parsed.getTime()) ? null : parsed; +} + +function fmtActivityTime(ts){ + const d = parseDateFlexible(ts); + if(!d) return ''; + return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`; } /* ══ MODEL CONFIG ══ */ @@ -2101,7 +2133,7 @@ const kLabel = kind==='assistant'?'回复':kind==='tool'?'工具':kind==='user'?'用户':'事件'; let txt = (a.text||'').replace(/\[\[.*?\]\]/g,'').replace(/\*\*/g,'').trim(); if(txt.length>200) txt=txt.substring(0,200)+'…'; - const time = (a.at||'').substring(11,19); + const time = fmtActivityTime(a.at); return `
${kIcon} @@ -2134,7 +2166,8 @@ function timeAgo(iso){ if(!iso) return ''; try{ - const d = new Date(iso.includes('T')?iso:iso.replace(' ','T')+'Z'); + const d = parseDateFlexible(iso); + if(!d) return ''; if(isNaN(d.getTime())) return ''; const diff = Date.now() - d.getTime(); const mins = Math.floor(diff/60000); @@ -2628,7 +2661,7 @@ const r = await postJ(API + '/scheduler-scan', {thresholdSec: 180}); if(r.ok){ const count = r.count || 0; - const at = (r.checkedAt || '').replace('T',' ').substring(11,19); + const at = fmtActivityTime(r.checkedAt || ''); globalScanActions = Array.isArray(r.actions) ? r.actions : []; globalScanCheckedAt = String(r.checkedAt || ''); globalScanCount = Number(count || 0); diff --git a/dashboard/server.py b/dashboard/server.py index 57fd64b..c734f33 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -1956,8 +1956,13 @@ def _do_dispatch(): 'lastDispatchTrigger': trigger, })) return + # NOTE: do not hardcode channel=feishu. In environments without Feishu channel, + # this causes: invalid agent params: unknown channel: feishu + # Also avoid --deliver unless you can provide a valid --channel/--reply-channel. + # For automated dispatch, we only need to trigger the agent turn; agents will + # update the kanban themselves via scripts. cmd = ['openclaw', 'agent', '--agent', agent_id, '-m', msg, - '--deliver', '--channel', 'feishu', '--timeout', '300'] + '--timeout', '300'] max_retries = 2 err = '' for attempt in range(1, max_retries + 1): diff --git a/edict/backend/app/models/task.py b/edict/backend/app/models/task.py index 13365ef..ff345e1 100644 --- a/edict/backend/app/models/task.py +++ b/edict/backend/app/models/task.py @@ -98,6 +98,7 @@ class Task(Base): scheduler = Column(JSONB, default=dict, comment="调度器元数据") template_id = Column(String(64), default="", comment="模板ID") template_params = Column(JSONB, default=dict, comment="模板参数") + meta = Column(JSONB, default=dict, comment="元数据") ac = Column(Text, default="", comment="验收标准") target_dept = Column(String(64), default="", comment="目标部门") @@ -136,7 +137,8 @@ def to_dict(self) -> dict: "templateParams": self.template_params or {}, "ac": self.ac, "targetDept": self.target_dept, - "_scheduler": self.scheduler or {}, + "scheduler": self.scheduler or {}, "createdAt": self.created_at.isoformat() if self.created_at else "", + "meta": self.meta or {}, "updatedAt": self.updated_at.isoformat() if self.updated_at else "", } diff --git a/edict/backend/app/services/task_service.py b/edict/backend/app/services/task_service.py index 3399b73..01c8c62 100644 --- a/edict/backend/app/services/task_service.py +++ b/edict/backend/app/services/task_service.py @@ -86,6 +86,7 @@ async def create_task( "state": initial_state.value, "priority": priority, "assignee_org": assignee_org, + "meta": meta or {}, }, ) diff --git a/install.sh b/install.sh index 6dc4038..1093fd7 100755 --- a/install.sh +++ b/install.sh @@ -24,6 +24,8 @@ warn() { echo -e "${YELLOW}⚠️ $1${NC}"; } error() { echo -e "${RED}❌ $1${NC}"; } info() { echo -e "${BLUE}ℹ️ $1${NC}"; } +AGENTS=(taizi zhongshu menxia shangshu hubu libu bingbu xingbu gongbu libu_hr zaochao) + # ── Step 0: 依赖检查 ────────────────────────────────────────── check_deps() { info "检查依赖..." @@ -91,8 +93,7 @@ backup_existing() { # ── Step 1: 创建 Workspace ────────────────────────────────── create_workspaces() { info "创建 Agent Workspace..." - - AGENTS=(taizi zhongshu menxia shangshu hubu libu bingbu xingbu gongbu libu_hr zaochao) + for agent in "${AGENTS[@]}"; do ws="$OC_HOME/workspace-$agent" mkdir -p "$ws/skills" @@ -120,6 +121,50 @@ AGENTS_EOF done } +# ── Step 1.5: 统一 data 软链到仓库 data 目录 ─────────────────── +link_workspace_data() { + info "为 Agent Workspace 建立 data 软链..." + + local target_data="$REPO_DIR/data" + local target_real + target_real="$(python3 -c 'import os,sys;print(os.path.realpath(sys.argv[1]))' "$target_data")" + + local has_workspace=false + for agent in "${AGENTS[@]}"; do + local ws="$OC_HOME/workspace-$agent" + [ -d "$ws" ] || { + warn "workspace-$agent 不存在,跳过 data 软链建立" + continue + } + has_workspace=true + + local ws_data="$ws/data" + local ws_name + ws_name="$(basename "$ws")" + + if [ -L "$ws_data" ]; then + local current_real + current_real="$(python3 -c 'import os,sys;print(os.path.realpath(sys.argv[1]))' "$ws_data")" + if [ "$current_real" = "$target_real" ]; then + log "$ws_name: data 软链已正确指向 $target_data" + continue + fi + mv "$ws_data" "$ws_data.bak.$(date +%Y%m%d-%H%M%S)" + warn "$ws_name: 旧 data 软链已备份,重新建立指向 $target_data" + elif [ -e "$ws_data" ]; then + mv "$ws_data" "$ws_data.bak.$(date +%Y%m%d-%H%M%S)" + warn "$ws_name: 旧 data 目录/文件已备份,重新建立软链" + fi + + ln -s "$target_data" "$ws_data" + log "$ws_name: data -> $target_data" + done + + if ! $has_workspace; then + warn "未检测到安装脚本目标 Workspace,跳过 data 软链建立" + fi +} + # ── Step 2: 注册 Agents ───────────────────────────────────── register_agents() { info "注册三省六部 Agents..." @@ -277,6 +322,7 @@ backup_existing create_workspaces register_agents init_data +link_workspace_data build_frontend first_sync restart_gateway diff --git a/scripts/run_loop.sh b/scripts/run_loop.sh index 53eef5c..d30f08f 100755 --- a/scripts/run_loop.sh +++ b/scripts/run_loop.sh @@ -73,6 +73,15 @@ while true; do safe_run "$SCRIPT_DIR/sync_officials_stats.py" safe_run "$SCRIPT_DIR/refresh_live_data.py" + # 太子通讯督办:确保关键任务每20分钟至少回报一次(写入看板 progress)。 + # 注意:这里只对少数旗舰任务启用,避免刷屏。 + python3 "$SCRIPT_DIR/taizi_20min_ping.py" --task JJC-20260305-002 --max-minutes 20 \ + --now "强制20分钟同步:正在督办RouteA vNext交付与验收" \ + --plan "收集各部回填🔄|催办未回填项🔄|汇总回奏皇上" >> "$LOG" 2>&1 || true + python3 "$SCRIPT_DIR/taizi_20min_ping.py" --task JJC-20260305-003 --max-minutes 20 \ + --now "强制20分钟同步:正在复盘同步失效根因并落地机制" \ + --plan "收集事实与时间线🔄|定位根因与对策🔄|落地机制并验收" >> "$LOG" 2>&1 || true + # 定期巡检:检测卡住的任务并自动重试 SCAN_COUNTER=$((SCAN_COUNTER + INTERVAL)) if (( SCAN_COUNTER >= SCAN_INTERVAL )); then diff --git a/scripts/sync_from_openclaw_runtime.py b/scripts/sync_from_openclaw_runtime.py index 8b3de21..7d3ee6f 100644 --- a/scripts/sync_from_openclaw_runtime.py +++ b/scripts/sync_from_openclaw_runtime.py @@ -312,7 +312,23 @@ def main(): try: existing = json.loads(existing_tasks_file.read_text()) jjc_existing = [t for t in existing if str(t.get('id', '')).startswith('JJC')] - + + # ✅ 防止 JJC 任务 state 被旧快照误降级: + # 1) 默认以 progress_log 最近一次上报的 state 为准(若存在) + # 2) 但加入“反降级”硬规则:若当前 state 属于执行态(Doing/Review/Blocked),禁止降级为 Assigned/Next/Inbox。 + for t in jjc_existing: + current_state = str(t.get('state') or '').strip() + pl = t.get('progress_log') or [] + if pl: + last = pl[-1] or {} + last_state = str(last.get('state') or '').strip() + if last_state: + # 反降级:执行态不得被 progress 尾部错误固化为 Assigned/Next/Inbox + if current_state in ('Doing', 'Review', 'Blocked') and last_state in ('Assigned', 'Next', 'Inbox'): + pass + else: + t['state'] = last_state + # 去掉 tasks 里已有的 JJC(以防重复),再把旨意放到最前面 tasks = [t for t in tasks if not str(t.get('id', '')).startswith('JJC')] tasks = jjc_existing + tasks diff --git a/scripts/taizi_20min_ping.py b/scripts/taizi_20min_ping.py new file mode 100644 index 0000000..3d56483 --- /dev/null +++ b/scripts/taizi_20min_ping.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""taizi_20min_ping.py + +A simple watchdog that ensures the taizi agent posts a minimal progress update +at least every N minutes for specific active tasks. + +Design goals: +- Be safe: no messaging APIs; only writes to kanban via kanban_update.py progress. +- Be conservative: only ping tasks in non-terminal states. + +Usage: + python3 scripts/taizi_20min_ping.py --task JJC-... --max-minutes 20 --now "..." --plan "...|..." + +Typical use: invoked by ops/edict run_loop.sh or cron. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import subprocess +from pathlib import Path + +BASE = Path(__file__).resolve().parent.parent +TASKS = BASE / "data" / "tasks_source.json" +K_UPDATE = BASE / "scripts" / "kanban_update.py" + +TERMINAL = {"Done", "Cancelled"} + + +def now_utc() -> dt.datetime: + return dt.datetime.now(dt.timezone.utc) + + +def parse_iso(s: str) -> dt.datetime | None: + try: + return dt.datetime.fromisoformat(s.replace("Z", "+00:00")) + except Exception: + return None + + +def load_tasks() -> list[dict]: + if not TASKS.exists(): + return [] + return json.loads(TASKS.read_text()) + + +def find_task(tasks: list[dict], task_id: str) -> dict | None: + for t in tasks: + if t.get("id") == task_id: + return t + return None + + +def last_progress_at(task: dict) -> dt.datetime | None: + pl = task.get("progress_log") or [] + if not pl: + return parse_iso(task.get("updatedAt") or "") + last = pl[-1] + return parse_iso(last.get("at") or "") or parse_iso(task.get("updatedAt") or "") + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--task", required=True) + ap.add_argument("--max-minutes", type=int, default=20) + ap.add_argument("--now", required=True) + ap.add_argument("--plan", required=True) + args = ap.parse_args() + + tasks = load_tasks() + task = find_task(tasks, args.task) + if not task: + return 0 + + if (task.get("state") in TERMINAL) or (task.get("org") in ("完成",)): + return 0 + + last = last_progress_at(task) + if not last: + # no timestamp: ping once + should = True + else: + gap = (now_utc() - last).total_seconds() / 60 + should = gap >= args.max_minutes + + if not should: + return 0 + + cmd = [ + "python3", + str(K_UPDATE), + "progress", + args.task, + args.now, + args.plan, + ] + subprocess.run(cmd, cwd=str(BASE), check=False) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())