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())