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.
@@ -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.4",
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
+ }
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)` : 'unknown'))
363
- : (daemon === 'active' || hbActive ? 'active' : (daemon === 'n/a' ? 'unknown' : daemon)),
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] : ''
@@ -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
  }
@@ -81,6 +81,7 @@
81
81
  "setupTitle": "先完成一次性初始化",
82
82
  "setupBody": "在要跑批的 git 仓库里运行:",
83
83
  "setupAfter": "然后刷新或重启 dsh;左侧会出现看板,只有低谷且机器空闲时才会自动跑。",
84
- "setupConfigPath": "配置文件:"
84
+ "setupConfigPath": "配置文件:",
85
+ "superagentLink": "打开 superagent 工单"
85
86
  }
86
87
  }