dsh-offhours 0.1.4 → 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 +53 -0
- package/bin/offhours.mjs +22 -0
- 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/feature-worktrees/README.md +0 -361
- package/mechanism/README.md +0 -266
- package/panel/cordis.patch.yml +0 -3
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
|
+
}
|
package/panel/client.js
CHANGED
|
@@ -837,6 +837,13 @@ window.__ModuleLoader__.load({
|
|
|
837
837
|
(c.sourceSessionId ? `\n\n【来源会话】${c.sourceSessionId}` : ''))),
|
|
838
838
|
h('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
|
|
839
839
|
h(Button, { onClick: () => store.act('scan-this', { cardId: c.id }) }, t('runThisNow')),
|
|
840
|
+
(c.superagentPr && c.superagentPr.url) || (c.superagentIssue && c.superagentIssue.url)
|
|
841
|
+
? h('a', {
|
|
842
|
+
href: (c.superagentPr && c.superagentPr.url) || (c.superagentIssue && c.superagentIssue.url),
|
|
843
|
+
target: '_blank', rel: 'noreferrer',
|
|
844
|
+
style: { fontSize: 12, color: T.brand, alignSelf: 'center' },
|
|
845
|
+
}, t('superagentLink'))
|
|
846
|
+
: null,
|
|
840
847
|
c.workerSessionId
|
|
841
848
|
? h(Button, { onClick: () => openWorkerSession(c.workerSessionId) }, t('openWorkerSession'))
|
|
842
849
|
: null,
|
|
@@ -1181,6 +1188,9 @@ window.__ModuleLoader__.load({
|
|
|
1181
1188
|
{ k: 'review', label: t('review'), n: reviews.length, tone: reviews.length ? T.warn : T.textSub },
|
|
1182
1189
|
{ k: 'live', label: t('running'), n: running.length, tone: running.length ? '#1a8f5a' : T.textSub },
|
|
1183
1190
|
{ k: 'doc', label: '手册', n: 0, tone: T.textSub },
|
|
1191
|
+
// 工程资源入口:iframe 指向自动生成的手册站(与「手册」并列,不抢它的位置)。
|
|
1192
|
+
// 用 location.hostname 而不是写死 IP:远程访问时也能指到同一台机器的 :8087。
|
|
1193
|
+
{ k: 'entries', label: '入口', n: 0, tone: T.textSub },
|
|
1184
1194
|
{ k: 'timers', label: '定时', n: (data.cron || []).length + 2, tone: T.textSub },
|
|
1185
1195
|
].map((x) => h('button', {
|
|
1186
1196
|
key: x.k, role: 'tab', 'aria-selected': tab === x.k, onClick: () => goTab(x.k),
|
|
@@ -1244,6 +1254,13 @@ window.__ModuleLoader__.load({
|
|
|
1244
1254
|
h('iframe', { src: '/nightshift/api/doc-page', title: '使用手册', style: { width: '100%', height: '100%', border: 0, display: 'block', background: '#fff' } }))
|
|
1245
1255
|
: null,
|
|
1246
1256
|
|
|
1257
|
+
tab === 'entries'
|
|
1258
|
+
? h('div', { style: { height: narrow ? '68vh' : '72vh', borderRadius: 12, overflow: 'hidden', border: `1px solid ${T.borderStrong}`, marginTop: 8 } },
|
|
1259
|
+
// 指向 :8087 工作台:菜单/手册/出处全部由 catalog.tsv 自动生成(15 分钟同步)。
|
|
1260
|
+
// 这里只负责显示,不复制任何条目——复制就一定会漂移。
|
|
1261
|
+
h('iframe', { src: `http://${location.hostname}:8087/`, title: '工程资源入口', style: { width: '100%', height: '100%', border: 0, display: 'block', background: '#fff' } }))
|
|
1262
|
+
: null,
|
|
1263
|
+
|
|
1247
1264
|
tab === 'live'
|
|
1248
1265
|
? h(Section, { title: t('running'), count: running.length },
|
|
1249
1266
|
running.length === 0
|
package/panel/index.js
CHANGED
|
@@ -132,6 +132,22 @@ function workerSessionOf(card) {
|
|
|
132
132
|
const boardState = (b) => join(NS_DIR, `state-${b.key}.json`)
|
|
133
133
|
const boardDay = (b) => join(NS_DIR, `day-${b.key}.json`)
|
|
134
134
|
|
|
135
|
+
/** 常驻 daemon 心跳年龄(秒);没有心跳文件返回 null。scan.sh --daemon 每 10s 写一次。 */
|
|
136
|
+
function daemonHeartbeatAgeSec() {
|
|
137
|
+
try {
|
|
138
|
+
const ts = Number((readFileSync(join(NS_DIR, 'DAEMON.heartbeat'), 'utf8') || '0').trim())
|
|
139
|
+
if (!ts) return null
|
|
140
|
+
return Math.floor(Date.now() / 1000 - ts)
|
|
141
|
+
} catch { return null }
|
|
142
|
+
}
|
|
143
|
+
const DAEMON_HB_STALE_SEC = 120
|
|
144
|
+
/** 'active' / 'stale(<n>s)' / 'unknown':与 scan.sh 的 10s 心跳节拍对齐。 */
|
|
145
|
+
function daemonHeartbeatState() {
|
|
146
|
+
const age = daemonHeartbeatAgeSec()
|
|
147
|
+
if (age === null) return 'unknown'
|
|
148
|
+
return age <= DAEMON_HB_STALE_SEC ? 'active' : `stale(${age}s)`
|
|
149
|
+
}
|
|
150
|
+
|
|
135
151
|
// 冷却时长与跑批保持同一来源:systemd 形态读 env-<board>;插件形态读 config.gates。
|
|
136
152
|
function cooldownFromEnvFile(key) {
|
|
137
153
|
try {
|
|
@@ -326,7 +342,8 @@ async function buildStatus(boardArg) {
|
|
|
326
342
|
const out = {
|
|
327
343
|
ok: true,
|
|
328
344
|
now: fmtLocal(now),
|
|
329
|
-
board: { key: board.key, label: board.label, path: board.path, workdir: board.workdir, timer: board.timer, requireAuto: board.requireAuto },
|
|
345
|
+
board: { key: board.key, label: board.label, path: board.path, workdir: board.workdir, timer: board.timer, requireAuto: board.requireAuto, executor: board.executor || CFG.executor || 'local' },
|
|
346
|
+
executor: board.executor || CFG.executor || 'local',
|
|
330
347
|
boards: BOARDS.map((b) => ({ key: b.key, label: b.label, path: b.path })),
|
|
331
348
|
peak,
|
|
332
349
|
timers,
|
|
@@ -352,21 +369,27 @@ async function buildStatus(boardArg) {
|
|
|
352
369
|
}
|
|
353
370
|
|
|
354
371
|
// SCHEDULER-REPORT:按配置的调度方式如实报告(systemd / 插件内 interval)。
|
|
372
|
+
// DAEMON-HEARTBEAT(分支 2026-11-04):scan.sh --daemon 每 10s 写 DAEMON.heartbeat;
|
|
373
|
+
// 沙箱/非 systemd 环境里 is-active 常是 unknown,心跳是唯一可判活的证据,故作为兜底。
|
|
355
374
|
try {
|
|
356
375
|
const hb = readHeartbeat(CFG)
|
|
357
376
|
const hbActive = !!(hb && hb.ageSec <= 180)
|
|
358
377
|
let daemon = 'n/a'
|
|
359
378
|
if (CFG.scheduler === 'systemd' && CFG.daemonUnit) daemon = await unitActive(CFG.daemonUnit)
|
|
379
|
+
const daemonHb = daemonHeartbeatState()
|
|
360
380
|
out.timer = {
|
|
361
381
|
active: CFG.scheduler === 'plugin'
|
|
362
|
-
? (hbActive ? 'active' : (hb ? `stale(${hb.ageSec}s)` :
|
|
363
|
-
: (daemon === 'active' || hbActive
|
|
382
|
+
? (hbActive ? 'active' : (hb ? `stale(${hb.ageSec}s)` : daemonHb))
|
|
383
|
+
: (daemon === 'active' || hbActive || daemonHb === 'active'
|
|
384
|
+
? 'active'
|
|
385
|
+
: (daemon !== 'n/a' && daemon !== 'unknown' ? daemon : daemonHb)),
|
|
364
386
|
next: null,
|
|
365
387
|
raw: CFG.scheduler === 'plugin'
|
|
366
|
-
? `plugin interval=${CFG.schedule.intervalMin}min heartbeat=${hb ? hb.ageSec + 's' : 'none'}`
|
|
367
|
-
: `daemon=${daemon} timer=${board.timer || 'n/a'}`,
|
|
388
|
+
? `plugin interval=${CFG.schedule.intervalMin}min heartbeat=${hb ? hb.ageSec + 's' : 'none'} daemon-hb=${daemonHb}`
|
|
389
|
+
: `daemon=${daemon} daemon-hb=${daemonHb} timer=${board.timer || 'n/a'}`,
|
|
368
390
|
scheduler: CFG.scheduler === 'plugin' ? 'in-process' : (CFG.daemonUnit || board.timer || 'systemd'),
|
|
369
391
|
heartbeat: hb,
|
|
392
|
+
daemonHeartbeat: daemonHb,
|
|
370
393
|
}
|
|
371
394
|
} catch { /* 忽略 */ }
|
|
372
395
|
// 暂停标记
|
|
@@ -382,11 +405,13 @@ async function buildStatus(boardArg) {
|
|
|
382
405
|
const ts = await timerState(board.timer)
|
|
383
406
|
out.timer.next = ts.next
|
|
384
407
|
out.timer.raw += ` /timer=${ts.active}`
|
|
385
|
-
if (ts.active !== 'active' && out.timer.active !== 'active') {
|
|
386
|
-
out.problems.push(`调度器未运行(/${board.timer}=${ts.active})`)
|
|
387
|
-
}
|
|
388
408
|
} catch { out.problems.push('systemctl 查询失败(定时器状态未知)') }
|
|
389
409
|
}
|
|
410
|
+
// DAEMON-ALIVE:常驻 daemon 形态下旧 timer 停用属正常 —— 只有「timer 不活且 daemon 心跳也过期」才算问题。
|
|
411
|
+
if (CFG.scheduler === 'systemd' && out.timer.active !== 'active') {
|
|
412
|
+
const age = daemonHeartbeatAgeSec()
|
|
413
|
+
out.problems.push(`调度器未运行(daemon 心跳=${age === null ? '无' : age + 's'},/${board.timer || 'n/a'}=${out.timer.active})`)
|
|
414
|
+
}
|
|
390
415
|
if (CFG.scheduler === 'plugin' && !CFG.schedule.enabled) {
|
|
391
416
|
out.timer.active = 'disabled'
|
|
392
417
|
out.timer.raw = 'plugin schedule disabled'
|
|
@@ -523,6 +548,9 @@ async function buildStatus(boardArg) {
|
|
|
523
548
|
rejected: clip(card.rejected, 200),
|
|
524
549
|
summary: clip(card.summary, 400),
|
|
525
550
|
// 处理会话 id(P5 写进卡片的机器块)⇒ 面板可一键用官方「对话/轨迹」窗口打开它
|
|
551
|
+
superagentIssue: card.superagentIssue || null,
|
|
552
|
+
superagentPr: card.superagentPr || null,
|
|
553
|
+
superagentState: card.superagentState || '',
|
|
526
554
|
workerSessionId: (() => {
|
|
527
555
|
const m = /<!--\s*nightshift:\s*worker=([^\s]+)/.exec(String(card.description || ''))
|
|
528
556
|
return m ? m[1] : ''
|
package/panel/locale/en.json
CHANGED
|
@@ -81,6 +81,7 @@
|
|
|
81
81
|
"setupTitle": "One-time setup",
|
|
82
82
|
"setupBody": "Run this inside the git repository you want to batch:",
|
|
83
83
|
"setupAfter": "Then reload or restart dsh. The board appears here; work only starts off-peak while the machine is idle.",
|
|
84
|
-
"setupConfigPath": "Config file:"
|
|
84
|
+
"setupConfigPath": "Config file:",
|
|
85
|
+
"superagentLink": "Open superagent ticket"
|
|
85
86
|
}
|
|
86
87
|
}
|
package/panel/locale/zh.json
CHANGED