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/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 > "$RUNTIME_DIR/DAEMON.heartbeat" 2>/dev/null || true
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
- mkdir -p "$SLOTS/$i" && echo $$ > "$SLOTS/$i/pid"
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" \
@@ -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": "session-selftest"}
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.1.3",
4
- "description": "DSH 社区插件:把看板待办在低谷/夜间自动做掉(挑卡 → 独占 worktree → worker → 证据 → 合并回收),只依赖配置里的 git 仓库。",
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
+ }