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/README.md
CHANGED
|
@@ -26,9 +26,12 @@ npx dsh-offhours init --repo . --name myapp
|
|
|
26
26
|
# 2) 体检(依赖、路径、worker 权限)
|
|
27
27
|
npx dsh-offhours doctor
|
|
28
28
|
|
|
29
|
-
# 3)
|
|
29
|
+
# 3) 建一张卡(必须在 dsh 会话里,或显式传当前会话 id)
|
|
30
|
+
# 最省事:在 dsh 里对 agent 说「用 offhours 技能建一张卡:给登录页补错误提示」。
|
|
31
|
+
# 终端命令需要带 --session;没有 sourceSessionId 的卡跑批永远不会捡。
|
|
30
32
|
npx dsh-offhours card add "给登录页补错误提示" \
|
|
31
|
-
--summary "目标:登录失败有提示。判据:npm test 通过。不做什么:不改后端。"
|
|
33
|
+
--summary "目标:登录失败有提示。判据:npm test 通过。不做什么:不改后端。" \
|
|
34
|
+
--session <当前 dsh 会话 id>
|
|
32
35
|
```
|
|
33
36
|
|
|
34
37
|
之后低谷时段自动跑;也可以用面板「立即跑」或 `npx dsh-offhours run --once`。
|
|
@@ -37,6 +40,42 @@ npx dsh-offhours card add "给登录页补错误提示" \
|
|
|
37
40
|
|
|
38
41
|
装完/升级插件后记得重启一次 `dsh web`,面板才会加载新代码。
|
|
39
42
|
|
|
43
|
+
## 可选:把卡交给 dsh-superagent 执行(Issue 桥接)
|
|
44
|
+
|
|
45
|
+
默认 `executor=local`:夜间跑批自己起 headless worker。
|
|
46
|
+
也可以让指定的看板走 **superagent 执行器**:夜间跑批只在低谷把 `todo(auto)` 卡变成 Gitea issue,superagent 的 Source → Eval → Ticket → Executor 负责独立 worktree、PR、评审和 attention;夜间跑批只回写卡状态。
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"executor": "superagent",
|
|
51
|
+
"superagent": {
|
|
52
|
+
"cli": "/path/to/gh-gitea",
|
|
53
|
+
"tokenFile": "~/.config/dsh-superagent/gitea-operator-token",
|
|
54
|
+
"repo": "<owner>/<repo>",
|
|
55
|
+
"baseUrl": "https://gitea.example.com",
|
|
56
|
+
"mention": "@dsh-agent"
|
|
57
|
+
},
|
|
58
|
+
"boards": [
|
|
59
|
+
{ "id": "myapp", "file": "~/code/myapp/KANBAN.json", "executor": "superagent" }
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- `tokenFile` 必须是**有权创建 issue 的操作者** token(不是 agent 自己的 token);agent token 创建的 issue 会被 superagent Source 当作自己的消息过滤掉。
|
|
65
|
+
- 只有 `todo` + `auto` + 非 `hold` 的卡会被派发;卡片状态会写 `superagentIssue` / `superagentPr` / `superagentState`。
|
|
66
|
+
- `stallMinutes`(默认 30):ticket 长时间 `working` 但无 report/attention 时,卡自动转 `review`(防 worker 等后台 gate 静默卡死);issue 正文也会要求“前台等命令、必须 report”。
|
|
67
|
+
- `dispatch` 只在低谷执行;峰段由 superagent 的 peak-pause 再兜底。
|
|
68
|
+
- **不自动合并代码 PR**:PR 打开 → 卡 `review`;PR 合并 → 卡 `done`;issue 被关但未合并 → 卡 `review`。
|
|
69
|
+
- superagent 与夜间跑批共用同一个仓库,superagent 的 `maxWorkers` 建议保持 1,避免同仓库并发写。
|
|
70
|
+
|
|
71
|
+
手动命令:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
npx dsh-offhours superagent status --board myapp
|
|
75
|
+
npx dsh-offhours superagent dispatch --board myapp --dry-run
|
|
76
|
+
npx dsh-offhours superagent sync --board myapp
|
|
77
|
+
```
|
|
78
|
+
|
|
40
79
|
## 配置
|
|
41
80
|
|
|
42
81
|
`~/.dsh/offhours/config.json`(`npx dsh-offhours init` 生成):
|
|
@@ -70,7 +109,7 @@ npx dsh-offhours card add "给登录页补错误提示" \
|
|
|
70
109
|
|---|---|
|
|
71
110
|
| `npx dsh-offhours init --repo .` | 初始化配置/看板/特性 |
|
|
72
111
|
| `npx dsh-offhours doctor` | 依赖与权限体检(失败退出非 0) |
|
|
73
|
-
| `npx dsh-offhours card add "<标题>" --summary "<目标/判据/边界>"` |
|
|
112
|
+
| `npx dsh-offhours card add "<标题>" --summary "<目标/判据/边界>" [--session <id>]` | 建卡并盖章会话(无 session 不会跑) |
|
|
74
113
|
| `npx dsh-offhours card list` | 列卡 |
|
|
75
114
|
| `npx dsh-offhours run --once` / `--dry-run` | 手动跑一轮 / 只看会挑哪张 |
|
|
76
115
|
| `npx dsh-offhours status` | 看板计数、心跳、最近状态(JSON) |
|
|
@@ -108,3 +147,20 @@ npm pack
|
|
|
108
147
|
```
|
|
109
148
|
|
|
110
149
|
MIT License。
|
|
150
|
+
|
|
151
|
+
## 自动采集数据(dsh-rsi)
|
|
152
|
+
|
|
153
|
+
RSI 自动采集的资产/经验**不放在 main**:完整历史(211 个提交、9990 个文件)在分支 **`data/dsh-rsi`**,
|
|
154
|
+
该子树已被 `.gitignore` 挡住,不会随日常提交回到主干。要用它:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
git fetch gitea data/dsh-rsi && git worktree add /tmp/rsi gitea/data/dsh-rsi
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
要把它变成**独立仓库**(推荐,需一次人工登录态):
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
glops token --create --name glops # 生成并保存令牌(仅一次)
|
|
164
|
+
glops create dsh-rsi-data --desc "dsh-rsi 采集数据"
|
|
165
|
+
git -C <你的 dsh-nightshift 检出> push "$(glops url <owner>/dsh-rsi-data | awk '/SSH/{print $NF}')" data/dsh-rsi:main
|
|
166
|
+
```
|
package/bin/offhours.mjs
CHANGED
|
@@ -28,6 +28,7 @@ const HELP = `dsh-offhours —— 夜间/低谷自动跑批(配一个 git 仓
|
|
|
28
28
|
npx dsh-offhours run [--once] [--dry-run] [--force] [--board id]
|
|
29
29
|
npx dsh-offhours status [--board id]
|
|
30
30
|
npx dsh-offhours boards
|
|
31
|
+
npx dsh-offhours superagent dispatch|sync|status [--board id] [--dry-run] [--force]
|
|
31
32
|
|
|
32
33
|
如果你已经全局安装了本包,也可以把 npx dsh-offhours 换成 offhours。
|
|
33
34
|
全局:--config <path>(默认 ~/.dsh/offhours/config.json)
|
|
@@ -163,6 +164,12 @@ function cmdDoctor(flags) {
|
|
|
163
164
|
hard(`仓库 ${b.repos[0] || '?'}`, !!cfg.repos[b.repos[0]] && existsSync(cfg.repos[b.repos[0]].path),
|
|
164
165
|
(cfg.repos[b.repos[0]] || {}).path || '(未登记)')
|
|
165
166
|
}
|
|
167
|
+
if (cfg.boards.some((b) => b.executor === 'superagent')) {
|
|
168
|
+
const sa = cfg.superagent || {}
|
|
169
|
+
hard('superagent cli', !!sa.cli && existsSync(sa.cli), sa.cli || '未配置')
|
|
170
|
+
hard('superagent token', !!sa.tokenFile && existsSync(sa.tokenFile), sa.tokenFile || '未配置')
|
|
171
|
+
hard('superagent repo', !!sa.repo, sa.repo || '未配置')
|
|
172
|
+
}
|
|
166
173
|
for (const [name, bin] of [['python3', 'python3'], ['bash', 'bash'], ['git', 'git'], ['jq', 'jq'], ['flock', 'flock'], ['dsh', cfg.dshBin]]) {
|
|
167
174
|
const p = which(bin)
|
|
168
175
|
hard(name, !!p, p || '未找到')
|
|
@@ -198,14 +205,17 @@ function cmdCard(args, flags) {
|
|
|
198
205
|
if (sub !== 'add') die('用法:offhours card add "<标题>" --summary "..."')
|
|
199
206
|
const title = args.slice(1).join(' ').trim()
|
|
200
207
|
if (!title) die('card add 需要一个标题')
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
208
|
+
const sessionId = String(flags.session || process.env.DSH_SESSION_ID || '')
|
|
209
|
+
if (!sessionId && !flags['allow-no-session']) {
|
|
210
|
+
die('缺少会话 id:在 dsh 会话里运行,或显式加 --session <sessionId>。\n' +
|
|
211
|
+
'没有 sourceSessionId 的卡不会被跑批捡起;确需建无主卡(只人工看)时加 --allow-no-session。')
|
|
212
|
+
}
|
|
213
|
+
const pyArgs = [nsPy(cfg), 'card-add', board.file, title, '--session', sessionId]
|
|
204
214
|
for (const k of ['summary', 'description', 'rationale', 'rejected', 'tags', 'status', 'run-timeout']) {
|
|
205
215
|
if (flags[k] !== undefined) pyArgs.push(`--${k}`, String(flags[k]))
|
|
206
216
|
}
|
|
207
217
|
if (flags['no-explain']) pyArgs.push('--no-explain')
|
|
208
|
-
const env = { ...process.env, DSH_SESSION_ID:
|
|
218
|
+
const env = { ...process.env, DSH_SESSION_ID: sessionId }
|
|
209
219
|
const r = spawnSync('python3', pyArgs, { env, encoding: 'utf8' })
|
|
210
220
|
process.stdout.write(r.stdout || '')
|
|
211
221
|
process.stderr.write(r.stderr || '')
|
|
@@ -264,6 +274,21 @@ function main() {
|
|
|
264
274
|
for (const b of cfg.boards) console.log(`${b.id}\t${b.file}`)
|
|
265
275
|
return
|
|
266
276
|
}
|
|
277
|
+
if (cmd === 'superagent') {
|
|
278
|
+
const { cfg, path } = loadOrDie(flags)
|
|
279
|
+
const board = boardOf(cfg, flags)
|
|
280
|
+
const sub = _[1] || 'status'
|
|
281
|
+
if (!['dispatch', 'sync', 'status'].includes(sub)) die('用法:superagent dispatch|sync|status')
|
|
282
|
+
const py = join(packageRoot(), 'mechanism', 'superagent_bridge.py')
|
|
283
|
+
const args = [py, sub, board.file, '--config', path]
|
|
284
|
+
if (flags.limit) args.push('--limit', String(flags.limit))
|
|
285
|
+
if (flags['dry-run']) args.push('--dry-run')
|
|
286
|
+
if (flags.force) args.push('--force')
|
|
287
|
+
const r = spawnSync('python3', args, { encoding: 'utf8', env: process.env })
|
|
288
|
+
process.stdout.write(r.stdout || '')
|
|
289
|
+
process.stderr.write(r.stderr || '')
|
|
290
|
+
process.exit(r.status || 0)
|
|
291
|
+
}
|
|
267
292
|
die(`未知命令:${cmd}\n\n${HELP}`)
|
|
268
293
|
}
|
|
269
294
|
|
|
@@ -72,13 +72,31 @@ feature_is_recycled() { [ "$(feature_status "$1")" = "recycled" ]; }
|
|
|
72
72
|
# 「在用」特性(不含已回收):**所有「哪里能干活/该去哪里干活」的判断都必须用它**。
|
|
73
73
|
# 已回收的 worktree 已经拆掉,原地只剩一个指向主检出的路标(符号链接)——
|
|
74
74
|
# 它是给旧会话的引用兜底的只读入口,不再是 worktree(见 feature_retired_path)。
|
|
75
|
+
# 性能(2026-11-10):SessionStart 每次启动都要扫全部特性;原来每个 slug 起一次 jq,
|
|
76
|
+
# 208 个特性时钩子要 8s。改成「一次 jq 读全部 feature.json」,把启动成本压到亚秒级。
|
|
77
|
+
_feature_files() {
|
|
78
|
+
local f
|
|
79
|
+
for f in "$FEATURES_ROOT"/*/feature.json; do
|
|
80
|
+
[ -f "$f" ] && printf '%s\n' "$f"
|
|
81
|
+
done
|
|
82
|
+
}
|
|
83
|
+
_feature_slug_of_file() { basename "$(dirname "$1")"; }
|
|
84
|
+
|
|
75
85
|
feature_slugs_active() {
|
|
76
|
-
local
|
|
77
|
-
|
|
86
|
+
local f
|
|
87
|
+
local -a files=()
|
|
88
|
+
while IFS= read -r f; do files+=("$f"); done < <(_feature_files)
|
|
89
|
+
[ "${#files[@]}" -gt 0 ] || return 0
|
|
90
|
+
jq -r 'select((.status // "active") != "recycled") | input_filename' "${files[@]}" 2>/dev/null \
|
|
91
|
+
| while IFS= read -r f; do _feature_slug_of_file "$f"; done
|
|
78
92
|
}
|
|
79
93
|
feature_slugs_recycled() {
|
|
80
|
-
local
|
|
81
|
-
|
|
94
|
+
local f
|
|
95
|
+
local -a files=()
|
|
96
|
+
while IFS= read -r f; do files+=("$f"); done < <(_feature_files)
|
|
97
|
+
[ "${#files[@]}" -gt 0 ] || return 0
|
|
98
|
+
jq -r 'select((.status // "active") == "recycled") | input_filename' "${files[@]}" 2>/dev/null \
|
|
99
|
+
| while IFS= read -r f; do _feature_slug_of_file "$f"; done
|
|
82
100
|
}
|
|
83
101
|
|
|
84
102
|
feature_projects() { feature_json "$1" | jq -r '.repos | keys[]' 2>/dev/null; }
|
|
@@ -165,14 +183,16 @@ retired_target() { # <slug> <proj> → 路标指向(readlink 优先,其次
|
|
|
165
183
|
}
|
|
166
184
|
|
|
167
185
|
retired_of_path() { # <path> → 每行 "<slug>\t<路标路径>\t<指向>"(命中已回收特性的路标)
|
|
168
|
-
local p=$1 slug proj wt
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
186
|
+
local p=$1 slug proj wt f
|
|
187
|
+
local -a files=()
|
|
188
|
+
while IFS= read -r f; do files+=("$f"); done < <(_feature_files)
|
|
189
|
+
[ "${#files[@]}" -gt 0 ] || return 1
|
|
190
|
+
while IFS=$'\t' read -r f proj wt; do
|
|
191
|
+
[ -n "$wt" ] || continue
|
|
192
|
+
[ -L "$wt" ] || continue
|
|
193
|
+
slug=$(_feature_slug_of_file "$f")
|
|
194
|
+
case "$p" in "$wt"|"$wt"/*) printf '%s\t%s\t%s\n' "$slug" "$wt" "$(retired_target "$slug" "$proj")"; return 0 ;; esac
|
|
195
|
+
done < <(jq -r 'select((.status // "active") == "recycled") | .repos | to_entries[] | [input_filename, .key, .value.path] | @tsv' "${files[@]}" 2>/dev/null)
|
|
176
196
|
return 1
|
|
177
197
|
}
|
|
178
198
|
retired_feature_of_path() { retired_of_path "$1" 2>/dev/null | head -1 | cut -f1; }
|
|
@@ -180,13 +200,15 @@ retired_feature_of_path() { retired_of_path "$1" 2>/dev/null | head -1 | cut -f1
|
|
|
180
200
|
# 路径落在哪个**在用**特性的 worktree 内 → 输出 slug(未命中返回 1)
|
|
181
201
|
# 已回收特性刻意不参与:它们的路径只是路标,谁都不能在那儿干活。
|
|
182
202
|
feature_of_path() {
|
|
183
|
-
local p=$1 slug wt
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
203
|
+
local p=$1 slug wt f
|
|
204
|
+
local -a files=()
|
|
205
|
+
while IFS= read -r f; do files+=("$f"); done < <(_feature_files)
|
|
206
|
+
[ "${#files[@]}" -gt 0 ] || return 1
|
|
207
|
+
while IFS=$'\t' read -r f wt; do
|
|
208
|
+
[ -n "$wt" ] || continue
|
|
209
|
+
slug=$(_feature_slug_of_file "$f")
|
|
210
|
+
case "$p" in "$wt"|"$wt"/*) printf '%s\n' "$slug"; return 0 ;; esac
|
|
211
|
+
done < <(jq -r 'select((.status // "active") != "recycled") | .repos[]?.path as $p | [input_filename, $p] | @tsv' "${files[@]}" 2>/dev/null)
|
|
190
212
|
return 1
|
|
191
213
|
}
|
|
192
214
|
|
package/lib/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/lib/config.js
CHANGED
|
@@ -85,6 +85,16 @@ export function defaultConfig() {
|
|
|
85
85
|
dshBin: process.env.DSH_BIN || 'dsh',
|
|
86
86
|
dshProfile: 'headless',
|
|
87
87
|
scheduler: 'plugin',
|
|
88
|
+
executor: 'local',
|
|
89
|
+
superagent: {
|
|
90
|
+
cli: '',
|
|
91
|
+
tokenFile: '',
|
|
92
|
+
repo: '',
|
|
93
|
+
baseUrl: '',
|
|
94
|
+
mention: '@dsh-agent',
|
|
95
|
+
issueLabel: 'agent:ready',
|
|
96
|
+
pollSeconds: 30,
|
|
97
|
+
},
|
|
88
98
|
daemonUnit: '',
|
|
89
99
|
repos: {},
|
|
90
100
|
boards: [],
|
|
@@ -120,6 +130,7 @@ function normalizeBoard(raw, base, cfg) {
|
|
|
120
130
|
timer: String(raw.unit || raw.timer || ''),
|
|
121
131
|
envFile: String(raw.envFile || ''),
|
|
122
132
|
requireAuto: asBool(raw.requireAuto, false),
|
|
133
|
+
executor: String(raw.executor || cfg.executor || 'local') === 'superagent' ? 'superagent' : 'local',
|
|
123
134
|
enabled: raw.enabled === undefined ? true : asBool(raw.enabled, true),
|
|
124
135
|
}
|
|
125
136
|
}
|
|
@@ -168,6 +179,17 @@ export function normalizeConfig(raw, { path = '' } = {}) {
|
|
|
168
179
|
dshBin: String(worker.dshBin || cfg.dshBin),
|
|
169
180
|
dshProfile: String(worker.dshProfile || cfg.dshProfile),
|
|
170
181
|
}
|
|
182
|
+
cfg.executor = String(cfg.executor || 'local') === 'superagent' ? 'superagent' : 'local'
|
|
183
|
+
const sa = cfg.superagent && typeof cfg.superagent === 'object' ? cfg.superagent : {}
|
|
184
|
+
cfg.superagent = {
|
|
185
|
+
cli: String(sa.cli || ''),
|
|
186
|
+
tokenFile: absPath(String(sa.tokenFile || ''), base),
|
|
187
|
+
repo: String(sa.repo || ''),
|
|
188
|
+
baseUrl: String(sa.baseUrl || ''),
|
|
189
|
+
mention: String(sa.mention || '@dsh-agent'),
|
|
190
|
+
issueLabel: String(sa.issueLabel || 'agent:ready'),
|
|
191
|
+
pollSeconds: asInt(sa.pollSeconds, 30, 10, 3600),
|
|
192
|
+
}
|
|
171
193
|
|
|
172
194
|
const repos = cfg.repos && typeof cfg.repos === 'object' ? cfg.repos : {}
|
|
173
195
|
cfg.repos = {}
|
|
@@ -197,13 +219,14 @@ export function normalizeConfig(raw, { path = '' } = {}) {
|
|
|
197
219
|
export function loadConfig(configFile) {
|
|
198
220
|
const path = absPath(configFile || defaultConfigPath(), process.cwd())
|
|
199
221
|
if (!existsSync(path)) {
|
|
200
|
-
const cfg = normalizeConfig({}, { path })
|
|
222
|
+
const cfg = normalizeConfig({}, { path }); cfg._path = path
|
|
201
223
|
cfg.problems.unshift(`配置文件不存在:${path}(先运行 offhours init 或安装时生成)`)
|
|
202
224
|
return { config: cfg, path, exists: false }
|
|
203
225
|
}
|
|
204
226
|
try {
|
|
205
227
|
const raw = JSON.parse(readFileSync(path, 'utf8'))
|
|
206
|
-
|
|
228
|
+
const config = normalizeConfig(raw, { path }); config._path = path
|
|
229
|
+
return { config, path, exists: true }
|
|
207
230
|
} catch (e) {
|
|
208
231
|
const cfg = normalizeConfig({}, { path })
|
|
209
232
|
cfg.problems.unshift(`配置不是合法 JSON:${path}(${(e && e.message) || e})`)
|
|
@@ -221,9 +244,11 @@ export function saveConfig(path, cfg) {
|
|
|
221
244
|
dshProfile: cfg.dshProfile,
|
|
222
245
|
scheduler: cfg.scheduler,
|
|
223
246
|
daemonUnit: cfg.daemonUnit || '',
|
|
247
|
+
executor: cfg.executor || 'local',
|
|
248
|
+
superagent: cfg.superagent || {},
|
|
224
249
|
repos: cfg.repos,
|
|
225
|
-
boards: cfg.boards.map(({ id, label, file, workdir, repos, unit, envFile, requireAuto, enabled }) => ({
|
|
226
|
-
id, label, file, workdir, repos, unit, envFile, requireAuto, enabled,
|
|
250
|
+
boards: cfg.boards.map(({ id, label, file, workdir, repos, unit, envFile, requireAuto, executor, enabled }) => ({
|
|
251
|
+
id, label, file, workdir, repos, unit, envFile, requireAuto, executor, enabled,
|
|
227
252
|
})),
|
|
228
253
|
gates: cfg.gates,
|
|
229
254
|
schedule: cfg.schedule,
|
|
@@ -255,6 +280,9 @@ export function scanEnv(cfg, board, extra = {}) {
|
|
|
255
280
|
NIGHTSHIFT_BOARDS: cfg.boards.map((b) => b.file).join(' '),
|
|
256
281
|
NIGHTSHIFT_WORKER_PERMISSION_MODE: cfg.worker.permissionMode,
|
|
257
282
|
NIGHTSHIFT_SANDBOX: 'auto',
|
|
283
|
+
NIGHTSHIFT_EXECUTOR: board.executor || cfg.executor || 'local',
|
|
284
|
+
NIGHTSHIFT_SUPERAGENT_CONFIG: cfg._path || '',
|
|
285
|
+
OFFHOURS_CONFIG: cfg._path || '',
|
|
258
286
|
MAX_CARDS_PER_RUN: String(cfg.gates.maxCardsPerRun),
|
|
259
287
|
MAX_CARDS_PER_DAY: String(cfg.gates.maxCardsPerDay),
|
|
260
288
|
MAX_CNY_PER_DAY: String(cfg.gates.maxCnyPerDay),
|
package/mechanism/ns.py
CHANGED
|
@@ -385,7 +385,9 @@ def eligible(card):
|
|
|
385
385
|
# HOLD_IN_TEXT(2026-11-01):标题或描述里写明 HOLD 的卡(外部依赖未就绪,如"需 key+设备")
|
|
386
386
|
# 以前只写在标题里、没有 hold 标签 ⇒ 会被反复挑中然后必然失败。
|
|
387
387
|
_t = (card.get("title") or "") + "\n" + (card.get("description") or "")[:2000]
|
|
388
|
-
|
|
388
|
+
# 2026-11-04:不再用"解除"二字做豁免(说明文字里的"解除 hold"会把阻塞卡误放行)。
|
|
389
|
+
# 放行必须显式加结构化标签 released(或去掉标题里的 HOLD)。
|
|
390
|
+
if "HOLD" in _t.upper() and "released" not in tags:
|
|
389
391
|
return False
|
|
390
392
|
if REQUIRE_AUTO and "auto" not in tags:
|
|
391
393
|
return False
|
|
@@ -440,8 +442,17 @@ def cmd_pick(argv):
|
|
|
440
442
|
|
|
441
443
|
cand = [c for c in todos if eligible(c)]
|
|
442
444
|
decisions = [c for c in cand if is_decision(c)]
|
|
443
|
-
|
|
444
|
-
|
|
445
|
+
runnable_all = sorted([c for c in cand if not is_decision(c)],
|
|
446
|
+
key=lambda c: 0 if "auto" in _tags(c) else 1) # auto 优先
|
|
447
|
+
# 同源会话串行保护:同一次扫描里,同一个 sourceSessionId 只允许一个 worker。
|
|
448
|
+
# 原因(2026-11-10 实测):同一会话派生的多张卡虽然各有 worker worktree,但 merge-back
|
|
449
|
+
# 都回到同一个源 worktree;并行合并会撞 .devloop/posture / worker-progress,并让 worker
|
|
450
|
+
# 看到别人的现场,最终出现「结果行无法解析/串味」。宁可串行,不要串味。
|
|
451
|
+
active_sessions = {x for x in os.environ.get("NIGHTSHIFT_ACTIVE_SESSIONS", "").split() if x}
|
|
452
|
+
blocked = [c for c in runnable_all
|
|
453
|
+
if (c.get("sourceSessionId") or "").strip() in active_sessions]
|
|
454
|
+
runnable = [c for c in runnable_all
|
|
455
|
+
if (c.get("sourceSessionId") or "").strip() not in active_sessions]
|
|
445
456
|
missing_session = [c for c in todos if not (c.get("sourceSessionId") or "").strip()]
|
|
446
457
|
picked = runnable[0] if runnable else None
|
|
447
458
|
reason = None
|
|
@@ -453,6 +464,9 @@ def cmd_pick(argv):
|
|
|
453
464
|
"或在 dsh 会话里给它补 --session)" % len(missing_session))
|
|
454
465
|
elif not cand:
|
|
455
466
|
reason = "所有 todo 都标了 hold(禁跑)"
|
|
467
|
+
elif blocked:
|
|
468
|
+
reason = ("同源会话已有 worker 在跑(%d 张同会话卡暂时串行),等这一波结束再跑"
|
|
469
|
+
% len(blocked))
|
|
456
470
|
else:
|
|
457
471
|
reason = "只剩需你拍板的卡(%d 张),无人值守不碰" % len(decisions)
|
|
458
472
|
print(json.dumps({
|
|
@@ -463,6 +477,7 @@ def cmd_pick(argv):
|
|
|
463
477
|
"sourceSessionId": picked.get("sourceSessionId") or ""},
|
|
464
478
|
"reason": reason,
|
|
465
479
|
"todoCount": len(todos), "runnableCount": len(runnable),
|
|
480
|
+
"blockedSessionCount": len(blocked),
|
|
466
481
|
"decisionCards": [{"id": c["id"], "title": c["title"][:60]} for c in decisions],
|
|
467
482
|
"heldCount": len(todos) - len(cand),
|
|
468
483
|
"missingSessionCount": len(missing_session),
|
|
@@ -1245,13 +1260,22 @@ def cmd_confirm_recommended(argv):
|
|
|
1245
1260
|
done = skipped = 0
|
|
1246
1261
|
for c in [x for x in board.get("cards", []) if x.get("status") == "review"]:
|
|
1247
1262
|
desc = c.get("description") or ""
|
|
1248
|
-
if "【审批单 · explain-plain】" not in desc:
|
|
1249
|
-
skipped += 1
|
|
1250
|
-
continue
|
|
1251
1263
|
opts = [l.strip() for l in desc.splitlines() if l.strip().startswith("- O")]
|
|
1252
1264
|
rec = [l for l in opts if "推荐" in l] or opts[:1]
|
|
1253
|
-
|
|
1254
|
-
|
|
1265
|
+
# 2026-11-10:审批窗口对「没有审批单」的 review 卡也显示「全部按推荐批准」。
|
|
1266
|
+
# 以前这里静默跳过 ⇒ 按钮看起来无效。没有审批单时退化为单卡一键放行
|
|
1267
|
+
# (与卡上「审核通过(回待办)」完全同一结论;hold 仍保留,除非显式 --unhold)。
|
|
1268
|
+
if "【审批单 · explain-plain】" not in desc or not rec:
|
|
1269
|
+
decision = "审核通过(按卡内推荐执行)"
|
|
1270
|
+
detail = "人审通过 ⇒ 回到待办,由后续轮次按卡内推荐执行"
|
|
1271
|
+
if dry:
|
|
1272
|
+
print("DRY %-20s ← %s" % (c["id"][:20], decision))
|
|
1273
|
+
continue
|
|
1274
|
+
rc = cmd_confirm([board_path, c["id"], decision, detail, "--by", by])
|
|
1275
|
+
if rc == 0:
|
|
1276
|
+
done += 1
|
|
1277
|
+
else:
|
|
1278
|
+
skipped += 1
|
|
1255
1279
|
continue
|
|
1256
1280
|
text = rec[0]
|
|
1257
1281
|
m = re.match(r"-\s*(O\d+)[::]\s*(.*)$", text)
|
|
@@ -1566,6 +1590,25 @@ def _source_worktree(card, allow_create=False):
|
|
|
1566
1590
|
return None, "E_ASSOC_INCOMPLETE 源会话关联文件缺 feature/project:%s" % assoc_path
|
|
1567
1591
|
ok, why = _feature_counts_as_usable(feature, project)
|
|
1568
1592
|
if not ok:
|
|
1593
|
+
# P2-ASSOC-HOLD(2026-11-04):源特性缺失 ⇒ 这张卡每次被领都会失败。
|
|
1594
|
+
# 在这里就把 hold 打上并写清原因,停止空转;人修好关联后去掉 hold 即可。
|
|
1595
|
+
try:
|
|
1596
|
+
def _fix(bd):
|
|
1597
|
+
cc = _find(bd, card["id"])
|
|
1598
|
+
if cc is None:
|
|
1599
|
+
return False
|
|
1600
|
+
tags = list(cc.get("tags") or [])
|
|
1601
|
+
if "hold" not in [t.lower() for t in tags]:
|
|
1602
|
+
tags.append("hold")
|
|
1603
|
+
cc["tags"] = tags
|
|
1604
|
+
cc["description"] = (cc.get("description") or "").rstrip() + (
|
|
1605
|
+
"\n【自动止血 %s】源特性缺失(%s)⇒ 已加 hold 停止空转;请 feature attach 后去掉 hold。\n"
|
|
1606
|
+
% (time.strftime("%Y-%m-%d %H:%M"), (why or "")[:80]))
|
|
1607
|
+
cc["updatedAt"] = int(time.time() * 1000)
|
|
1608
|
+
return True
|
|
1609
|
+
mutate_board(board_path or KANBAN_DEFAULT, _fix)
|
|
1610
|
+
except Exception:
|
|
1611
|
+
pass
|
|
1569
1612
|
return None, "E_ASSOC_STALE %s;请重新 feature attach <特性名>(或删掉该关联后重跑)" % why
|
|
1570
1613
|
wt = _feature_worktree(feature, project)
|
|
1571
1614
|
return {"session": sid, "feature": feature, "project": project, "worktree": wt}, ""
|
|
@@ -2221,6 +2264,14 @@ def cmd_invariants(argv):
|
|
|
2221
2264
|
if heal:
|
|
2222
2265
|
_sh.rmtree(sp, ignore_errors=True); healed.append("释放僵尸槽 %s" % d)
|
|
2223
2266
|
continue
|
|
2267
|
+
# SLOT-BOARD-SCOPE:只处置"属于本看板"的槽位,避免跨板误伤(2026-11-04 故障注入发现)
|
|
2268
|
+
try:
|
|
2269
|
+
_owner = open(_os.path.join(sp, "board")).read().strip()
|
|
2270
|
+
except Exception:
|
|
2271
|
+
_owner = ""
|
|
2272
|
+
_mykey = _os.path.basename(_os.path.dirname(_os.path.abspath(path))) or ""
|
|
2273
|
+
if _owner and _mykey and _owner != _mykey:
|
|
2274
|
+
continue
|
|
2224
2275
|
try:
|
|
2225
2276
|
card = open(_os.path.join(sp, "card")).read().strip()
|
|
2226
2277
|
except Exception:
|
|
@@ -2228,12 +2279,60 @@ def cmd_invariants(argv):
|
|
|
2228
2279
|
if card:
|
|
2229
2280
|
if card in seen:
|
|
2230
2281
|
violations.append({"inv": "I4", "slot": d, "why": "卡 %s 同占 %s/%s" % (card[:16], seen[card], d)})
|
|
2282
|
+
if heal:
|
|
2283
|
+
# 保留先出现的那个槽,释放重复槽(避免同卡被两个 worker 抢着写)
|
|
2284
|
+
import shutil as _sh2
|
|
2285
|
+
_sh2.rmtree(sp, ignore_errors=True)
|
|
2286
|
+
healed.append("释放重复槽 %s(卡 %s 已由槽 %s 占用)" % (d, card[:16], seen[card]))
|
|
2287
|
+
continue
|
|
2231
2288
|
seen[card] = d
|
|
2232
2289
|
if card not in ip:
|
|
2233
2290
|
violations.append({"inv": "I1", "slot": d, "card": card[:16], "why": "槽位占着非 in_progress 卡"})
|
|
2234
2291
|
if heal:
|
|
2235
2292
|
_sh.rmtree(sp, ignore_errors=True); healed.append("释放槽 %s" % d)
|
|
2236
2293
|
return_viol = list(violations)
|
|
2294
|
+
# I5(2026-11-04):已有人工【确认】且确认时间晚于最后一次【送审】的 review 卡,
|
|
2295
|
+
# 说明"批准→回待办"这一步没落地(面板/confirm-all 路径都可能漏)⇒ 自愈推回 todo。
|
|
2296
|
+
import re as _re
|
|
2297
|
+
_review = [c for c in b.get("cards", []) if c.get("status") == "review"]
|
|
2298
|
+
_advanced = []
|
|
2299
|
+
for c in _review:
|
|
2300
|
+
desc = c.get("description") or ""
|
|
2301
|
+
confs = _re.findall(r"【确认 (\d{4}-\d{2}-\d{2} \d{2}:\d{2})", desc)
|
|
2302
|
+
subms = _re.findall(r"【夜间跑批送审 (\d{4}-\d{2}-\d{2} \d{2}:\d{2})", desc)
|
|
2303
|
+
if not confs:
|
|
2304
|
+
continue
|
|
2305
|
+
if subms and confs[-1] < subms[-1]:
|
|
2306
|
+
continue # 批准后又被重新送审 ⇒ 需要人再看,不动
|
|
2307
|
+
# P1(2026-11-04):收窄三处误判 ——
|
|
2308
|
+
# ① 正在被 worker 跑(槽位里有它)⇒ 不动;② 有活槽位(一轮在跑)⇒ 不动;
|
|
2309
|
+
# ③ 最后一次送审在 30 分钟内 ⇒ 视为"刚送审,可能还在流转",不动。
|
|
2310
|
+
if c["id"] in seen:
|
|
2311
|
+
continue
|
|
2312
|
+
if len(seen) > 0:
|
|
2313
|
+
continue
|
|
2314
|
+
try:
|
|
2315
|
+
import time as _t
|
|
2316
|
+
if subms:
|
|
2317
|
+
_last = _t.mktime(_t.strptime(subms[-1], "%Y-%m-%d %H:%M"))
|
|
2318
|
+
if _t.time() - _last < 1800:
|
|
2319
|
+
continue
|
|
2320
|
+
except Exception:
|
|
2321
|
+
pass
|
|
2322
|
+
if heal:
|
|
2323
|
+
def _mut(bd, _cid=c["id"], _conf=confs[-1]):
|
|
2324
|
+
cc = _find(bd, _cid)
|
|
2325
|
+
if cc is None or cc.get("status") != "review":
|
|
2326
|
+
return False
|
|
2327
|
+
cc["status"] = "todo"
|
|
2328
|
+
cc["description"] = (cc.get("description") or "").rstrip() + (
|
|
2329
|
+
"\n【推进自愈 %s】有效确认(%s)但状态仍 review ⇒ 回 todo。\n" % (time.strftime("%Y-%m-%d %H:%M"), _conf))
|
|
2330
|
+
cc["updatedAt"] = int(time.time() * 1000)
|
|
2331
|
+
return True
|
|
2332
|
+
if mutate_board(path, _mut) == "ok":
|
|
2333
|
+
_advanced.append(c["id"][:16]); healed.append("批准未推进 ⇒ 回 todo %s" % c["id"][:16])
|
|
2334
|
+
else:
|
|
2335
|
+
return_viol.append({"inv": "I5", "card": c["id"][:16], "why": "已有效确认但仍是 review(批准→待办未落地)"})
|
|
2237
2336
|
for cid in list(ip):
|
|
2238
2337
|
if cid in seen:
|
|
2239
2338
|
continue
|