dsh-offhours 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -3
- package/bin/offhours.mjs +29 -4
- package/feature-worktrees/feature-lib.sh +41 -19
- package/lib/client.js +17 -0
- package/lib/config.js +32 -4
- package/mechanism/ns.py +107 -8
- package/mechanism/scan.sh +82 -3
- package/mechanism/selftest.py +23 -2
- package/mechanism/superagent_bridge.py +426 -0
- package/package.json +5 -4
- package/panel/client.js +17 -0
- package/panel/index.js +36 -8
- package/panel/locale/en.json +2 -1
- package/panel/locale/zh.json +2 -1
- package/skills/offhours/SKILL.md +1 -0
- package/feature-worktrees/README.md +0 -361
- package/mechanism/README.md +0 -266
- package/panel/cordis.patch.yml +0 -3
package/mechanism/scan.sh
CHANGED
|
@@ -62,6 +62,44 @@ ISOLATED=${NIGHTSHIFT_ISOLATED_WORKTREE:-0}
|
|
|
62
62
|
ONLY_LIST=$(printf '%s' "${ONLY:-}" | tr ',' ' ')
|
|
63
63
|
LOG=$RUNTIME_DIR/nightshift.log
|
|
64
64
|
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# 执行器分派:superagent 模式不再起本地 headless worker。
|
|
67
|
+
# 夜间跑批只把卡变成 Gitea issue(方案 A),superagent 的 Source/Eval/Ticket/Executor
|
|
68
|
+
# 负责认领、独立 worktree、PR 与 attention;这里只做低谷闸门 + 派发 + 状态回写。
|
|
69
|
+
EXECUTOR=${NIGHTSHIFT_EXECUTOR:-local}
|
|
70
|
+
if [ "$EXECUTOR" = "superagent" ]; then
|
|
71
|
+
mkdir -p "$RUNTIME_DIR"
|
|
72
|
+
CFG=${NIGHTSHIFT_SUPERAGENT_CONFIG:-${OFFHOURS_CONFIG:-}}
|
|
73
|
+
BRIDGE="$SELF_DIR/superagent_bridge.py"
|
|
74
|
+
_sa_log() { printf '%s [%s] %s\n' "$(date '+%F %T')" "${NIGHTSHIFT_BOARD##*/}" "$*" >> "$LOG"; }
|
|
75
|
+
if [ -z "$CFG" ] || [ ! -f "$CFG" ]; then
|
|
76
|
+
echo "NIGHTSHIFT_SUPERAGENT_CONFIG 未设置或不存在:${CFG:-<empty>}" >&2
|
|
77
|
+
exit 2
|
|
78
|
+
fi
|
|
79
|
+
[ -f "$BRIDGE" ] || { echo "缺少 $BRIDGE" >&2; exit 2; }
|
|
80
|
+
if [ "$DRY" = 1 ]; then
|
|
81
|
+
python3 "$BRIDGE" dispatch "$BOARD" --config "$CFG" --dry-run
|
|
82
|
+
exit 0
|
|
83
|
+
fi
|
|
84
|
+
DISABLED="${DISABLED:-$RUNTIME_DIR/DISABLED}"
|
|
85
|
+
FORCE_ARG=""
|
|
86
|
+
[ "$FORCE" = 1 ] && FORCE_ARG="--force"
|
|
87
|
+
if [ "$DAEMON" = 1 ]; then
|
|
88
|
+
_sa_log "SUPERAGENT daemon 启动(派发 issue + 回写卡状态)"
|
|
89
|
+
while :; do
|
|
90
|
+
date +%s > "$RUNTIME_DIR/DAEMON.heartbeat" 2>/dev/null || true
|
|
91
|
+
[ -f "$DISABLED" ] && { _sa_log "SUPERAGENT 暂停(DISABLED)"; sleep 30; continue; }
|
|
92
|
+
python3 "$BRIDGE" dispatch "$BOARD" --config "$CFG" --limit 1 $FORCE_ARG >>"$LOG" 2>&1 || _sa_log "dispatch 失败"
|
|
93
|
+
python3 "$BRIDGE" sync "$BOARD" --config "$CFG" >>"$LOG" 2>&1 || _sa_log "sync 失败"
|
|
94
|
+
sleep 10
|
|
95
|
+
done
|
|
96
|
+
fi
|
|
97
|
+
date +%s > "$RUNTIME_DIR/DAEMON.heartbeat" 2>/dev/null || true
|
|
98
|
+
python3 "$BRIDGE" dispatch "$BOARD" --config "$CFG" --limit 1 $FORCE_ARG >>"$LOG" 2>&1 || _sa_log "dispatch 失败"
|
|
99
|
+
python3 "$BRIDGE" sync "$BOARD" --config "$CFG" >>"$LOG" 2>&1 || _sa_log "sync 失败"
|
|
100
|
+
exit 0
|
|
101
|
+
fi
|
|
102
|
+
|
|
65
103
|
# ---------------------------------------------------------------------------
|
|
66
104
|
# worker 提示词的「稳定头」:**所有 worker 逐字节完全相同**,因此它是可被
|
|
67
105
|
# DeepSeek 磁盘缓存识别并持久化的公共前缀单元(官方文档:命中要求完全匹配一个
|
|
@@ -184,13 +222,36 @@ rotate_log() {
|
|
|
184
222
|
# 每轮跑完后立刻开始下一轮;没有可跑卡时等 10 秒再试,直到高峰/暂停。
|
|
185
223
|
if [ "$DAEMON" = 1 ]; then
|
|
186
224
|
log "DAEMON 启动(每轮最多 $MAX_CARDS_PER_RUN 张,轮间空档 ≤10s)"
|
|
225
|
+
# BOOT-RESET-ATTEMPTS:机器重启/实例故障后,把 attempts 清零——那是"环境中断"而不是"卡片失败",
|
|
226
|
+
# 否则连断两次就把好卡推进 review(用户 2026-11-04 明确要求:故障后要能及时治愈回来)。
|
|
227
|
+
# 状态文件按 main 的「运行目录」口径(NIGHTSHIFT_STATE=$RUNTIME_DIR/state-<板>.json)扫描。
|
|
228
|
+
python3 - "$RUNTIME_DIR" <<'PYEOF' 2>/dev/null || true
|
|
229
|
+
import json, os, sys, glob
|
|
230
|
+
d = sys.argv[1]
|
|
231
|
+
for f in glob.glob(os.path.join(d, "state.json")) + glob.glob(os.path.join(d, "state-*.json")):
|
|
232
|
+
try:
|
|
233
|
+
o = json.load(open(f, encoding="utf-8"))
|
|
234
|
+
except Exception:
|
|
235
|
+
continue
|
|
236
|
+
if isinstance(o, dict) and o.get("attempts"):
|
|
237
|
+
o["attempts"] = {}
|
|
238
|
+
json.dump(o, open(f, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
|
|
239
|
+
print("reset attempts:", f)
|
|
240
|
+
PYEOF
|
|
241
|
+
log "已清零 attempts(视为上次中断是环境故障)"
|
|
242
|
+
# HEARTBEAT-LOOP:独立的 10 秒心跳(轮次同步执行时也能证明"调度器活着")
|
|
243
|
+
# 读写方约定:panel/index.js 的 NS_DIR 与 deploy/selfcheck.sh 都读部署目录下的 DAEMON.heartbeat,
|
|
244
|
+
# 所以写 $SELF_DIR(=扫描器所在目录);不写 $RUNTIME_DIR,否则面板读不到。
|
|
245
|
+
( while :; do date +%s > "$SELF_DIR/DAEMON.heartbeat" 2>/dev/null; sleep 10; done ) &
|
|
246
|
+
_HB_PID=$!
|
|
247
|
+
trap 'kill $_HB_PID 2>/dev/null' EXIT
|
|
187
248
|
while :; do
|
|
188
249
|
[ -f "$DISABLED" ] && { log "DAEMON 暂停(DISABLED)"; sleep 30; continue; }
|
|
189
250
|
python3 "$SELF_DIR/ns.py" offpeak >/dev/null 2>&1 || { log "DAEMON 峰段休眠 60s"; sleep 60; continue; }
|
|
190
251
|
# INVARIANT-HEAL:每轮后自愈(僵尸槽/错位槽/孤儿卡),把"状态一致性"变成常态而非人工
|
|
191
252
|
python3 "$SELF_DIR/ns.py" invariants "$BOARD" --heal >/dev/null 2>&1 || true
|
|
192
253
|
# HEARTBEAT:面板在沙箱里无法 systemctl,只能靠心跳文件判断调度器是否活着
|
|
193
|
-
date +%s > "$
|
|
254
|
+
date +%s > "$SELF_DIR/DAEMON.heartbeat" 2>/dev/null || true
|
|
194
255
|
MAX_CARDS_PER_RUN="${NIGHTSHIFT_DAEMON_MAX_PER_ROUND:-3}" "$0" --force >/dev/null 2>&1 || true
|
|
195
256
|
sleep 10
|
|
196
257
|
done
|
|
@@ -264,7 +325,8 @@ acquire_worker_slot() {
|
|
|
264
325
|
rm -rf -- "$SLOTS/$i" # 僵尸 ⇒ 回收
|
|
265
326
|
log "回收僵尸槽位 $i(PID ${p:-未知} 已不在)"
|
|
266
327
|
fi
|
|
267
|
-
|
|
328
|
+
# SLOT-OWNER:槽位记录所属看板,避免对 A 板跑 invariants 时误伤 B 板的槽位
|
|
329
|
+
mkdir -p "$SLOTS/$i" && echo $$ > "$SLOTS/$i/pid" && printf '%s\n' "${BOARDKEY:-ops}" > "$SLOTS/$i/board" 2>/dev/null || true
|
|
268
330
|
echo "$i"; return 0
|
|
269
331
|
done
|
|
270
332
|
return 1
|
|
@@ -513,6 +575,13 @@ PYST
|
|
|
513
575
|
$PROMPT_HEAD
|
|
514
576
|
|
|
515
577
|
=== 本次任务(以下为逐卡变量;上面的硬规则对本次任务同样适用)===
|
|
578
|
+
|
|
579
|
+
### DEVLOOP-CLOSE(2026-11-04):与 dev-loop 闭环
|
|
580
|
+
- 若工程存在 `scripts/devloop-round.sh`:**必须**按 dev-loop 一轮执行
|
|
581
|
+
`status` → `begin --round=<n>` → 干活 → `gate --all` → `finish`;
|
|
582
|
+
- **判据以 gate 退出码/gate 报告为准**,不许用"看起来对"代替;
|
|
583
|
+
- gate 红:只修本次改动引入的问题;与本次无关的历史红,写进卡证据并继续可做的部分;
|
|
584
|
+
- 收尾把三行结论写进 `docs/devloop/ledger.md`(工程有该文件时)。
|
|
516
585
|
卡片 id:$cid
|
|
517
586
|
源自会话:${SOURCE_SESSION:-未记录}(DSH 无 fork CLI,血缘记在卡上)
|
|
518
587
|
工作区:$WORKDIR (看板文件 $BOARD 由外层脚本维护:**你不要读改看板、也不要调用 board 相关工具——本 profile 没有它们**)
|
|
@@ -783,6 +852,9 @@ if [ "$PARALLEL" -gt 1 ] && [ "$DRY" != 1 ]; then
|
|
|
783
852
|
# 波次循环:每波并发 PARALLEL 张,跑满 MAX_CARDS_PER_RUN 为止
|
|
784
853
|
# 2026-11-01:槽位一空就补位(条件加 pids 非空)
|
|
785
854
|
pids=""; wave_n=0 # 只在进入循环前初始化一次;循环内不得重置,否则会丢掉仍在跑的 pid
|
|
855
|
+
# 2026-11-10:同源会话串行保护。同一 sourceSessionId 的多张卡并行时 merge-back 会撞
|
|
856
|
+
# 同一个源 worktree(实测 .devloop/posture 冲突、结果行串味),本轮只放一个 worker。
|
|
857
|
+
ACTIVE_SESSIONS=""
|
|
786
858
|
while [ "$launched" -lt "$MAX_CARDS_PER_RUN" ] || [ -n "$pids" ]; do
|
|
787
859
|
wave=$((wave+1))
|
|
788
860
|
while [ "$wave_n" -lt "$PARALLEL" ] && [ "$launched" -lt "$MAX_CARDS_PER_RUN" ]; do
|
|
@@ -791,7 +863,7 @@ if [ "$PARALLEL" -gt 1 ] && [ "$DRY" != 1 ]; then
|
|
|
791
863
|
if r=$(budget_deny_reason); then log "预算拒绝:$r"; break; fi
|
|
792
864
|
if r=$(cost_deny_reason); then log "花费拒绝:$r"; break; fi
|
|
793
865
|
fi
|
|
794
|
-
PICK=$(python3 "$SELF_DIR/ns.py" pick "$BOARD")
|
|
866
|
+
PICK=$(NIGHTSHIFT_ACTIVE_SESSIONS="$ACTIVE_SESSIONS" python3 "$SELF_DIR/ns.py" pick "$BOARD")
|
|
795
867
|
cid=$(printf '%s' "$PICK" | python3 -c "import sys,json;print((json.load(sys.stdin).get('picked') or {}).get('id',''))")
|
|
796
868
|
[ -n "$cid" ] || break
|
|
797
869
|
title=$(printf '%s' "$PICK" | python3 -c "import sys,json;print((json.load(sys.stdin).get('picked') or {}).get('title',''))")
|
|
@@ -828,7 +900,14 @@ if [ "$PARALLEL" -gt 1 ] && [ "$DRY" != 1 ]; then
|
|
|
828
900
|
SRC_S=$(printf '%s\n' "$SRC" | sed -n 's/^SESSION=//p')
|
|
829
901
|
SRC_F=$(printf '%s\n' "$SRC" | sed -n 's/^FEATURE=//p')
|
|
830
902
|
SRC_P=$(printf '%s\n' "$SRC" | sed -n 's/^PROJECT=//p')
|
|
903
|
+
# source-of 在“自动建特性”的卡上只返回 E_DRY_RUN(父进程不建特性),不带 SESSION;
|
|
904
|
+
# 这时用 pick 自带的 sourceSessionId 作为串行键,否则同源会话保护会漏掉首轮。
|
|
905
|
+
if [ -z "$SRC_S" ]; then
|
|
906
|
+
SRC_S=$(printf '%s' "$PICK" | python3 -c "import sys,json;print((json.load(sys.stdin).get('picked') or {}).get('sourceSessionId') or '')" 2>/dev/null || true)
|
|
907
|
+
fi
|
|
831
908
|
SRC_W=$(printf '%s\n' "$SRC" | sed -n 's/^WORKTREE=//p')
|
|
909
|
+
# 这张卡的源会话进入本轮 active 集:同会话下一张卡等到下一轮/下一波。
|
|
910
|
+
[ -n "$SRC_S" ] && ACTIVE_SESSIONS="$ACTIVE_SESSIONS $SRC_S"
|
|
832
911
|
log "并行开跑($((launched+1))/$PARALLEL):$title(card=$cid)"
|
|
833
912
|
(
|
|
834
913
|
export NIGHTSHIFT_SOURCE_SESSION="$SRC_S" NIGHTSHIFT_SOURCE_FEATURE="$SRC_F" \
|
package/mechanism/selftest.py
CHANGED
|
@@ -38,12 +38,12 @@ def new_board(cards):
|
|
|
38
38
|
json.dump({"version": 1, "cards": cards}, fh, ensure_ascii=False, indent=2)
|
|
39
39
|
|
|
40
40
|
|
|
41
|
-
def card(cid, title="测试卡", tags=None, desc="说明", status="todo"):
|
|
41
|
+
def card(cid, title="测试卡", tags=None, desc="说明", status="todo", sid="session-selftest"):
|
|
42
42
|
# sourceSessionId 是 eligible() 的硬门槛(2026-11-01 起:没有来源会话 ⇒ 一定 E_NO_SOURCE,
|
|
43
43
|
# 选卡阶段就排除)。夹具必须带上,否则 pick 恒为 None、整个用例集失去意义。
|
|
44
44
|
return {"id": cid, "title": title, "status": status, "tags": tags or [],
|
|
45
45
|
"description": desc, "createdAt": 1, "updatedAt": 1,
|
|
46
|
-
"sourceSessionId":
|
|
46
|
+
"sourceSessionId": sid}
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
def read_card(cid):
|
|
@@ -70,6 +70,16 @@ check("todo 总数=5", pick["todoCount"] == 5, pick["todoCount"])
|
|
|
70
70
|
check("hold 卡被计入 held", pick["heldCount"] == 1, pick["heldCount"])
|
|
71
71
|
check("决策卡被剔除(拍板/阻塞)", len(pick["decisionCards"]) == 2, pick["decisionCards"])
|
|
72
72
|
|
|
73
|
+
# 2026-11-10:同源会话串行保护。两张卡来自不同会话时,active 其一只能挑另一张。
|
|
74
|
+
new_board([
|
|
75
|
+
card("p1", "会话1的卡", ["auto"], sid="session-p1"),
|
|
76
|
+
card("p2", "会话2的卡", ["auto"], sid="session-p2"),
|
|
77
|
+
])
|
|
78
|
+
p2 = json.loads(os.popen("NIGHTSHIFT_ACTIVE_SESSIONS=session-p1 python3 %s/ns.py pick %s" % (HERE, BOARD)).read())
|
|
79
|
+
check("同源会话 active 时挑不同会话卡", (p2.get("picked") or {}).get("id") == "p2", p2)
|
|
80
|
+
p3 = json.loads(os.popen("NIGHTSHIFT_ACTIVE_SESSIONS='session-p1 session-p2' python3 %s/ns.py pick %s" % (HERE, BOARD)).read())
|
|
81
|
+
check("全部同源会话都在跑时返回空", p3.get("picked") is None and p3.get("blockedSessionCount") == 2, p3)
|
|
82
|
+
|
|
73
83
|
print("\n== 2) done 必须有证据 ==")
|
|
74
84
|
new_board([card("d1")])
|
|
75
85
|
ns.cmd_apply([BOARD, "d1", result_file("KANBAN-RESULT: done\nKANBAN-SUMMARY: 做完了\n")])
|
|
@@ -249,3 +259,14 @@ check("claim 后看板形状合法", ns.board_shape_errors(json.load(open(BOARD,
|
|
|
249
259
|
|
|
250
260
|
print("\n=== 结果:%s ===" % ("全部通过" if not fails else "失败 %d 项:%s" % (len(fails), fails)))
|
|
251
261
|
sys.exit(1 if fails else 0)
|
|
262
|
+
|
|
263
|
+
print("\n== R) 审批窗口:全部按推荐批准覆盖无审批单卡 ==")
|
|
264
|
+
new_board([
|
|
265
|
+
card("ra", "无审批单的 review", status="review"),
|
|
266
|
+
card("rb", "有审批单的 review", status="review",
|
|
267
|
+
desc="【审批单 · explain-plain】\n- O1:按推荐做|代价小\n- O2:不做\n"),
|
|
268
|
+
])
|
|
269
|
+
ns.cmd_confirm_recommended([BOARD])
|
|
270
|
+
check("无审批单卡也回 todo", read_card("ra")["status"] == "todo", read_card("ra")["status"])
|
|
271
|
+
check("有审批单卡按推荐回 todo", read_card("rb")["status"] == "todo", read_card("rb")["status"])
|
|
272
|
+
check("确认块写回无审批单卡", "【确认 " in (read_card("ra").get("description") or ""))
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""dsh-offhours × dsh-superagent 的 Issue 桥接(方案 A)。
|
|
3
|
+
|
|
4
|
+
夜间跑批只负责:低谷/预算闸门 + 把 KANBAN 卡变成 Gitea `agent:ready`/@agent issue。
|
|
5
|
+
superagent 负责:Source → Eval → Ticket → 独立 Session/worktree → PR/评审/attention。
|
|
6
|
+
本桥接不执行代码、不合并 PR、不修改 superagent 的 policy。
|
|
7
|
+
|
|
8
|
+
用法:
|
|
9
|
+
superagent_bridge.py dispatch <BOARD> --config <config.json> [--limit N] [--dry-run]
|
|
10
|
+
superagent_bridge.py sync <BOARD> --config <config.json>
|
|
11
|
+
superagent_bridge.py status <BOARD> --config <config.json>
|
|
12
|
+
|
|
13
|
+
卡片上写入的字段(ns.py/面板会忽略未知字段):
|
|
14
|
+
superagentIssue: {number, url, at}
|
|
15
|
+
superagentPr: {number, url, state}
|
|
16
|
+
superagentState: issue-open | pr-open | merged | reviewed | failed
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import fcntl
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
import time
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from urllib.parse import urlparse
|
|
32
|
+
from urllib.request import Request, urlopen
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def now_iso() -> str:
|
|
36
|
+
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def log(msg: str) -> None:
|
|
40
|
+
print(f"[superagent-bridge] {msg}", file=sys.stderr)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_json(path: str):
|
|
44
|
+
with open(path, encoding="utf-8") as fh:
|
|
45
|
+
return json.load(fh)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def atomic_write(path: str, data) -> None:
|
|
49
|
+
p = Path(path)
|
|
50
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
fd, tmp = tempfile.mkstemp(prefix=p.name + ".", dir=str(p.parent))
|
|
52
|
+
try:
|
|
53
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
54
|
+
json.dump(data, fh, ensure_ascii=False, indent=2)
|
|
55
|
+
fh.write("\n")
|
|
56
|
+
os.replace(tmp, path)
|
|
57
|
+
finally:
|
|
58
|
+
try:
|
|
59
|
+
os.unlink(tmp)
|
|
60
|
+
except FileNotFoundError:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def with_board_lock(board_path: str):
|
|
65
|
+
"""Sidecar lock; ns.py/panel writes are short, this serialises bridge RMW only."""
|
|
66
|
+
class _Lock:
|
|
67
|
+
def __enter__(self):
|
|
68
|
+
self.fh = open(board_path + ".superagent.lock", "a+")
|
|
69
|
+
fcntl.flock(self.fh, fcntl.LOCK_EX)
|
|
70
|
+
return self
|
|
71
|
+
def __exit__(self, *exc):
|
|
72
|
+
fcntl.flock(self.fh, fcntl.LOCK_UN)
|
|
73
|
+
self.fh.close()
|
|
74
|
+
return _Lock()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def board_config(config: dict, board_path: str) -> dict | None:
|
|
78
|
+
target = os.path.realpath(board_path)
|
|
79
|
+
for b in config.get("boards", []):
|
|
80
|
+
if os.path.realpath(str(b.get("file") or "")) == target:
|
|
81
|
+
return b
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def superagent_config(config: dict, board: dict) -> dict:
|
|
86
|
+
sa = config.get("superagent") or {}
|
|
87
|
+
if board.get("executor", config.get("executor", "local")) != "superagent":
|
|
88
|
+
raise SystemExit("board.executor 不是 superagent")
|
|
89
|
+
missing = [k for k in ("cli", "tokenFile", "repo") if not sa.get(k)]
|
|
90
|
+
if missing:
|
|
91
|
+
raise SystemExit("superagent 配置缺少:" + ",".join(missing))
|
|
92
|
+
if not os.path.exists(sa["tokenFile"]):
|
|
93
|
+
raise SystemExit(f"superagent tokenFile 不存在:{sa['tokenFile']}")
|
|
94
|
+
if not os.access(sa["cli"], os.X_OK):
|
|
95
|
+
raise SystemExit(f"superagent cli 不可执行:{sa['cli']}")
|
|
96
|
+
return {
|
|
97
|
+
"cli": sa["cli"],
|
|
98
|
+
"tokenFile": sa["tokenFile"],
|
|
99
|
+
"repo": sa["repo"],
|
|
100
|
+
"mention": str(sa.get("mention") or "@dsh-agent"),
|
|
101
|
+
"issueLabel": str(sa.get("issueLabel") or "agent:ready"),
|
|
102
|
+
"baseUrl": str(sa.get("baseUrl") or "").rstrip("/"),
|
|
103
|
+
"orchestratorUrl": str(sa.get("orchestratorUrl") or "http://127.0.0.1:3082").rstrip("/"),
|
|
104
|
+
"stallMinutes": int(sa.get("stallMinutes") or 30),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def api(sa: dict, method: str, path: str, fields: dict | None = None):
|
|
109
|
+
env = dict(os.environ)
|
|
110
|
+
env["GITEA_TOKEN_FILE"] = sa["tokenFile"]
|
|
111
|
+
args = [sa["cli"], "api", path]
|
|
112
|
+
if method != "GET":
|
|
113
|
+
args += ["--method", method]
|
|
114
|
+
for k, v in (fields or {}).items():
|
|
115
|
+
args += ["-f", f"{k}={v}"]
|
|
116
|
+
p = subprocess.run(args, capture_output=True, text=True, env=env, timeout=60)
|
|
117
|
+
if p.returncode != 0:
|
|
118
|
+
raise RuntimeError(f"gitea api {method} {path} 失败(rc={p.returncode}):{(p.stderr or p.stdout)[:500]}")
|
|
119
|
+
try:
|
|
120
|
+
return json.loads(p.stdout or "null")
|
|
121
|
+
except json.JSONDecodeError as exc:
|
|
122
|
+
raise RuntimeError(f"gitea api 返回非 JSON({path}):{p.stdout[:300]}") from exc
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def marker(board: dict, cid: str) -> str:
|
|
126
|
+
return f"<!-- offhours:card={cid} board={board.get('id','')} -->"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def issue_body(board: dict, card: dict, sa: dict) -> str:
|
|
130
|
+
cid = str(card.get("id") or "")
|
|
131
|
+
summary = str(card.get("summary") or card.get("description") or "").strip()
|
|
132
|
+
desc = str(card.get("description") or "").strip()
|
|
133
|
+
title = str(card.get("title") or cid)
|
|
134
|
+
parts = [
|
|
135
|
+
f"来自夜间跑批看板 `{board.get('id','')}` 的卡:**{title}**",
|
|
136
|
+
"",
|
|
137
|
+
summary or "(无摘要;请按 issue 标题执行,并在完成前给出可复核证据。)",
|
|
138
|
+
"",
|
|
139
|
+
*(["", "卡片补充/人工确认:", desc] if desc and desc != summary else []),
|
|
140
|
+
f"{sa['mention']} 请按本 issue 执行;这是夜间跑批准入的任务,不是讨论请求。",
|
|
141
|
+
"",
|
|
142
|
+
"结束纪律:前台等命令结束(job_output wait=true),不要用“等后台 gate”结束回合;完成后必须给出 report,无法完成时报告 blocked/attention,不要静默结束。",
|
|
143
|
+
"",
|
|
144
|
+
marker(board, cid),
|
|
145
|
+
]
|
|
146
|
+
return "\n".join(parts)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def find_card(board_data: dict, cid: str):
|
|
150
|
+
return next((c for c in board_data.get("cards", []) if c.get("id") == cid), None)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def eligible(card: dict) -> bool:
|
|
154
|
+
if card.get("status") != "todo":
|
|
155
|
+
return False
|
|
156
|
+
tags = [str(t).lower() for t in (card.get("tags") or [])]
|
|
157
|
+
if "hold" in tags or "blocked" in tags or "decision-needed" in tags:
|
|
158
|
+
return False
|
|
159
|
+
if "auto" not in tags and "superagent" not in tags:
|
|
160
|
+
return False
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def cmd_dispatch(board_path: str, config_path: str, limit: int, dry: bool, force: bool = False) -> int:
|
|
165
|
+
config = load_json(config_path)
|
|
166
|
+
board = board_config(config, board_path)
|
|
167
|
+
if not board:
|
|
168
|
+
raise SystemExit(f"配置里找不到看板:{board_path}")
|
|
169
|
+
sa = superagent_config(config, board)
|
|
170
|
+
if not dry and not force:
|
|
171
|
+
# offpeak 是硬闸门;superagent 自己也会 peak-pause,但不在峰段创建新 issue。
|
|
172
|
+
p = subprocess.run([sys.executable, str(Path(__file__).with_name("ns.py")), "offpeak"],
|
|
173
|
+
capture_output=True, text=True, timeout=10)
|
|
174
|
+
if p.returncode != 0:
|
|
175
|
+
log("当前不是低谷,跳过 dispatch")
|
|
176
|
+
return 0
|
|
177
|
+
with with_board_lock(board_path):
|
|
178
|
+
data = load_json(board_path)
|
|
179
|
+
candidates = [c for c in data.get("cards", []) if eligible(c) and not c.get("superagentIssue")]
|
|
180
|
+
if not candidates:
|
|
181
|
+
log("没有可 dispatch 的 todo(auto, 非 hold)")
|
|
182
|
+
return 0
|
|
183
|
+
if dry:
|
|
184
|
+
c = candidates[0]
|
|
185
|
+
print(json.dumps({
|
|
186
|
+
"wouldCreate": {
|
|
187
|
+
"repository": sa["repo"],
|
|
188
|
+
"title": f"[offhours] {c.get('title') or c.get('id')}",
|
|
189
|
+
"body": issue_body(board, c, sa),
|
|
190
|
+
}
|
|
191
|
+
}, ensure_ascii=False, indent=2))
|
|
192
|
+
return 0
|
|
193
|
+
created = 0
|
|
194
|
+
for card in candidates[:max(1, limit)]:
|
|
195
|
+
cid = str(card.get("id") or "")
|
|
196
|
+
title = f"[offhours] {str(card.get('title') or cid)[:120]}"
|
|
197
|
+
issue = api(sa, "POST", f"repos/{sa['repo']}/issues", {
|
|
198
|
+
"title": title,
|
|
199
|
+
"body": issue_body(board, card, sa),
|
|
200
|
+
})
|
|
201
|
+
number = issue.get("number")
|
|
202
|
+
url = issue.get("html_url") or (f"{sa['baseUrl']}/lengr/hemou/issues/{number}" if sa["baseUrl"] else "")
|
|
203
|
+
card["status"] = "in_progress"
|
|
204
|
+
card["superagentIssue"] = {"number": number, "url": url, "at": now_iso()}
|
|
205
|
+
card["superagentState"] = "issue-open"
|
|
206
|
+
card["updatedAt"] = now_iso()
|
|
207
|
+
created += 1
|
|
208
|
+
log(f"created issue #{number} for card {cid}")
|
|
209
|
+
atomic_write(board_path, data)
|
|
210
|
+
print(json.dumps({"created": created}, ensure_ascii=False))
|
|
211
|
+
return 0
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
PR_RE = re.compile(r"https?://[^\s)>\]]+/pulls/(\d+)")
|
|
215
|
+
|
|
216
|
+
def local_api(sa: dict, path: str):
|
|
217
|
+
"""superagent 管理 API(仅本机回环,无需额外鉴权头之外的秘密)。"""
|
|
218
|
+
url = sa.get("orchestratorUrl") or "http://127.0.0.1:3082"
|
|
219
|
+
req = Request(url + path, headers={"x-orchestrator-request": "1"})
|
|
220
|
+
with urlopen(req, timeout=8) as r:
|
|
221
|
+
return json.loads(r.read().decode("utf-8"))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def local_post(sa: dict, path: str, payload: dict):
|
|
225
|
+
url = (sa.get("orchestratorUrl") or "http://127.0.0.1:3082") + path
|
|
226
|
+
req = Request(url, data=json.dumps(payload).encode("utf-8"),
|
|
227
|
+
headers={"x-orchestrator-request": "1", "content-type": "application/json"}, method="POST")
|
|
228
|
+
with urlopen(req, timeout=8) as r:
|
|
229
|
+
return json.loads(r.read().decode("utf-8"))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def cancel_ticket(sa: dict, ticket: dict | None) -> None:
|
|
233
|
+
if not ticket or ticket.get("state") != "open":
|
|
234
|
+
return
|
|
235
|
+
try:
|
|
236
|
+
local_post(sa, f"/orchestrator/api/tickets/{ticket['id']}/stop",
|
|
237
|
+
{"reason": "human confirmed the offhours card; take over"})
|
|
238
|
+
except Exception:
|
|
239
|
+
pass
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def close_issue(sa: dict, number: int) -> None:
|
|
243
|
+
try:
|
|
244
|
+
api(sa, "PATCH", f"repos/{sa['repo']}/issues/{number}", {"state": "closed"})
|
|
245
|
+
except Exception:
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def find_ticket(sa: dict, issue_number: int, card_title: str):
|
|
250
|
+
"""找到这张 issue 对应的 superagent ticket;找不到返回 None。
|
|
251
|
+
|
|
252
|
+
用 Thread key `github:<issue>` 做身份,而不是标题猜测;标题只用于先筛列表。
|
|
253
|
+
"""
|
|
254
|
+
try:
|
|
255
|
+
data = local_api(sa, "/orchestrator/api/tickets?state=all&limit=100")
|
|
256
|
+
except Exception:
|
|
257
|
+
return None
|
|
258
|
+
items = data.get("items", []) or []
|
|
259
|
+
want = card_title.strip()
|
|
260
|
+
def matches(item):
|
|
261
|
+
try:
|
|
262
|
+
full = local_api(sa, f"/orchestrator/api/tickets/{item['id']}")
|
|
263
|
+
except Exception:
|
|
264
|
+
return None
|
|
265
|
+
for th in full.get("threads", []) or []:
|
|
266
|
+
if th.get("key") == f"github:{issue_number}":
|
|
267
|
+
return full
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
# 先按标题缩小范围(大多数卡标题与 issue 标题一致);
|
|
271
|
+
for item in items:
|
|
272
|
+
title = str(item.get("title") or "").strip()
|
|
273
|
+
if want and want not in title and f"[offhours] {want}" not in title:
|
|
274
|
+
continue
|
|
275
|
+
got = matches(item)
|
|
276
|
+
if got:
|
|
277
|
+
return got
|
|
278
|
+
# 标题被人工改过/工单标题被 superagent 重写时,按 Thread key 全量兜底。
|
|
279
|
+
for item in items:
|
|
280
|
+
title = str(item.get("title") or "").strip()
|
|
281
|
+
if want and (want in title or f"[offhours] {want}" in title):
|
|
282
|
+
continue
|
|
283
|
+
got = matches(item)
|
|
284
|
+
if got:
|
|
285
|
+
return got
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def ticket_status(ticket: dict | None):
|
|
290
|
+
if not ticket:
|
|
291
|
+
return None
|
|
292
|
+
if ticket.get("attention"):
|
|
293
|
+
return "review", "attention"
|
|
294
|
+
ended = ticket.get("ended") or {}
|
|
295
|
+
if ticket.get("state") == "ended":
|
|
296
|
+
outcome = str(ended.get("outcome") or "")
|
|
297
|
+
return ("done" if outcome in ("merged", "completed") else "review", "ticket-ended")
|
|
298
|
+
if ticket.get("stage") == "working":
|
|
299
|
+
return "in_progress", "working"
|
|
300
|
+
return "review", "person"
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _pr_from_comments(comments) -> int | None:
|
|
304
|
+
for c in comments or []:
|
|
305
|
+
m = PR_RE.search(str(c.get("body") or ""))
|
|
306
|
+
if m:
|
|
307
|
+
return int(m.group(1))
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def cmd_sync(board_path: str, config_path: str) -> int:
|
|
312
|
+
config = load_json(config_path)
|
|
313
|
+
board = board_config(config, board_path)
|
|
314
|
+
if not board:
|
|
315
|
+
raise SystemExit(f"配置里找不到看板:{board_path}")
|
|
316
|
+
sa = superagent_config(config, board)
|
|
317
|
+
with with_board_lock(board_path):
|
|
318
|
+
data = load_json(board_path)
|
|
319
|
+
changed = 0
|
|
320
|
+
for card in data.get("cards", []):
|
|
321
|
+
issue = card.get("superagentIssue")
|
|
322
|
+
if not issue or not issue.get("number"):
|
|
323
|
+
continue
|
|
324
|
+
n = int(issue["number"])
|
|
325
|
+
# 人工确认后 ns.py 把卡改回 todo 并写【确认】块;此时旧 ticket/issue 不能再把卡拉回 review。
|
|
326
|
+
# 先停止旧 ticket、关闭旧 issue、清掉 superagent 关联,让下一轮 dispatch 按新确认重新派发。
|
|
327
|
+
if card.get("status") == "todo" and "【确认 " in str(card.get("description") or ""):
|
|
328
|
+
t = find_ticket(sa, n, str(card.get("title") or ""))
|
|
329
|
+
cancel_ticket(sa, t)
|
|
330
|
+
close_issue(sa, n)
|
|
331
|
+
for k in ("superagentIssue", "superagentPr", "superagentTicket"):
|
|
332
|
+
card.pop(k, None)
|
|
333
|
+
card["superagentState"] = "human-confirmed"
|
|
334
|
+
card["updatedAt"] = now_iso()
|
|
335
|
+
changed += 1
|
|
336
|
+
continue
|
|
337
|
+
issue_obj = api(sa, "GET", f"repos/{sa['repo']}/issues/{n}")
|
|
338
|
+
state = str(issue_obj.get("state") or "open")
|
|
339
|
+
comments = api(sa, "GET", f"repos/{sa['repo']}/issues/{n}/comments")
|
|
340
|
+
pr_num = _pr_from_comments(comments)
|
|
341
|
+
pr = api(sa, "GET", f"repos/{sa['repo']}/pulls/{pr_num}") if pr_num else None
|
|
342
|
+
old = (card.get("status"), card.get("superagentState"), (card.get("superagentPr") or {}).get("state"))
|
|
343
|
+
if pr:
|
|
344
|
+
pr_state = "merged" if pr.get("merged") or pr.get("merged_at") else ("closed" if str(pr.get("state")) == "closed" else "open")
|
|
345
|
+
card["superagentPr"] = {"number": pr_num, "url": pr.get("html_url") or "", "state": pr_state}
|
|
346
|
+
if pr_state == "merged" or (state == "closed" and pr_state != "open"):
|
|
347
|
+
card["status"] = "done"
|
|
348
|
+
card["superagentState"] = "merged"
|
|
349
|
+
elif pr_state == "closed":
|
|
350
|
+
card["status"] = "review"
|
|
351
|
+
card["superagentState"] = "reviewed"
|
|
352
|
+
else:
|
|
353
|
+
card["status"] = "review"
|
|
354
|
+
card["superagentState"] = "pr-open"
|
|
355
|
+
elif state == "closed":
|
|
356
|
+
card["status"] = "review"
|
|
357
|
+
card["superagentState"] = "reviewed"
|
|
358
|
+
else:
|
|
359
|
+
card["status"] = "in_progress"
|
|
360
|
+
card["superagentState"] = "issue-open"
|
|
361
|
+
ticket = find_ticket(sa, n, str(card.get("title") or ""))
|
|
362
|
+
if ticket:
|
|
363
|
+
card["superagentTicket"] = {"id": ticket.get("id"), "title": ticket.get("title")}
|
|
364
|
+
ts = ticket_status(ticket)
|
|
365
|
+
if ts:
|
|
366
|
+
tstatus, tstate = ts
|
|
367
|
+
# PR merged / ticket merged 都算 done;attention/working 以 ticket 实时状态为准。
|
|
368
|
+
if card.get("superagentState") == "merged" or tstatus == "done":
|
|
369
|
+
card["status"] = "done"; card["superagentState"] = "merged"
|
|
370
|
+
elif tstatus == "review":
|
|
371
|
+
card["status"] = "review"; card["superagentState"] = tstate
|
|
372
|
+
elif card.get("status") != "review":
|
|
373
|
+
card["status"] = "in_progress"; card["superagentState"] = tstate
|
|
374
|
+
# 防静默卡死:worker 可能结束回合却不再 report(等后台 gate 的已知失败模式)。
|
|
375
|
+
# 超过 stallMinutes 仍是 working 且无 report/attention ⇒ 卡转 review,让人看见。
|
|
376
|
+
if tstate == "working" and card.get("status") == "in_progress":
|
|
377
|
+
at = str((card.get("superagentIssue") or {}).get("at") or "")
|
|
378
|
+
try:
|
|
379
|
+
started = datetime.fromisoformat(at).timestamp()
|
|
380
|
+
except Exception:
|
|
381
|
+
started = 0
|
|
382
|
+
if started and time.time() - started > max(1, sa["stallMinutes"]) * 60:
|
|
383
|
+
card["status"] = "review"; card["superagentState"] = "stalled"
|
|
384
|
+
if (card.get("status"), card.get("superagentState"), (card.get("superagentPr") or {}).get("state")) != old:
|
|
385
|
+
card["updatedAt"] = now_iso()
|
|
386
|
+
changed += 1
|
|
387
|
+
atomic_write(board_path, data)
|
|
388
|
+
print(json.dumps({"changed": changed}, ensure_ascii=False))
|
|
389
|
+
return 0
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def cmd_status(board_path: str, config_path: str) -> int:
|
|
393
|
+
config = load_json(config_path)
|
|
394
|
+
board = board_config(config, board_path)
|
|
395
|
+
if not board:
|
|
396
|
+
raise SystemExit(f"配置里找不到看板:{board_path}")
|
|
397
|
+
sa = superagent_config(config, board)
|
|
398
|
+
data = load_json(board_path)
|
|
399
|
+
rows = [{"id": c.get("id"), "title": c.get("title"), "status": c.get("status"),
|
|
400
|
+
"issue": c.get("superagentIssue"), "pr": c.get("superagentPr"),
|
|
401
|
+
"state": c.get("superagentState")}
|
|
402
|
+
for c in data.get("cards", []) if c.get("superagentIssue")]
|
|
403
|
+
print(json.dumps({"executor": "superagent", "repo": sa["repo"], "cards": rows}, ensure_ascii=False, indent=2))
|
|
404
|
+
return 0
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def main(argv) -> int:
|
|
408
|
+
ap = argparse.ArgumentParser()
|
|
409
|
+
ap.add_argument("command", choices=["dispatch", "sync", "status"])
|
|
410
|
+
ap.add_argument("board")
|
|
411
|
+
ap.add_argument("--config", default=os.environ.get("OFFHOURS_CONFIG") or os.environ.get("NIGHTSHIFT_SUPERAGENT_CONFIG"))
|
|
412
|
+
ap.add_argument("--limit", type=int, default=1)
|
|
413
|
+
ap.add_argument("--dry-run", action="store_true")
|
|
414
|
+
ap.add_argument("--force", action="store_true")
|
|
415
|
+
ns = ap.parse_args(argv)
|
|
416
|
+
if not ns.config:
|
|
417
|
+
raise SystemExit("需要 --config 或 OFFHOURS_CONFIG")
|
|
418
|
+
if ns.command == "dispatch":
|
|
419
|
+
return cmd_dispatch(ns.board, ns.config, ns.limit, ns.dry_run, ns.force)
|
|
420
|
+
if ns.command == "sync":
|
|
421
|
+
return cmd_sync(ns.board, ns.config)
|
|
422
|
+
return cmd_status(ns.board, ns.config)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
if __name__ == "__main__":
|
|
426
|
+
raise SystemExit(main(sys.argv[1:]))
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-offhours",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "DSH
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "DSH 社区插件:把看板待办在低谷/夜间自动做掉;可选把卡交给 dsh-superagent 执行",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"main": "panel/index.js",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"mechanism/scan.sh",
|
|
27
27
|
"mechanism/ns.py",
|
|
28
28
|
"mechanism/selftest.py",
|
|
29
|
+
"mechanism/superagent_bridge.py",
|
|
29
30
|
"feature-worktrees/feature",
|
|
30
31
|
"feature-worktrees/feature-lib.sh",
|
|
31
32
|
"feature-worktrees/feature-guard.sh",
|
|
@@ -38,7 +39,7 @@
|
|
|
38
39
|
"scripts": {
|
|
39
40
|
"prepack": "node scripts/gen-client.mjs",
|
|
40
41
|
"gen:client": "node scripts/gen-client.mjs",
|
|
41
|
-
"test": "node --test tests/*.test.mjs",
|
|
42
|
+
"test": "node --test tests/node/*.test.mjs",
|
|
42
43
|
"check": "node --check panel/index.js && node --check lib/config.js && node --check lib/runtime.js && node --check bin/offhours.mjs"
|
|
43
44
|
},
|
|
44
45
|
"engines": {
|
|
@@ -76,4 +77,4 @@
|
|
|
76
77
|
"bugs": {
|
|
77
78
|
"url": "https://github.com/shuke52tt/dsh-offhours/issues"
|
|
78
79
|
}
|
|
79
|
-
}
|
|
80
|
+
}
|