@marsken/dsh-studio 0.0.0-stage → 0.1.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/AGENTS.md +148 -0
- package/LICENSE +202 -0
- package/README.md +1356 -3
- package/client.js +952 -0
- package/client.src.js +2466 -0
- package/cordis.patch.yml +9 -0
- package/dist/client.js +952 -0
- package/docs/compatibility.md +99 -0
- package/docs/publish.md +86 -0
- package/docs/quickstart.md +53 -0
- package/icon.svg +6 -0
- package/index.js +2674 -0
- package/locale/en.json +11 -0
- package/locale/zh.json +11 -0
- package/package.json +63 -4
- package/scripts/smoke-store.mjs +76 -0
- package/src/host/cases.js +135 -0
- package/src/host/design.js +87 -0
- package/src/host/dispatcher.js +936 -0
- package/src/host/domain.js +1966 -0
- package/src/host/executor.js +1396 -0
- package/src/host/gitlab-facts.js +22 -0
- package/src/host/impact.js +32 -0
- package/src/host/integrations.js +587 -0
- package/src/host/quality.js +106 -0
- package/src/host/routes/admin.js +100 -0
- package/src/host/routes/agents.js +71 -0
- package/src/host/routes/auth.js +155 -0
- package/src/host/routes/coding.js +203 -0
- package/src/host/routes/connect.js +166 -0
- package/src/host/routes/exports.js +122 -0
- package/src/host/routes/insights.js +97 -0
- package/src/host/routes/releases.js +335 -0
- package/src/host/routes/requirements.js +430 -0
- package/src/host/routes/skills.js +75 -0
- package/src/host/routes/tasks.js +573 -0
- package/src/host/store.js +522 -0
- package/src/host/team-memory.js +46 -0
- package/src/host/tools.js +1211 -0
|
@@ -0,0 +1,936 @@
|
|
|
1
|
+
// ------------------------------------------------------------------ dispatcher
|
|
2
|
+
import { DEFAULT_ROLES, FALLBACK_AGENT_MODEL, ROLE_IDS, SANDBOX_MODES, STATUSES, codingSummary, normalizeStrategy, worktreeRoot } from './domain.js'
|
|
3
|
+
import { loadTeamMemory, teamMemoryBlock } from './team-memory.js'
|
|
4
|
+
import { mkdir } from 'node:fs/promises'
|
|
5
|
+
import { randomBytes } from 'node:crypto'
|
|
6
|
+
import { homedir } from 'node:os'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
// 派单引擎:一卡一会话、槽位限流、幂等投递、SLA 升级。
|
|
9
|
+
// 需要宿主能力的少数几处走 configureDispatcher() 注入,避免这里反过来依赖 index.js。
|
|
10
|
+
|
|
11
|
+
let host = {}
|
|
12
|
+
|
|
13
|
+
export function configureDispatcher(services) {
|
|
14
|
+
host = services ?? {}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------- dispatcher
|
|
18
|
+
|
|
19
|
+
/** Display labels for the four shipped roles. */
|
|
20
|
+
|
|
21
|
+
/** What each role is expected to do with the card it is handed. */
|
|
22
|
+
export const ROLE_BRIEF = {
|
|
23
|
+
product: '澄清需求:补齐目标、范围、验收标准,写进 requirement 字段;能开工就把卡推进到「待方案」。',
|
|
24
|
+
architect: '产出技术方案:改哪些模块、数据流、接口、风险与替代方案;结论写进评论并附关键文件路径;定稿后推进到「待开发」。',
|
|
25
|
+
dev: '按方案实现代码:先读相关源码,改动小步可验证;需要时开分支提 MR 并把链接写进 gitlabMR;自测通过后推进到「待测试」。',
|
|
26
|
+
qa: '按需求与方案设计用例并验证:正常路径、边界、异常;记录实际执行的命令与结果;通过推进到「待验收」,不通过退回「待开发」并写明原因。',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 一个角色位在本领域下的候选人:本领域优先,其次通用(没有领域归属),最后其他领域。 */
|
|
30
|
+
export function roleCandidates(config, role, domain) {
|
|
31
|
+
const pool = (config.experts ?? []).filter(expert => expert.enabled !== false && (expert.role ?? '') === role)
|
|
32
|
+
const want = String(domain ?? '').trim()
|
|
33
|
+
const rank = expert => (want !== '' && expert.domain === want ? 2 : (expert.domain ?? '') === '' ? 1 : 0)
|
|
34
|
+
return pool.slice().sort((a, b) => rank(b) - rank(a))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Resolve one column owner (`{kind,id}`) against the roster. */
|
|
38
|
+
export function resolveOwner(config, owner, domain) {
|
|
39
|
+
if (owner === null || typeof owner !== 'object') return undefined
|
|
40
|
+
// 这一列没人负责:卡停在这等人认领,不自动派。
|
|
41
|
+
if (owner.kind === 'none') return undefined
|
|
42
|
+
// 角色位:泳道配的是「产品经理/研发工程师」,具体落到哪位由卡的业务领域决定。
|
|
43
|
+
if (owner.kind === 'role') {
|
|
44
|
+
const role = String(owner.id ?? owner.role ?? '').trim()
|
|
45
|
+
if (!ROLE_IDS.includes(role)) return undefined
|
|
46
|
+
const picks = roleCandidates(config, role, domain)
|
|
47
|
+
if (picks.length === 0) return undefined
|
|
48
|
+
const expert = picks[0]
|
|
49
|
+
return { kind: 'expert', expert, team: undefined, label: `${expert.emoji} ${expert.name}`, viaRole: role }
|
|
50
|
+
}
|
|
51
|
+
if (owner.kind === 'team') {
|
|
52
|
+
const team = (config.teams ?? []).find(entry => entry.id === owner.id)
|
|
53
|
+
if (team === undefined || team.members.length === 0) return undefined
|
|
54
|
+
const leadId = team.members.includes(team.lead) ? team.lead : team.members[0]
|
|
55
|
+
const expert = (config.experts ?? []).find(entry => entry.id === leadId)
|
|
56
|
+
if (expert === undefined) return undefined
|
|
57
|
+
return { kind: 'team', team, expert, label: `专家团 · ${team.name}` }
|
|
58
|
+
}
|
|
59
|
+
const expert = (config.experts ?? []).find(entry => entry.id === owner.id)
|
|
60
|
+
if (expert === undefined) return undefined
|
|
61
|
+
return { kind: 'expert', expert, team: undefined, label: `${expert.emoji} ${expert.name}` }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 卡片业务领域:卡上写了用卡的,没写就用它所属需求的。 */
|
|
65
|
+
export function taskDomain(task, state) {
|
|
66
|
+
const own = String(task?.domain ?? '').trim()
|
|
67
|
+
if (own !== '') return own
|
|
68
|
+
const requirement = (state?.requirements ?? []).find(entry => entry.id === task?.requirementId)
|
|
69
|
+
return String(requirement?.domain ?? '').trim()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Who owns a card right now: the column's owner, else the card's own expert. */
|
|
73
|
+
export function ownerFor(config, task, column, load, state) {
|
|
74
|
+
const raw = config.columns?.[column.id]
|
|
75
|
+
const domain = taskDomain(task, state)
|
|
76
|
+
const candidates = columnCandidates(config, column, domain)
|
|
77
|
+
let resolved = pickFromPool(candidates, raw, task, column, load)
|
|
78
|
+
// 卡片标了业务领域、而挑出来的这位没有领域归属时,去全员里找同领域、角色位对得上的。
|
|
79
|
+
if (domain.length > 0 && resolved?.kind !== 'team' && (resolved === undefined || (resolved.expert?.domain ?? '') === '')) {
|
|
80
|
+
const sameDomain = (config.experts ?? []).filter(entry => entry.enabled !== false && entry.domain === domain)
|
|
81
|
+
if (sameDomain.length > 0) {
|
|
82
|
+
const byRole = sameDomain.find(entry => (entry.role ?? '') === (column?.role ?? ''))
|
|
83
|
+
const pick = byRole ?? sameDomain[0]
|
|
84
|
+
return { kind: 'expert', expert: pick, team: undefined, label: `${pick.emoji} ${pick.name}` }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (resolved !== undefined) return resolved
|
|
88
|
+
if (typeof task.expert === 'string' && task.expert.length > 0) {
|
|
89
|
+
const fallback = resolveOwner(config, { kind: 'expert', id: task.expert })
|
|
90
|
+
if (fallback !== undefined) return fallback
|
|
91
|
+
}
|
|
92
|
+
// 没人负责就没人负责:不再默认塞给产品经理,等人来指定。
|
|
93
|
+
return undefined
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 这一列的全部候选人(展开团队时取团长)。 */
|
|
97
|
+
export function columnCandidates(config, column, domain) {
|
|
98
|
+
const raw = config.columns?.[column.id]
|
|
99
|
+
if (raw === undefined || raw === null) return []
|
|
100
|
+
// 角色位展开成「同角色的一组人」,再交给候选池策略挑。
|
|
101
|
+
if (Array.isArray(raw.members)) {
|
|
102
|
+
const out = []
|
|
103
|
+
const seen = new Set()
|
|
104
|
+
for (const member of raw.members) {
|
|
105
|
+
if (member?.kind === 'role') {
|
|
106
|
+
for (const expert of roleCandidates(config, String(member.id ?? ''), domain)) {
|
|
107
|
+
if (seen.has(expert.id)) continue
|
|
108
|
+
seen.add(expert.id)
|
|
109
|
+
out.push({ kind: 'expert', expert, team: undefined, label: `${expert.emoji} ${expert.name}` })
|
|
110
|
+
}
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
const one = resolveOwner(config, member, domain)
|
|
114
|
+
if (one === undefined) continue
|
|
115
|
+
const key = one.expert?.id ?? ''
|
|
116
|
+
if (key.length > 0 && seen.has(key)) continue
|
|
117
|
+
if (key.length > 0) seen.add(key)
|
|
118
|
+
out.push(one)
|
|
119
|
+
}
|
|
120
|
+
return out
|
|
121
|
+
}
|
|
122
|
+
const one = resolveOwner(config, raw, domain)
|
|
123
|
+
return one === undefined ? [] : [one]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 按策略从候选池里挑一个:轮询看手上活多少,匹配看专长,或交给专家团团长。 */
|
|
127
|
+
export function pickFromPool(candidates, raw, task, column, load) {
|
|
128
|
+
if (candidates.length === 0) return undefined
|
|
129
|
+
if (candidates.length === 1) return candidates[0]
|
|
130
|
+
const strategy = normalizeStrategy(raw?.strategy)
|
|
131
|
+
if (strategy === 'lead') {
|
|
132
|
+
const team = candidates.find(entry => entry.kind === 'team')
|
|
133
|
+
return team ?? leastLoaded(candidates, load)
|
|
134
|
+
}
|
|
135
|
+
if (strategy === 'best_match') {
|
|
136
|
+
const scored = candidates.map(entry => ({ entry, score: matchScore(entry, task, column) }))
|
|
137
|
+
const top = Math.max(...scored.map(item => item.score))
|
|
138
|
+
return leastLoaded(scored.filter(item => item.score === top).map(item => item.entry), load)
|
|
139
|
+
}
|
|
140
|
+
return leastLoaded(candidates, load)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function leastLoaded(candidates, load) {
|
|
144
|
+
if (load === undefined || typeof load.get !== 'function') return candidates[0]
|
|
145
|
+
let best = candidates[0]
|
|
146
|
+
let bestLoad = Number.POSITIVE_INFINITY
|
|
147
|
+
for (const entry of candidates) {
|
|
148
|
+
const count = load.get(entry.expert?.id ?? '') ?? 0
|
|
149
|
+
if (count < bestLoad) { best = entry; bestLoad = count }
|
|
150
|
+
}
|
|
151
|
+
return best
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function matchScore(entry, task, column) {
|
|
155
|
+
const expert = entry.expert
|
|
156
|
+
if (expert === undefined) return 0
|
|
157
|
+
let score = 0
|
|
158
|
+
if (expert.domain && task.domain && expert.domain === task.domain) score += 50
|
|
159
|
+
if ((column?.role ?? '') !== '' && (expert.role ?? '') === column.role) score += 20
|
|
160
|
+
const text = `${task.title ?? ''} ${task.requirement ?? ''}`.toLowerCase()
|
|
161
|
+
if (text.trim() !== '') {
|
|
162
|
+
for (const skill of expert.skills ?? []) {
|
|
163
|
+
const key = String(skill ?? '').trim().toLowerCase()
|
|
164
|
+
if (key.length >= 2 && text.includes(key)) score += 5
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return score
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* People (and the board) call an expert by writing `@名字` in a comment.
|
|
172
|
+
* Sorting by length keeps `@架构师助理` from also firing `@架构师`.
|
|
173
|
+
*/
|
|
174
|
+
export function parseMentions(body, config) {
|
|
175
|
+
const source = String(body ?? '')
|
|
176
|
+
if (source.length === 0) return []
|
|
177
|
+
const candidates = [
|
|
178
|
+
...(config.experts ?? []).filter(expert => expert.enabled).map(expert => ({ kind: 'expert', id: expert.id, name: expert.name })),
|
|
179
|
+
...(config.teams ?? []).filter(team => team.enabled).map(team => ({ kind: 'team', id: team.id, name: team.name })),
|
|
180
|
+
].sort((a, b) => b.name.length - a.name.length)
|
|
181
|
+
const hits = []
|
|
182
|
+
const seen = new Set()
|
|
183
|
+
for (const candidate of candidates) {
|
|
184
|
+
if (candidate.name.length === 0) continue
|
|
185
|
+
if (!source.includes(`@${candidate.name}`) && !source.includes(`@${candidate.id}`)) continue
|
|
186
|
+
const key = `${candidate.kind}:${candidate.id}`
|
|
187
|
+
if (seen.has(key)) continue
|
|
188
|
+
seen.add(key)
|
|
189
|
+
hits.push({ ...candidate, label: candidate.kind === 'team' ? `专家团 · ${candidate.name}` : candidate.name })
|
|
190
|
+
}
|
|
191
|
+
return hits
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Deep-freeze a literal message object. */
|
|
195
|
+
export function frozen(value) {
|
|
196
|
+
if (value !== null && typeof value === 'object') {
|
|
197
|
+
for (const item of Object.values(value)) frozen(item)
|
|
198
|
+
Object.freeze(value)
|
|
199
|
+
}
|
|
200
|
+
return value
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Write the dispatcher record of one card without going through the tool patch filter. */
|
|
204
|
+
export async function markDispatch(store, id, dispatch) {
|
|
205
|
+
await store.mutate(state => {
|
|
206
|
+
const task = state.tasks.find(entry => entry.id === id)
|
|
207
|
+
if (task !== undefined) task.dispatch = dispatch
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Hands a card to its role agent: one Session per card, bounded concurrency,
|
|
213
|
+
* and at most one delivery per (card, column) pair.
|
|
214
|
+
*/
|
|
215
|
+
/**
|
|
216
|
+
* 一次派单 = 一次 AI 执行。这里留最近 300 条,用于「这个卡是谁、用哪个模型、
|
|
217
|
+
* 哪一版提示词、跑了多久」的成本视图(宿主目前不回报 token,所以只统计次数与时长)。
|
|
218
|
+
*/
|
|
219
|
+
export const RUN_LOG = []
|
|
220
|
+
|
|
221
|
+
// 运行日志落盘:quality 的「派单次数 / 平均耗时」靠它,只放内存的话重启就归零。
|
|
222
|
+
// 同样走 side 键,不占 state、不涨 revision;写是去抖的,一次派单不会写一次盘。
|
|
223
|
+
const RUNS_KEY = 'agent-runs'
|
|
224
|
+
let runStore = null
|
|
225
|
+
let runTimer = null
|
|
226
|
+
|
|
227
|
+
/** 开机时把上次的派单日志读回来;store 没就绪也能调,readSide 自己会等。 */
|
|
228
|
+
export function attachRunLog(store) {
|
|
229
|
+
if (store === null || store === undefined) return
|
|
230
|
+
runStore = store
|
|
231
|
+
void store.readSide(RUNS_KEY).then(saved => {
|
|
232
|
+
if (!Array.isArray(saved) || saved.length === 0) return
|
|
233
|
+
if (RUN_LOG.length > 0) return
|
|
234
|
+
RUN_LOG.push(...saved.slice(0, 300))
|
|
235
|
+
}).catch(() => { /* 读不回来就按空的重来 */ })
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function flushRunLog() {
|
|
239
|
+
if (runStore === null || runTimer !== null) return
|
|
240
|
+
runTimer = setTimeout(() => {
|
|
241
|
+
runTimer = null
|
|
242
|
+
void runStore.writeSide(RUNS_KEY, RUN_LOG.slice(0, 300)).catch(() => {})
|
|
243
|
+
}, 1500)
|
|
244
|
+
runTimer.unref?.()
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function recordRun(entry) {
|
|
248
|
+
RUN_LOG.unshift({ ...entry, at: Number(entry.at) || Date.now(), doneAt: 0, ms: 0 })
|
|
249
|
+
if (RUN_LOG.length > 300) RUN_LOG.length = 300
|
|
250
|
+
flushRunLog()
|
|
251
|
+
return RUN_LOG[0]
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function settleRun(sessionId) {
|
|
255
|
+
const entry = RUN_LOG.find(item => item.sessionId === sessionId && item.doneAt === 0)
|
|
256
|
+
if (entry === undefined) return
|
|
257
|
+
entry.doneAt = Date.now()
|
|
258
|
+
entry.ms = Math.max(0, entry.doneAt - entry.at)
|
|
259
|
+
flushRunLog()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function runSummary() {
|
|
263
|
+
const byExpert = new Map()
|
|
264
|
+
const byModel = new Map()
|
|
265
|
+
const byPrompt = new Map()
|
|
266
|
+
const byDay = new Map()
|
|
267
|
+
let finished = 0
|
|
268
|
+
let totalMs = 0
|
|
269
|
+
for (const run of RUN_LOG) {
|
|
270
|
+
const bump = (map, key) => {
|
|
271
|
+
const current = map.get(key) ?? { key, runs: 0, ms: 0 }
|
|
272
|
+
current.runs += 1
|
|
273
|
+
current.ms += run.ms
|
|
274
|
+
map.set(key, current)
|
|
275
|
+
}
|
|
276
|
+
bump(byExpert, run.expertName || run.expert || '未记录')
|
|
277
|
+
bump(byModel, run.model || '跟随默认')
|
|
278
|
+
bump(byPrompt, run.promptVersion || '未记录')
|
|
279
|
+
const day = new Date(run.at).toISOString().slice(0, 10)
|
|
280
|
+
const currentDay = byDay.get(day) ?? { day, runs: 0 }
|
|
281
|
+
currentDay.runs += 1
|
|
282
|
+
byDay.set(day, currentDay)
|
|
283
|
+
if (run.doneAt > 0) {
|
|
284
|
+
finished += 1
|
|
285
|
+
totalMs += run.ms
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const sort = map => [...map.values()].sort((a, b) => b.runs - a.runs)
|
|
289
|
+
return {
|
|
290
|
+
total: RUN_LOG.length,
|
|
291
|
+
finished,
|
|
292
|
+
avgMs: finished > 0 ? Math.round(totalMs / finished) : 0,
|
|
293
|
+
byExpert: sort(byExpert),
|
|
294
|
+
byModel: sort(byModel),
|
|
295
|
+
byPromptVersion: sort(byPrompt),
|
|
296
|
+
days: [...byDay.values()].sort((a, b) => (a.day < b.day ? -1 : 1)).slice(-14),
|
|
297
|
+
note: '宿主没有回传 token 用量,这里统计的是派单次数与执行时长;成本按你们自己的模型单价乘次数即可。',
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* AI 派单失败的退避计划:第 1 次失败等 base,第 2 次等 base×factor……
|
|
303
|
+
* 超过 max 次就不再自动重试,改成挂阻塞等人。
|
|
304
|
+
*/
|
|
305
|
+
export function agentRetryPlan(config, failures) {
|
|
306
|
+
const retry = (config?.agents ?? {}).retry ?? {}
|
|
307
|
+
const max = Math.max(0, Math.min(6, Number(retry.max ?? 2)))
|
|
308
|
+
const base = Math.max(1000, Math.min(600000, Number(retry.baseMs ?? 60000)))
|
|
309
|
+
const factor = Math.max(1, Math.min(6, Number(retry.factor ?? 2)))
|
|
310
|
+
const exhausted = failures > max
|
|
311
|
+
const delayMs = exhausted ? 0 : Math.min(Math.round(base * Math.pow(factor, Math.max(0, failures - 1))), 30 * 60000)
|
|
312
|
+
return { max, base, factor, exhausted, delayMs }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** 记一次 AI 失败:计数、退避时间、历史、通知;超过上限就挂阻塞。 */
|
|
316
|
+
export async function noteAgentFailure(store, taskId, error, by = '系统', opts = {}) {
|
|
317
|
+
const state = store.snapshot()
|
|
318
|
+
const task = state.tasks.find(entry => entry.id === taskId)
|
|
319
|
+
if (task === undefined) return undefined
|
|
320
|
+
const next = Math.max(0, Number(task.agentFailures) || 0) + 1
|
|
321
|
+
const plan = agentRetryPlan(state.config, next)
|
|
322
|
+
const now = Date.now()
|
|
323
|
+
const message = String(error?.message ?? error ?? '未知错误').slice(0, 200)
|
|
324
|
+
await store.mutate(draft => {
|
|
325
|
+
const found = draft.tasks.find(entry => entry.id === taskId)
|
|
326
|
+
if (found === undefined) return
|
|
327
|
+
found.agentFailures = next
|
|
328
|
+
found.retryAt = plan.exhausted ? 0 : now + plan.delayMs
|
|
329
|
+
found.history = [...(found.history ?? []), {
|
|
330
|
+
at: now,
|
|
331
|
+
by,
|
|
332
|
+
status: found.status,
|
|
333
|
+
note: plan.exhausted
|
|
334
|
+
? `AI 第 ${next} 次失败,超过自动重试上限(${plan.max} 次),已挂阻塞等人处理:${message}`
|
|
335
|
+
: `AI 第 ${next} 次失败,${Math.max(1, Math.round(plan.delayMs / 1000))} 秒后自动重试:${message}`,
|
|
336
|
+
}]
|
|
337
|
+
if (plan.exhausted) found.blocked = { reason: `AI 连续 ${next} 次失败:${message}`, at: now, by }
|
|
338
|
+
found.updatedAt = now
|
|
339
|
+
})
|
|
340
|
+
if (opts.notify !== false) {
|
|
341
|
+
const text = plan.exhausted
|
|
342
|
+
? `【AI 失败·已升级】${task.title}\n第 ${next} 次失败:${message}\n已挂起等人处理,修好后点「重新派单」`
|
|
343
|
+
: `【AI 失败·将重试】${task.title}\n第 ${next} 次失败:${message}\n${Math.max(1, Math.round(plan.delayMs / 1000))} 秒后自动重试(上限 ${plan.max} 次)`
|
|
344
|
+
host.feishuBroadcast(store.snapshot().config, text).catch(cause => {
|
|
345
|
+
host.INTEGRATION.lastPushError = String(cause?.message ?? cause).slice(0, 300)
|
|
346
|
+
})
|
|
347
|
+
}
|
|
348
|
+
return { failures: next, ...plan, message }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** 重新派单:清掉失败计数、退避时间和系统挂的阻塞。 */
|
|
352
|
+
export async function clearAgentFailure(store, taskId, by = '人') {
|
|
353
|
+
const now = Date.now()
|
|
354
|
+
let changed = false
|
|
355
|
+
await store.mutate(draft => {
|
|
356
|
+
const found = draft.tasks.find(entry => entry.id === taskId)
|
|
357
|
+
if (found === undefined) return
|
|
358
|
+
const wasBlocked = found.blocked !== null && found.blocked !== undefined
|
|
359
|
+
found.agentFailures = 0
|
|
360
|
+
found.retryAt = 0
|
|
361
|
+
if (wasBlocked && String(found.blocked?.by ?? '') === '系统') found.blocked = null
|
|
362
|
+
found.history = [...(found.history ?? []), { at: now, by, status: found.status, note: '手动清掉 AI 失败计数,重新派单' }]
|
|
363
|
+
found.updatedAt = now
|
|
364
|
+
changed = true
|
|
365
|
+
})
|
|
366
|
+
return changed
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** 多实例部署时的派单租约锁:谁在 ttl 内续上租约,谁负责这一轮派单。 */
|
|
370
|
+
export const INSTANCE_ID = `${process.pid}-${randomBytes(3).toString('hex')}`
|
|
371
|
+
|
|
372
|
+
export function claimDispatchLock(store, ttlMs = 12_000) {
|
|
373
|
+
const db = store?.db ?? null
|
|
374
|
+
if (db === null) return true
|
|
375
|
+
const now = Date.now()
|
|
376
|
+
try {
|
|
377
|
+
db.prepare('INSERT INTO locks (name, owner, at) VALUES (?, ?, ?) ON CONFLICT(name) DO UPDATE SET owner = excluded.owner, at = excluded.at WHERE locks.at < ?')
|
|
378
|
+
.run('dispatch', INSTANCE_ID, now, now - ttlMs)
|
|
379
|
+
const row = db.prepare('SELECT owner FROM locks WHERE name = ?').get('dispatch')
|
|
380
|
+
return row !== undefined && row.owner === INSTANCE_ID
|
|
381
|
+
} catch {
|
|
382
|
+
return true
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function dispatchLockInfo(store) {
|
|
387
|
+
try {
|
|
388
|
+
const row = store?.db?.prepare('SELECT owner, at FROM locks WHERE name = ?').get('dispatch')
|
|
389
|
+
return row === undefined ? null : { owner: row.owner, at: Number(row.at) || 0, mine: row.owner === INSTANCE_ID }
|
|
390
|
+
} catch {
|
|
391
|
+
return null
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export class Dispatcher {
|
|
396
|
+
constructor(ctx, store) {
|
|
397
|
+
this.ctx = ctx
|
|
398
|
+
this.store = store
|
|
399
|
+
this.timer = undefined
|
|
400
|
+
this.running = new Map()
|
|
401
|
+
this.log = []
|
|
402
|
+
this.lastTickAt = 0
|
|
403
|
+
this.lastError = ''
|
|
404
|
+
this.deliveryVia = ''
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** The live config (a card's dispatch policy is read at tick time). */
|
|
408
|
+
config() {
|
|
409
|
+
return this.store.snapshot().config
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Ring-buffer one diagnostic line. */
|
|
413
|
+
note(line) {
|
|
414
|
+
this.log.unshift({ at: Date.now(), line: String(line).slice(0, 400) })
|
|
415
|
+
if (this.log.length > 40) this.log.length = 40
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
start() {
|
|
419
|
+
if (this.timer !== undefined) return
|
|
420
|
+
// 上一次进程结束时正在跑的会话已经不在了,卡上的「AI 处理中」要跟着收掉。
|
|
421
|
+
void this.reconcile()
|
|
422
|
+
this.timer = setInterval(() => {
|
|
423
|
+
this.tick().catch(error => { this.lastError = String(error?.message ?? error) })
|
|
424
|
+
}, 5000)
|
|
425
|
+
this.timer.unref?.()
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** 把上次进程留下的 running 印记改成 stopped,免得看板一直显示「AI 处理中」。 */
|
|
429
|
+
async reconcile() {
|
|
430
|
+
const state = this.store.snapshot()
|
|
431
|
+
for (const task of state.tasks) {
|
|
432
|
+
if (task.dispatch?.state !== 'running') continue
|
|
433
|
+
await markDispatch(this.store, task.id, { ...task.dispatch, state: 'stopped', at: Date.now() })
|
|
434
|
+
this.note(`清掉上次进程留下的派单印记 ${task.id}`)
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
stop() {
|
|
439
|
+
if (this.timer !== undefined) clearInterval(this.timer)
|
|
440
|
+
this.timer = undefined
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Drop a card from the running set once its agent goes idle. */
|
|
444
|
+
settle(sessionId) {
|
|
445
|
+
for (const [slot, info] of this.running) {
|
|
446
|
+
if (info.sessionId !== sessionId) continue
|
|
447
|
+
this.running.delete(slot)
|
|
448
|
+
markDispatch(this.store, info.taskId, { ...info, state: 'idle', at: Date.now() }).catch(() => {})
|
|
449
|
+
settleRun(sessionId)
|
|
450
|
+
this.note(`${sessionId} went idle`)
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Is any expert already working this card? */
|
|
456
|
+
busy(taskId) {
|
|
457
|
+
for (const info of this.running.values()) if (info.taskId === taskId) return true
|
|
458
|
+
return false
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Stop whatever an expert is doing on this card (still keeps its output). */
|
|
462
|
+
abortTask(taskId, expertId) {
|
|
463
|
+
let stopped = 0
|
|
464
|
+
for (const [slot, info] of [...this.running]) {
|
|
465
|
+
if (info.taskId !== taskId) continue
|
|
466
|
+
if (typeof expertId === 'string' && expertId !== '' && info.expert !== expertId) continue
|
|
467
|
+
this.running.delete(slot)
|
|
468
|
+
markDispatch(this.store, info.taskId, { ...info, state: 'stopped', at: Date.now() }).catch(() => {})
|
|
469
|
+
try { this.ctx.agents.get(info.sessionId)?.abort?.() } catch { /* best effort */ }
|
|
470
|
+
this.note(`${info.sessionId} aborted`)
|
|
471
|
+
stopped += 1
|
|
472
|
+
}
|
|
473
|
+
return stopped
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** 缺陷卡超过 SLA 还没关:记一次升级并推飞书,避免一直躺着没人管。 */
|
|
477
|
+
async escalateOverdue() {
|
|
478
|
+
const state = this.store.snapshot()
|
|
479
|
+
const now = Date.now()
|
|
480
|
+
for (const task of state.tasks) {
|
|
481
|
+
const sla = host.bugSla(task, state.config)
|
|
482
|
+
if (sla === null || sla.state !== 'overdue') continue
|
|
483
|
+
if ((Number(task.escalatedAt) || 0) > 0) continue
|
|
484
|
+
const overdueHours = Math.max(1, Math.round(-sla.remaining / 3_600_000))
|
|
485
|
+
await this.store.mutate(draft => {
|
|
486
|
+
const found = draft.tasks.find(item => item.id === task.id)
|
|
487
|
+
if (found === undefined) return
|
|
488
|
+
found.escalatedAt = now
|
|
489
|
+
found.history = [...(found.history ?? []), { at: now, status: found.status, by: '系统', note: `SLA 超时 ${overdueHours} 小时,已升级提醒` }]
|
|
490
|
+
})
|
|
491
|
+
await host.feishuBroadcast(
|
|
492
|
+
this.store.snapshot().config,
|
|
493
|
+
`【SLA 超时】${task.code || task.id} ${task.title}\n严重度 ${String(sla.severity).toUpperCase()},超时约 ${overdueHours} 小时`,
|
|
494
|
+
'sla',
|
|
495
|
+
).catch(() => undefined)
|
|
496
|
+
this.note(`SLA 超时升级 ${task.code || task.id}`)
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Diagnostics for the panel and for debugging the agent APIs. */
|
|
501
|
+
status() {
|
|
502
|
+
const cfg = this.store.snapshot().config.agents
|
|
503
|
+
return {
|
|
504
|
+
enabled: cfg.enabled,
|
|
505
|
+
maxConcurrent: cfg.maxConcurrent,
|
|
506
|
+
autoStatuses: cfg.autoStatuses,
|
|
507
|
+
workspaceDir: cfg.workspaceDir,
|
|
508
|
+
agentPreset: cfg.agentPreset,
|
|
509
|
+
sandbox: cfg.sandbox ?? 'workspace-write',
|
|
510
|
+
isolation: cfg.isolation ?? 'none',
|
|
511
|
+
worktreeRoot: worktreeRoot(this.store.snapshot().config),
|
|
512
|
+
cleanupWorktree: cfg.cleanupWorktree ?? 'manual',
|
|
513
|
+
running: [...this.running.values()].map(info => ({ ...info })),
|
|
514
|
+
lastTickAt: this.lastTickAt,
|
|
515
|
+
lastError: this.lastError,
|
|
516
|
+
deliveryVia: this.deliveryVia,
|
|
517
|
+
instance: INSTANCE_ID,
|
|
518
|
+
lock: dispatchLockInfo(this.store),
|
|
519
|
+
log: this.log.slice(0, 20),
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** One pass: hand every eligible card to its column owner, up to the cap. */
|
|
524
|
+
async tick() {
|
|
525
|
+
this.lastTickAt = Date.now()
|
|
526
|
+
// 多实例部署时同一张卡只能被一个进程派出去:抢不到租约就跳过这一轮。
|
|
527
|
+
if (!claimDispatchLock(this.store)) return
|
|
528
|
+
await this.escalateOverdue()
|
|
529
|
+
const state = this.store.snapshot()
|
|
530
|
+
const cfg = state.config.agents
|
|
531
|
+
if (!cfg.enabled) return
|
|
532
|
+
// 卡被删掉后,替它跑着的槽位要自己收回来,否则并发名额会被幽灵会话占死。
|
|
533
|
+
const alive = new Set(state.tasks.map(task => task.id))
|
|
534
|
+
for (const [slot, info] of [...this.running.entries()]) {
|
|
535
|
+
if (!alive.has(info.taskId)) {
|
|
536
|
+
this.note(`drop ghost slot ${slot} (${info.taskId} 已删除)`)
|
|
537
|
+
this.abortTask(info.taskId, info.expert)
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
const limit = Math.max(1, Math.min(8, Number(cfg.maxConcurrent) || 2))
|
|
541
|
+
const auto = new Set(Array.isArray(cfg.autoStatuses) ? cfg.autoStatuses : [])
|
|
542
|
+
// 完成的卡按策略回收它的 worktree(manual 就留着等人看 diff)。
|
|
543
|
+
if (String(cfg.cleanupWorktree ?? 'manual') === 'on_done') {
|
|
544
|
+
for (const task of state.tasks) {
|
|
545
|
+
if (task.status !== 'done' || task.worktree === null || task.worktree === undefined) continue
|
|
546
|
+
const result = await host.removeWorktree(this.store, task, state.config)
|
|
547
|
+
this.note(`cleanup worktree ${task.code || task.id}: ${result.ok ? 'removed' : (result.error ?? result.skipped ?? 'failed')}`)
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
for (const task of state.tasks) {
|
|
551
|
+
if (this.running.size >= limit) break
|
|
552
|
+
if (this.busy(task.id)) continue
|
|
553
|
+
// 阻塞的卡停在原地等人处理,不自动往下派。
|
|
554
|
+
if (task.blocked !== null && task.blocked !== undefined) continue
|
|
555
|
+
// 上次失败还在退避窗口里:到点再试,别每 5 秒硬撞。
|
|
556
|
+
if ((Number(task.retryAt) || 0) > Date.now()) continue
|
|
557
|
+
if (!auto.has(task.status)) continue
|
|
558
|
+
const column = STATUSES.find(entry => entry.id === task.status)
|
|
559
|
+
if (column === undefined) continue
|
|
560
|
+
// 这一列没人负责(或没匹配到领域专家)就停着,等人指派,不算失败。
|
|
561
|
+
if (ownerFor(state.config, task, column, this.loadByExpert(), state) === undefined) continue
|
|
562
|
+
// One hand-off per (card, column): after that the card waits for a human
|
|
563
|
+
// or for the agent to move it on. "马上处理" is the way to run it again.
|
|
564
|
+
if (task.dispatch?.status === task.status) continue
|
|
565
|
+
const key = `${task.id}:${task.status}`
|
|
566
|
+
await this.launch(task, column, key).then(async () => {
|
|
567
|
+
// 派单成功:把失败计数清掉,避免历史失败影响下一次判断。
|
|
568
|
+
if ((Number(task.agentFailures) || 0) > 0) {
|
|
569
|
+
await this.store.mutate(draft => {
|
|
570
|
+
const found = draft.tasks.find(entry => entry.id === task.id)
|
|
571
|
+
if (found === undefined) return
|
|
572
|
+
found.agentFailures = 0
|
|
573
|
+
found.retryAt = 0
|
|
574
|
+
})
|
|
575
|
+
}
|
|
576
|
+
}).catch(async error => {
|
|
577
|
+
this.lastError = String(error?.message ?? error).slice(0, 400)
|
|
578
|
+
this.note(`launch ${task.id} failed: ${this.lastError}`)
|
|
579
|
+
await markDispatch(this.store, task.id, { key, status: task.status, state: 'failed', error: this.lastError, at: Date.now() })
|
|
580
|
+
// 失败要退避重试;连续失败超过上限就升级成人处理。
|
|
581
|
+
await noteAgentFailure(this.store, task.id, error).catch(() => {})
|
|
582
|
+
})
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Force one card through the same path, used by the panel's "马上处理" button. */
|
|
587
|
+
async launchNow(id, expertId) {
|
|
588
|
+
const task = this.store.snapshot().tasks.find(entry => entry.id === id)
|
|
589
|
+
if (task === undefined) throw new Error(`no task ${id}`)
|
|
590
|
+
const column = STATUSES.find(entry => entry.id === task.status) ?? STATUSES[0]
|
|
591
|
+
const key = `${task.id}:${task.status}:manual:${Date.now()}`
|
|
592
|
+
if (typeof expertId === 'string' && expertId.length > 0) {
|
|
593
|
+
await this.launch(task, column, key, { owner: { kind: 'expert', id: expertId } })
|
|
594
|
+
return
|
|
595
|
+
}
|
|
596
|
+
await this.launch(task, column, key)
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Hand a card to whoever a comment just @mentioned. */
|
|
600
|
+
async dispatchMention(taskId, mention, instruction) {
|
|
601
|
+
const task = this.store.snapshot().tasks.find(entry => entry.id === taskId)
|
|
602
|
+
if (task === undefined) throw new Error(`no task ${taskId}`)
|
|
603
|
+
const column = STATUSES.find(entry => entry.id === task.status) ?? STATUSES[0]
|
|
604
|
+
await this.launch(task, column, `${task.id}:${task.status}:mention:${Date.now()}`, {
|
|
605
|
+
owner: { kind: mention.kind, id: mention.id },
|
|
606
|
+
instruction,
|
|
607
|
+
})
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** 每个专家手上正在跑几张卡——轮询策略靠它挑人。 */
|
|
611
|
+
loadByExpert() {
|
|
612
|
+
const map = new Map()
|
|
613
|
+
for (const info of this.running.values()) {
|
|
614
|
+
const key = info.expert ?? ''
|
|
615
|
+
if (key.length === 0) continue
|
|
616
|
+
map.set(key, (map.get(key) ?? 0) + 1)
|
|
617
|
+
}
|
|
618
|
+
return map
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async launch(task, column, key, extra = {}) {
|
|
622
|
+
const config = this.store.snapshot().config
|
|
623
|
+
const cfg = config.agents
|
|
624
|
+
const resolved = extra.owner !== undefined
|
|
625
|
+
? resolveOwner(config, extra.owner)
|
|
626
|
+
: ownerFor(config, task, column, this.loadByExpert(), this.store.snapshot())
|
|
627
|
+
if (resolved === undefined) {
|
|
628
|
+
throw new Error(`「${column.label}」这一列还没人负责:在卡上选一个专家,或到「设置 → 列的负责人」里指定`)
|
|
629
|
+
}
|
|
630
|
+
const expert = resolved.expert
|
|
631
|
+
const sessionId = `studio-${task.id}-${expert.id}`
|
|
632
|
+
const slot = `${task.id}#${expert.id}`
|
|
633
|
+
this.running.set(slot, { taskId: task.id, sessionId, expert: expert.id, team: resolved.team?.id ?? '', at: Date.now(), state: 'running' })
|
|
634
|
+
try {
|
|
635
|
+
const agent = await this.ensureSession(sessionId, task, expert, cfg)
|
|
636
|
+
await markDispatch(this.store, task.id, {
|
|
637
|
+
key,
|
|
638
|
+
status: task.status,
|
|
639
|
+
expert: expert.id,
|
|
640
|
+
team: resolved.team?.id ?? '',
|
|
641
|
+
sessionId,
|
|
642
|
+
state: 'running',
|
|
643
|
+
at: Date.now(),
|
|
644
|
+
})
|
|
645
|
+
const requirement = this.store.snapshot().requirements.find(entry => entry.id === task.requirementId)
|
|
646
|
+
// 团级记忆:这个团最近经手过什么,接活前先看一眼。
|
|
647
|
+
let teamMemory = []
|
|
648
|
+
if (resolved.team !== undefined) {
|
|
649
|
+
const memory = await loadTeamMemory(this.store).catch(() => ({}))
|
|
650
|
+
teamMemory = Array.isArray(memory[resolved.team.id]) ? memory[resolved.team.id] : []
|
|
651
|
+
}
|
|
652
|
+
const prompt = buildExpertPrompt(task, expert, column, config, resolved.team, extra.instruction, requirement, this.store.snapshot(), teamMemory)
|
|
653
|
+
const via = await this.deliver(agent, sessionId, prompt)
|
|
654
|
+
this.deliveryVia = via
|
|
655
|
+
this.lastError = ''
|
|
656
|
+
recordRun({
|
|
657
|
+
taskId: task.id,
|
|
658
|
+
taskCode: task.code || task.id,
|
|
659
|
+
sessionId,
|
|
660
|
+
expert: expert.id,
|
|
661
|
+
expertName: expert.name,
|
|
662
|
+
model: String(this.modelFor(expert)?.model ?? ''),
|
|
663
|
+
promptVersion: String(expert.promptVersion ?? ''),
|
|
664
|
+
})
|
|
665
|
+
this.note(`dispatched ${task.id} → ${expert.name} via ${via}`)
|
|
666
|
+
} catch (error) {
|
|
667
|
+
this.running.delete(slot)
|
|
668
|
+
throw error
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Open the card's Session through the Agent factory. `agents.create` starts
|
|
674
|
+
* the driver loop, so a followup message actually wakes a turn; the session
|
|
675
|
+
* controller alone only records the conversation.
|
|
676
|
+
*/
|
|
677
|
+
async ensureSession(sessionId, task, expert, cfg) {
|
|
678
|
+
const ctx = this.ctx
|
|
679
|
+
const live = ctx.agents.get(sessionId)
|
|
680
|
+
if (live !== undefined) return live
|
|
681
|
+
const base = (cfg.workspaceDir ?? '').trim() || join(homedir(), 'dsh-workspace')
|
|
682
|
+
// 一卡一 worktree:配了 project.repoPath 且 isolation=worktree 时,这个会话
|
|
683
|
+
// 只看得到自己那张卡的分支,互不踩文件。
|
|
684
|
+
let worktree
|
|
685
|
+
try {
|
|
686
|
+
worktree = await host.ensureWorktree(this.store, task, this.config())
|
|
687
|
+
} catch (error) {
|
|
688
|
+
this.note(`worktree: ${error?.message ?? error}`)
|
|
689
|
+
}
|
|
690
|
+
if (worktree?.skipped !== undefined) this.note(`worktree skipped: ${worktree.skipped}`)
|
|
691
|
+
const dir = worktree?.path !== undefined ? worktree.path : base
|
|
692
|
+
await mkdir(dir, { recursive: true })
|
|
693
|
+
try {
|
|
694
|
+
await ctx.workspaceRegistry.create(dir, '需求&任务工作空间')
|
|
695
|
+
} catch (error) {
|
|
696
|
+
this.note(`workspace: ${error?.message ?? error}`)
|
|
697
|
+
}
|
|
698
|
+
let agent
|
|
699
|
+
// A session created by the factory carries no model unless we hand one in;
|
|
700
|
+
// without it every turn ends with "agent has no provider/model".
|
|
701
|
+
const agentOptions = this.modelFor(expert)
|
|
702
|
+
// `meta.agentPreset` only snapshots the name — the tools/persona arrive
|
|
703
|
+
// through the factory's setup callback, which mounts the preset into the
|
|
704
|
+
// new agent's scope. Without it the card agent sees no shell and no fs.
|
|
705
|
+
const composition = await this.composeSession(expert, cfg)
|
|
706
|
+
const options = {
|
|
707
|
+
sessionId,
|
|
708
|
+
meta: { cwd: dir, ...(composition !== undefined ? { agentPreset: composition.agentPreset } : {}) },
|
|
709
|
+
...(composition !== undefined ? { setup: composition.setup } : {}),
|
|
710
|
+
...(agentOptions !== undefined ? { agentOptions } : {}),
|
|
711
|
+
}
|
|
712
|
+
try {
|
|
713
|
+
agent = (await ctx.agents.create(options)).agent
|
|
714
|
+
} catch (error) {
|
|
715
|
+
this.note(`create fell back to resume: ${error?.message ?? error}`)
|
|
716
|
+
agent = (await ctx.agents.resume({
|
|
717
|
+
resumeSessionId: sessionId,
|
|
718
|
+
...(composition !== undefined ? { setup: composition.setup } : {}),
|
|
719
|
+
...(agentOptions !== undefined ? { agentOptions } : {}),
|
|
720
|
+
})).agent
|
|
721
|
+
}
|
|
722
|
+
// 沙箱档位:专家自己的设置优先,否则跟随全局。解析不出可用档位时落到
|
|
723
|
+
// workspace-write(安全默认)——绝不因为「没配」而悄悄全开。
|
|
724
|
+
const wanted = String(expert?.sandbox ?? '').trim() || String(cfg.sandbox ?? '').trim()
|
|
725
|
+
let sandbox = SANDBOX_MODES.includes(wanted) ? wanted : 'workspace-write'
|
|
726
|
+
if (!SANDBOX_MODES.includes(wanted)) this.note(`sandbox "${wanted}" 不认识,按 workspace-write 执行`)
|
|
727
|
+
try {
|
|
728
|
+
ctx.permissionPresets.set(agent.session, sandbox)
|
|
729
|
+
} catch (error) {
|
|
730
|
+
this.note(`permission preset ${sandbox} failed: ${error?.message ?? error}`)
|
|
731
|
+
}
|
|
732
|
+
try {
|
|
733
|
+
await ctx.sessionController.rename({ sessionId, title: `[${expert.name}] ${task.title}`.slice(0, 120) })
|
|
734
|
+
} catch (error) {
|
|
735
|
+
this.note(`rename failed: ${error?.message ?? error}`)
|
|
736
|
+
}
|
|
737
|
+
return agent
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Resolve the role's agent preset and return the composition the factory
|
|
742
|
+
* needs: the preset id for the session header, plus a setup callback that
|
|
743
|
+
* mounts its tools and persona into the new agent's scope.
|
|
744
|
+
*/
|
|
745
|
+
async composeSession(expert, cfg) {
|
|
746
|
+
const presets = this.ctx.get?.('agentPresets')
|
|
747
|
+
if (presets === undefined) return undefined
|
|
748
|
+
const wanted = String(expert.preset ?? '').trim() || cfg.agentPreset || 'standard'
|
|
749
|
+
try {
|
|
750
|
+
const resolved = await presets.resolve(wanted)
|
|
751
|
+
return {
|
|
752
|
+
agentPreset: resolved.id,
|
|
753
|
+
setup: async scope => { await presets.mount(scope, resolved.id) },
|
|
754
|
+
}
|
|
755
|
+
} catch (error) {
|
|
756
|
+
this.note(`preset "${wanted}" unavailable: ${error?.message ?? error}`)
|
|
757
|
+
return undefined
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Model for one role: the settings override ("provider/model" or a bare model
|
|
763
|
+
* id, in which case the host default provider is borrowed), else the host
|
|
764
|
+
* default selection. Returning undefined lets the factory decide.
|
|
765
|
+
*/
|
|
766
|
+
modelFor(expert) {
|
|
767
|
+
const current = this.ctx.agentDefaultModel?.currentSelection?.()
|
|
768
|
+
const configured = String(expert?.model ?? '').trim() || this.store.snapshot().config.roles?.[expert?.id]?.model
|
|
769
|
+
if (typeof configured === 'string' && configured.trim() !== '') {
|
|
770
|
+
const spec = configured.trim()
|
|
771
|
+
const slash = spec.indexOf('/')
|
|
772
|
+
const provider = slash > 0 ? spec.slice(0, slash) : current?.provider
|
|
773
|
+
const model = slash > 0 ? spec.slice(slash + 1) : spec
|
|
774
|
+
if (provider !== undefined && provider !== '') {
|
|
775
|
+
return { provider, model, ...(current?.reasoningEffort ? { reasoningEffort: current.reasoningEffort } : {}) }
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
// 最后兜底不用宿主默认模型:宿主默认可能是当前不可用的型号(deepseek-v4-pro 在
|
|
779
|
+
// distributor 上没有通道,整轮 turn/end 直接 503,表现为「派了但没讨论」)。
|
|
780
|
+
return {
|
|
781
|
+
provider: String(current?.provider ?? 'deepseek-official'),
|
|
782
|
+
model: FALLBACK_AGENT_MODEL,
|
|
783
|
+
...(current?.reasoningEffort ? { reasoningEffort: current.reasoningEffort } : {}),
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/** Diagnostics: which card sessions are live and what the driver thinks. */
|
|
788
|
+
async probePrompt() {
|
|
789
|
+
const live = (this.ctx.agents.list?.() ?? []).map(agent => ({
|
|
790
|
+
id: agent.id,
|
|
791
|
+
status: agent.status,
|
|
792
|
+
}))
|
|
793
|
+
return { live, running: [...this.running.keys()] }
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Deliver the work order. `inject` stages framing without waking the driver,
|
|
798
|
+
* `followup` queues the user turn and wakes it — both must carry a serializable
|
|
799
|
+
* source listed in MessageSourceMap (`user` is the one every build has).
|
|
800
|
+
*/
|
|
801
|
+
async deliver(agent, sessionId, text) {
|
|
802
|
+
const stamp = Date.now()
|
|
803
|
+
agent.inject({
|
|
804
|
+
id: `studio:${sessionId}:${stamp}:frame`,
|
|
805
|
+
role: 'user',
|
|
806
|
+
content: [{ type: 'text', text: '【需求&任务工作空间派单】下面是看板发来的工作指令。完成后必须调用 studio_task_comment 记录结论,并调用 studio_task_update 把卡片推进到下一列。' }],
|
|
807
|
+
source: { kind: 'user' },
|
|
808
|
+
})
|
|
809
|
+
agent.followup({
|
|
810
|
+
id: `studio:${sessionId}:${stamp}:body`,
|
|
811
|
+
role: 'user',
|
|
812
|
+
content: [{ type: 'text', text }],
|
|
813
|
+
source: { kind: 'user' },
|
|
814
|
+
})
|
|
815
|
+
this.deliveryVia = 'followup'
|
|
816
|
+
return 'followup'
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** The work order one expert (or a whole team) receives. */
|
|
821
|
+
export function buildExpertPrompt(task, expert, column, config, team, instruction, requirement, state, teamMemory = []) {
|
|
822
|
+
const recent = (task.comments ?? []).slice(-6).map(entry => `- [${entry.author}] ${entry.body}`).join('\n')
|
|
823
|
+
const members = team === undefined
|
|
824
|
+
? ''
|
|
825
|
+
: (team.members ?? [])
|
|
826
|
+
.map(id => (config.experts ?? []).find(entry => entry.id === id))
|
|
827
|
+
.filter(Boolean)
|
|
828
|
+
.map(entry => `- @${entry.name}:${entry.description || ROLE_BRIEF[entry.id] || '按分工协作'}`)
|
|
829
|
+
.join('\n')
|
|
830
|
+
return [
|
|
831
|
+
`你是「${expert.emoji} ${expert.name}」,需求&任务工作空间里的一名专家。现在接手工作看板上的一张卡。`,
|
|
832
|
+
expert.description ? `\n你的定位:${expert.description}` : '',
|
|
833
|
+
expert.domain ? `\n业务领域:${expert.domain}(判断与取舍以这个领域的规范和风险偏好为准)` : '',
|
|
834
|
+
(expert.skills ?? []).length > 0 ? `\n你可以使用的技能:${(expert.skills ?? []).map(name => {
|
|
835
|
+
const known = activeSkills?.get(name)
|
|
836
|
+
return known === undefined ? name : `${name}(${known.description})`
|
|
837
|
+
}).join('、')}。需要时调用 skill 工具加载对应技能,再按其说明操作。` : '',
|
|
838
|
+
team !== undefined ? `\n你在「${team.name}」专家团里协作,本团成员:\n${members}` : '',
|
|
839
|
+
teamMemoryBlock(teamMemory),
|
|
840
|
+
'',
|
|
841
|
+
`任务号:${task.id}${task.code ? `(${task.code})` : ''}`,
|
|
842
|
+
`标题:${task.title}`,
|
|
843
|
+
(() => {
|
|
844
|
+
if (requirement === undefined) return ''
|
|
845
|
+
const links = requirement.links ?? {}
|
|
846
|
+
const extra = [links.prd ? `PRD ${links.prd}` : '', links.proto ? `原型 ${links.proto}` : '', links.acceptance ? `验收标准 ${links.acceptance}` : ''].filter(Boolean).join(';')
|
|
847
|
+
return `所属需求:${requirement.code || requirement.id}|${requirement.title}${extra ? `(${extra})` : ''}`
|
|
848
|
+
})(),
|
|
849
|
+
`当前列:${column.label}`,
|
|
850
|
+
`优先级:${task.priority}`,
|
|
851
|
+
task.project ? `所属项目:${(config.projects ?? []).find(entry => entry.id === task.project)?.name ?? task.project}${task.iteration ? ` · 迭代 ${(config.projects ?? []).find(entry => entry.id === task.project)?.iterations?.find(iter => iter.id === task.iteration)?.name ?? task.iteration}` : ''}` : '',
|
|
852
|
+
task.dueAt > 0 ? `期望完成:${new Date(task.dueAt).toISOString().slice(0, 10)}` : '',
|
|
853
|
+
(() => {
|
|
854
|
+
const service = host.serviceOf(config, task)
|
|
855
|
+
if (service === undefined) return ''
|
|
856
|
+
const hasWorktree = task.worktree !== null && task.worktree !== undefined
|
|
857
|
+
if (hasWorktree) return `服务:${service.name}(worktree 见下,别在服务主目录里改)`
|
|
858
|
+
const base = String(service.baseBranch ?? '').trim() || 'main'
|
|
859
|
+
return `服务:${service.name}${service.repoPath ? `(本机代码目录 ${service.repoPath},基线分支 ${base})` : '(这个服务还没配本机代码目录)'}`
|
|
860
|
+
})(),
|
|
861
|
+
(() => {
|
|
862
|
+
const service = host.serviceOf(config, task)
|
|
863
|
+
const project = host.workProjectOf(config, task)
|
|
864
|
+
if (service === undefined || project === undefined) return ''
|
|
865
|
+
const names = (service.dependsOn ?? [])
|
|
866
|
+
.map(id => (project.services ?? []).find(one => one.id === id)?.name)
|
|
867
|
+
.filter(name => name !== undefined)
|
|
868
|
+
if (names.length === 0) return ''
|
|
869
|
+
return `本服务依赖:${names.join('、')}(这些服务的改动先落地;如果本卡要改它们依赖的接口,先在评论里写清兼容方案,别直接改协议)`
|
|
870
|
+
})(),
|
|
871
|
+
(() => {
|
|
872
|
+
const conflict = host.serviceConflicts(state ?? { config, tasks: [task] }, task)
|
|
873
|
+
if (conflict === undefined) return ''
|
|
874
|
+
return `同服务并行:这个服务上还有 ${conflict.count} 张卡在改(${conflict.tasks.map(item => item.code).join('、')})。只改本卡范围,动公共文件前先看别人的分支,必要时在评论里打招呼,别把别人的改动顶掉。`
|
|
875
|
+
})(),
|
|
876
|
+
task.owner ? `业务负责人:${task.owner}` : '',
|
|
877
|
+
task.expert ? `当前指派专家:${task.expert}` : '',
|
|
878
|
+
(task.checklist ?? []).length > 0
|
|
879
|
+
? `\n这张卡的验收清单(做完一项就勾一项,没做完别勾):\n${(task.checklist ?? []).map(item => `${item.done ? '☑' : '☐'} ${item.id} ${item.text}${item.note ? `(${item.note})` : ''}`).join('\n')}`
|
|
880
|
+
: '',
|
|
881
|
+
(task.artifacts ?? []).length > 0
|
|
882
|
+
? `\n已登记的交付物:\n${(task.artifacts ?? []).map(item => `- [${item.kind}] ${item.title}${item.ref ? ` → ${item.ref}` : ''}(${item.status})`).join('\n')}`
|
|
883
|
+
: '',
|
|
884
|
+
(task.cases ?? []).length > 0
|
|
885
|
+
? `\n已登记的测试用例:\n${(task.cases ?? []).map(item => `- ${item.result === 'pass' ? '✅' : item.result === 'fail' ? '❌' : item.result === 'skip' ? '⏭️' : '⬜'} ${item.id} ${item.title}${item.expected ? `(期望:${item.expected})` : ''}${item.note ? ` — ${item.note}` : ''}`).join('\n')}`
|
|
886
|
+
: '',
|
|
887
|
+
(task.gitlabMR?.iid ?? 0) > 0 ? `\n关联 MR:!${task.gitlabMR.iid} ${task.gitlabMR.title}(${task.gitlabMR.state})${task.gitlabMR.url ? ` ${task.gitlabMR.url}` : ''}` : '',
|
|
888
|
+
task.coding !== null && task.coding !== undefined && codingSummary(task.coding) !== ''
|
|
889
|
+
? `\n这一棒的 AI Coding 执行记录:${codingSummary(task.coding)}${task.coding.branch ? `(分支 ${task.coding.branch})` : ''}${task.coding.review.status === 'changes_requested' && task.coding.review.note ? `\n评审意见:${task.coding.review.note}` : ''}`
|
|
890
|
+
: '',
|
|
891
|
+
task.reqBaseline ? `\n这张卡是按需求基线 ${task.reqBaseline.version}(${task.reqBaseline.title})开的,改动要对着这一版。` : '',
|
|
892
|
+
task.worktree !== null && task.worktree !== undefined
|
|
893
|
+
? `\n你的工作目录是这张卡专属的 git worktree:${task.worktree.path}${task.worktree.serviceName ? `(服务:${task.worktree.serviceName})` : ''}\n分支 ${task.worktree.branch}(基线 ${task.worktree.base}),仓库 ${task.worktree.repo}。只在这个目录里改代码,提交到这条分支,不要动别人的 worktree 或主分支。`
|
|
894
|
+
: '',
|
|
895
|
+
column.id === 'dev' || column.id === 'dev_ready'
|
|
896
|
+
? `\n这是研发环节,做完必须留下可审计的执行记录:调用 studio_task_coding,如实填写 agent/model、分支、commits(sha + message + 改动文件数)、改动文件清单、单测总数与通过数,以及你依据的需求基线版本。没登记执行记录的「完成」不算完成。`
|
|
897
|
+
: '',
|
|
898
|
+
column.id === 'review'
|
|
899
|
+
? `\n这是评审环节:先读 MR 的改动(glab mr diff 或在 GitLab 上看),逐条对照方案、验收清单和执行记录里的 files / commits / 单测结果,把问题写清楚(文件、行、为什么、建议怎么改)。评完用 studio_task_coding action=review 写结论(approved / changes_requested + 说明);没问题就说明通过理由,推进到「待测试」,有问题就 @ 开发同学,把卡退回「开发中」。`
|
|
900
|
+
: '',
|
|
901
|
+
task.requirement ? `\n需求描述:\n${task.requirement}` : '',
|
|
902
|
+
column.id === 'review'
|
|
903
|
+
? `\n这是评审环节:先读 MR 的改动(glab mr diff 或在 GitLab 上看),逐条对照方案与验收清单,把问题写清楚(文件、行、为什么、建议怎么改)。没问题就说明通过理由,推进到「待测试」;有问题就 @ 开发同学,把卡退回「开发中」。`
|
|
904
|
+
: '',
|
|
905
|
+
column.id === 'test'
|
|
906
|
+
? `\n这是测试环节:先用 studio_task_case 把用例登记到卡上(做什么、期望什么);**能写成接口调用的,就把 method/url/expectStatus/expectContains 一起写上,用 studio_test_run 让平台真的去跑**(它会发请求、比对状态码与响应体、把结果写回用例并留下执行记录,失败的自动开缺陷卡)。只有跑不了的用例(要人工操作、要看 UI、要连设备)才自己执行并用 result 回填 pass/fail/skip,失败要写清环境与复现步骤。**用例一旦判 fail,平台会自动开一张缺陷卡(挂同一需求、父任务指向本卡),你不用再手工建 Bug,也不要重复建**。全部用例有结论后:有用例失败就 @ 开发同学退回「开发中」;全通过就推进到「待验收」,并登记一份测试报告(kind=cases)。` +
|
|
907
|
+
(task.bugSource == null ? '' : `\n这张卡来自缺陷 ${task.bugSource.code || task.bugSource.taskId}(用例「${task.bugSource.caseTitle}」)。修复已进入待测试,平台把来源用例重置成了待执行:请复跑这一条,确认通过后把本卡推进到「待验收」,并通过就把缺陷关闭。`)
|
|
908
|
+
: '',
|
|
909
|
+
task.type === 'bug'
|
|
910
|
+
? `\n这是一张缺陷卡(严重级别 ${String(task.severity || 'p2').toUpperCase()}),不是新需求,处理方式不同:
|
|
911
|
+
- 先复现:写清触发条件、环境、实际结果与期望结果;复现不了就说明缺什么信息。
|
|
912
|
+
- 再定位根因:指出是哪一层 / 哪个模块 / 哪次改动导致的,能用 git log、git blame 佐证的贴出来。
|
|
913
|
+
- 修复最小化,只动与缺陷相关的代码;评论里列出改动文件、影响范围、回归点。
|
|
914
|
+
- 修完推进到「待测试」,并把复现步骤和回归清单贴给测试同学。`
|
|
915
|
+
: '',
|
|
916
|
+
task.gitlabIssue ? `\nGitLab Issue:${task.gitlabIssue}` : '',
|
|
917
|
+
task.blockedReason ? `\n上次阻塞原因:${task.blockedReason}` : '',
|
|
918
|
+
recent ? `\n最近评论:\n${recent}` : '',
|
|
919
|
+
instruction ? `\n有人刚刚在看板上 @ 了你:\n${instruction}` : '',
|
|
920
|
+
'',
|
|
921
|
+
expert.prompt ? `工作要求(来自管理员配置):\n${expert.prompt}` : `你的职责:${ROLE_BRIEF[expert.id] ?? '按卡上的要求推进工作。'}`,
|
|
922
|
+
'',
|
|
923
|
+
'工作方式:',
|
|
924
|
+
`1. 先用 studio_task_get 读取这张卡的完整内容(id=${task.id})。`,
|
|
925
|
+
'2. 需要改代码时在工作目录内操作,改动要小步、可验证。',
|
|
926
|
+
'3. 每一步结束用 studio_task_comment 留下证据:结论、改动文件、执行过的命令与结果。',
|
|
927
|
+
` 调用时 author 一律填你自己的名字「${expert.name}」,看板靠它把人和 AI 的发言分开显示。`,
|
|
928
|
+
'4. 产出物要登记:文档/MR/用例/报告用 studio_task_artifact 记一条(kind + title + ref + content 正文),系统会把正文写成飞书云盘「需求文件夹」下的 Markdown 文档;同时把要点写进 studio_task_update 的 artifacts 字段。',
|
|
929
|
+
'5. 做完一项就 studio_task_check 勾一项(附证据),清单全勾完才算这一步真的完了。',
|
|
930
|
+
`6. 完成后用 studio_task_update 把状态推进到下一列(当前列是「${column.label}」);真的推进不了就调 studio_task_block 写清楚卡在哪,别自己硬凑。`,
|
|
931
|
+
team !== undefined
|
|
932
|
+
? '7. 需要团里其他专家配合时,在评论里写 @对方名字 并写清要他做什么,看板会把活儿派给他。'
|
|
933
|
+
: '7. 只处理这一张卡,不要顺手改别的卡;做完就停,不要空转。',
|
|
934
|
+
].filter(line => line !== '').join('\n')
|
|
935
|
+
}
|
|
936
|
+
|