Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ venv/
node_modules/
edict/frontend/node_modules/

# Local env
edict/.env

# Backups
*.bak*
data
backups/
deliverables/
57 changes: 45 additions & 12 deletions dashboard/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 : ''}` : '未巡检';
}
Expand Down Expand Up @@ -1105,7 +1105,7 @@
<span><span class="as-dot idle" style="position:static;width:8px;height:8px"></span> ${idle} 待命</span>
${offline?`<span><span class="as-dot offline" style="position:static;width:8px;height:8px"></span> ${offline} 离线</span>`:''}
${unconf?`<span><span class="as-dot unconfigured" style="position:static;width:8px;height:8px"></span> ${unconf} 未配置</span>`:''}
<span style="margin-left:auto;font-size:10px;color:var(--muted)">检测于 ${(data.checkedAt||'').substring(11,19)}</span>
<span style="margin-left:auto;font-size:10px;color:var(--muted)">检测于 ${fmtActivityTime(data.checkedAt||'')}</span>
</div>
</div>`;
}
Expand Down Expand Up @@ -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 ══ */
Expand Down Expand Up @@ -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 `<div style="padding:8px 12px;border-bottom:1px solid var(--line);font-size:12px;line-height:1.5">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:3px">
<span>${kIcon}</span>
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 6 additions & 1 deletion dashboard/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
4 changes: 3 additions & 1 deletion edict/backend/app/models/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="目标部门")

Expand Down Expand Up @@ -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 "",
}
1 change: 1 addition & 0 deletions edict/backend/app/services/task_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ async def create_task(
"state": initial_state.value,
"priority": priority,
"assignee_org": assignee_org,
"meta": meta or {},
},
)

Expand Down
50 changes: 48 additions & 2 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 "检查依赖..."
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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..."
Expand Down Expand Up @@ -277,6 +322,7 @@ backup_existing
create_workspaces
register_agents
init_data
link_workspace_data
build_frontend
first_sync
restart_gateway
Expand Down
9 changes: 9 additions & 0 deletions scripts/run_loop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion scripts/sync_from_openclaw_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
105 changes: 105 additions & 0 deletions scripts/taizi_20min_ping.py
Original file line number Diff line number Diff line change
@@ -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())