dsh-card-dispatch 0.1.0 → 0.1.1

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/src/split.js CHANGED
@@ -25,13 +25,31 @@
25
25
  * 不会出现「模型自己编了五个任务」这种不可解释的账单。
26
26
  */
27
27
 
28
- import { randomUUID } from 'node:crypto'
28
+ import { createHash } from 'node:crypto'
29
29
 
30
30
  import { verdictPath } from './verdict.js'
31
31
 
32
32
  /** 单卡 prompt 里判据条目的前缀(子卡只列自己那组)。 */
33
33
  const CRITERIA_HEADING = '验收判据(完成后逐条自证):'
34
34
 
35
+ /** 判据条数上限:再多人也看不过来,prompt 也会被撑爆。 */
36
+ export const MAX_CRITERIA = 30
37
+
38
+ /** 单条判据字数上限:一条判据写成一段话,验收就没法逐条核对。 */
39
+ export const MAX_CRITERIA_TEXT = 500
40
+
41
+ /** 同一批卡的 id 幂等窗口:10 分钟内用同样输入再点一次,写的是同一批卡。 */
42
+ const CREATE_IDEMPOTENCY_MS = 10 * 60 * 1000
43
+
44
+ /** 运行信封的幂等窗口:短一些,避免「想重跑」被当成重复点击。 */
45
+ const RUN_IDEMPOTENCY_MS = 5 * 60 * 1000
46
+
47
+ /** 验收卡等报告的上限:开发卡迟迟不结束才有意义,正常交付时报告一出现就开始验收。 */
48
+ export const VERIFY_WAIT_MINUTES = 120
49
+
50
+ /** 等待期间的轮询间隔:太短烧上下文,太长验收反应慢。 */
51
+ export const VERIFY_POLL_SECONDS = 60
52
+
35
53
  /** 交付要求:三类模型都会照做,不依赖具体仓库结构。 */
36
54
  const DELIVERY_RULES = [
37
55
  '先读相关代码再动手,改动尽量小而且能用。',
@@ -42,51 +60,97 @@ const DELIVERY_RULES = [
42
60
 
43
61
  /** 证据要求:只有开了交付门槛才写进 prompt(关掉 = 省钱,也放弃了可复核性)。 */
44
62
  const EVIDENCE_RULES = [
63
+ '开工前先删掉这张卡上一轮的 `out/<本卡 id>/report.md`;报告只在最后一步写——复验时验收卡才不会把上一轮的旧报告当成本轮证据。',
45
64
  '把交付报告写到 `out/<本卡 id>/report.md`(相对工作区根目录)。',
46
65
  '报告里逐条写:判据 id、期望、实际、证据(命令输出 / 测试名 / 文件路径)。',
47
66
  '带上本次改动的 commit sha;没有 commit 就写 `none` 并说明为什么。',
48
67
  '没有报告或报告缺条目,这张卡就不算完成——验收卡会据此打回。',
49
68
  ]
50
69
 
70
+ function criteriaId(sequence) {
71
+ return `C-${sequence < 10 ? `0${sequence}` : sequence}`
72
+ }
73
+
51
74
  /**
52
- * 判据归一化。
75
+ * 判据归一化(带警告)。
53
76
  *
54
77
  * 人只会写正文(「能算加减法」),但看板/台账的判据 id 有格式要求,
55
78
  * 所以没写编号就按出现顺序补 `C-01`、`C-02`…;写了编号就沿用并接着往下编。
56
79
  *
80
+ * 三道收敛(`/split` 可被直接 curl,服务端必须自己扛住):
81
+ * - 超过 `MAX_CRITERIA` 条只收前面的;
82
+ * - 单条超过 `MAX_CRITERIA_TEXT` 字截断;
83
+ * - 编号重复时重新编号,否则验收按 `criteriaId` 归卡会歧义。
84
+ *
57
85
  * @param {Array<string|{id?:string,text:string}>} criteria
58
- * @returns {Array<{id:string, text:string}>}
86
+ * @returns {{criteria:Array<{id:string,text:string}>, warnings:Array<string>}}
59
87
  */
60
- export function normalizeCriteria(criteria = []) {
88
+ export function normalizeCriteriaDetailed(criteria = []) {
61
89
  let sequence = 0
90
+ const used = new Set()
62
91
  const out = []
92
+ const warnings = []
93
+ let clipped = 0
94
+ let dropped = 0
95
+ let renumbered = 0
96
+
97
+ const nextId = () => {
98
+ sequence += 1
99
+ while (used.has(criteriaId(sequence))) sequence += 1
100
+ return criteriaId(sequence)
101
+ }
102
+
63
103
  for (const raw of criteria) {
104
+ if (out.length >= MAX_CRITERIA) {
105
+ dropped += 1
106
+ continue
107
+ }
64
108
  const isObject = raw !== null && typeof raw === 'object'
65
- const text = String((isObject ? raw.text : raw) ?? '').trim()
109
+ let text = String((isObject ? raw.text : raw) ?? '').trim()
66
110
  if (text === '') continue
67
111
  const rawId = isObject ? String(raw.id ?? '').trim() : ''
68
112
  let id = /^C-\d{2,}$/.test(rawId) ? rawId : ''
69
- let body = text
70
113
  if (id !== '') {
71
- sequence = Number.parseInt(id.slice(2), 10) || sequence
114
+ sequence = Math.max(sequence, Number.parseInt(id.slice(2), 10) || 0)
72
115
  } else {
73
116
  // 字符串形态里可能自带编号(「C-05 自定义编号」),解析出来并让后续编号接着走。
74
117
  // 页面那边会先解析一遍,但 /split 是可直接 curl 的,服务端必须自己扛得住。
75
118
  const explicit = /^(C-\d{2,})[\s::、.\-—]*(.*)$/.exec(text)
76
119
  if (explicit) {
77
120
  id = explicit[1]
78
- body = explicit[2].trim()
79
- sequence = Number.parseInt(id.slice(2), 10) || sequence
121
+ text = explicit[2].trim()
122
+ sequence = Math.max(sequence, Number.parseInt(id.slice(2), 10) || 0)
80
123
  }
81
124
  }
82
- if (body === '') continue
125
+ if (text === '') continue
83
126
  if (id === '') {
84
- sequence += 1
85
- id = `C-${sequence < 10 ? `0${sequence}` : sequence}`
127
+ id = nextId()
128
+ } else if (used.has(id)) {
129
+ id = nextId()
130
+ renumbered += 1
131
+ }
132
+ used.add(id)
133
+ if (text.length > MAX_CRITERIA_TEXT) {
134
+ text = `${text.slice(0, MAX_CRITERIA_TEXT - 1)}…`
135
+ clipped += 1
86
136
  }
87
- out.push({ id, text: body })
137
+ out.push({ id, text })
88
138
  }
89
- return out
139
+
140
+ if (dropped > 0) warnings.push(`判据超过 ${MAX_CRITERIA} 条,后面的 ${dropped} 条没有收进来`)
141
+ if (clipped > 0) warnings.push(`有 ${clipped} 条判据超过 ${MAX_CRITERIA_TEXT} 字,已截断`)
142
+ if (renumbered > 0) warnings.push(`有 ${renumbered} 条判据编号重复,已重新编号`)
143
+ return { criteria: out, warnings }
144
+ }
145
+
146
+ /**
147
+ * 判据归一化(只要结果,不要警告)。
148
+ *
149
+ * @param {Array<string|{id?:string,text:string}>} criteria
150
+ * @returns {Array<{id:string, text:string}>}
151
+ */
152
+ export function normalizeCriteria(criteria = []) {
153
+ return normalizeCriteriaDetailed(criteria).criteria
90
154
  }
91
155
 
92
156
  function clip(text, max) {
@@ -141,22 +205,34 @@ export function buildPrompt({ title, description = '', criteria = [], scopeNote
141
205
  * @param {string} input.title
142
206
  * @param {Array<{id:string,text:string}>} input.criteria
143
207
  * @param {Array<{id:string,title:string}>} input.targets 待验收的卡
208
+ * @param {number} [input.maxReworkRounds] 打回上限(与配置同一来源,不能写死)
144
209
  * @returns {string}
145
210
  */
146
- export function buildVerifyPrompt({ title, criteria = [], targets = [], rootId = '' }) {
211
+ export function buildVerifyPrompt({ title, criteria = [], targets = [], rootId = '', maxReworkRounds = 2 }) {
212
+ const limit = Math.max(0, Math.trunc(Number(maxReworkRounds) || 0))
147
213
  const targetLines = targets
148
214
  .map((target) => `- ${target.title}(卡 ${target.id})→ 报告 out/${target.id}/report.md`)
149
215
  .join('\n')
150
216
  return [
151
217
  `目标:独立验收「${title}」。只核对证据,不改实现。`,
218
+ '等待交付(你很可能和开发卡同时启动,报告还没写完):',
219
+ [
220
+ `1. 开始后先等 30 秒再读第一遍报告:开发卡可能刚启动,正按自己的要求删掉上一轮的旧报告。`,
221
+ `2. 先看下面列出的报告是否都在;都在就进入验收。`,
222
+ `3. 报告必须比它所在卡最近一次执行的 startedAt 新(有任务看板工具就用 task_board_get 看 executions):更旧的是上一轮报告,继续等,不要当本轮证据。`,
223
+ `4. 有缺失或报告陈旧时不要立刻 REJECT:每 ${VERIFY_POLL_SECONDS} 秒看一次(例如 PowerShell:Start-Sleep -Seconds ${VERIFY_POLL_SECONDS})。`,
224
+ '5. 某张卡已经结束(executions 出现 endedAt)但仍缺本轮报告 → 立即 REJECT,summary 写「卡 <id> 已结束但报告缺失」。',
225
+ `6. 最多等 ${VERIFY_WAIT_MINUTES} 分钟;超时仍缺报告 → REJECT,summary 写「等待报告超时」。`,
226
+ '7. 报告缺失时的 REJECT 也要给出 {criteriaId, expected, actual, evidencePath}:expected 写「报告存在」,actual 写「缺失」,evidencePath 指向本应存在的 report.md。',
227
+ ].join('\n'),
152
228
  '职责:',
153
229
  [
154
- '1. 逐张读下面列出的报告;报告不存在 = 那张卡没交付,直接 REJECT。',
230
+ '1. 逐张读上面等到的报告。',
155
231
  '2. 对每条判据核对「期望 / 实际 / 证据」是否对得上;证据必须是能复核的东西(命令输出、测试名、文件路径、commit sha)。',
156
232
  '3. 结论只能是 PASS 或 REJECT:',
157
233
  ' - PASS:逐条给出判据 id 与证据位置;',
158
234
  ' - REJECT:逐条写 {criteriaId, expected, actual, evidencePath},缺一项都不算数。',
159
- '4. 同一批最多打回两次;第三次停下来把问题交给人,不要再让开发继续改(对应基线 R3)。',
235
+ `4. 同一批最多打回 ${limit} 次;第 ${limit + 1} 次停下来把问题交给人,不要再让开发继续改(对应基线 R3)。`,
160
236
  ].join('\n'),
161
237
  [
162
238
  '结论落盘(必须做,否则这次验收等于没发生):',
@@ -231,14 +307,35 @@ export function outlineCards({
231
307
  return out
232
308
  }
233
309
 
310
+ /**
311
+ * 32 位十六进制 → 稳定的 UUID 形状 id。
312
+ *
313
+ * 为什么不用 randomUUID:卡片 id 稳定,同一批重试时看板能按 requestId 幂等重放,
314
+ * 不会「失败一次就多一棵重复的树」。看板只要求 id 是非空字符串,UUID 形状只是好认。
315
+ *
316
+ * @param {string} seed
317
+ * @returns {string}
318
+ */
319
+ function stableId(seed) {
320
+ const chars = createHash('sha256').update(String(seed)).digest('hex').slice(0, 32).split('')
321
+ chars[12] = '4'
322
+ chars[16] = '89ab'[Number.parseInt(chars[16], 16) % 4]
323
+ const value = chars.join('')
324
+ return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20, 32)}`
325
+ }
326
+
234
327
  /** 看板 action 信封:`{ requestId, action }`,形状由看板 `parseActionEnvelope` 校验。 */
235
328
  function envelope(action, requestId) {
236
- return { requestId: requestId ?? `card-dispatch-${randomUUID()}`, action }
329
+ if (typeof requestId !== 'string' || requestId === '') throw new Error('信封必须有 requestId')
330
+ return { requestId, action }
237
331
  }
238
332
 
239
333
  /**
240
334
  * 把一个需求拆成「父卡 + 可选子卡」,并生成可直接投递给看板的信封。
241
335
  *
336
+ * 幂等:同一份输入(标题 / 描述 / 判据 / 跑法 / 提示词,10 分钟窗口内)得到同一批
337
+ * 卡片 id 与 requestId。页面重试、用户连点两次,看板都会按 requestId 重放而不是再建一份。
338
+ *
242
339
  * @param {object} input
243
340
  * @param {string} input.title 需求标题
244
341
  * @param {string} [input.description] 需求描述
@@ -247,7 +344,12 @@ function envelope(action, requestId) {
247
344
  * @param {string|null} [input.workspaceId] 钉住的工作区
248
345
  * @param {string|null} [input.agentPreset] 钉住的 agent 预设(看板字段名是 mode)
249
346
  * @param {string|null} [input.permission] 权限预设
250
- * @returns {{rootId:string, cards:Array, envelopes:Array, run:object|null, warnings:Array<string>}}
347
+ * @param {boolean} [input.requireEvidence] 交付门槛
348
+ * @param {boolean} [input.teamRun] 用 teamRun 卡片
349
+ * @param {object} [input.intros] 模型写的开头段
350
+ * @param {number} [input.maxReworkRounds] 打回上限(写进验收 prompt,不能写死)
351
+ * @param {number} [input.now] 幂等窗口用的时间(测试可注入)
352
+ * @returns {{rootId:string, verifyId:string|null, cards:Array, envelopes:Array, run:object|null, warnings:Array<string>}}
251
353
  */
252
354
  export function splitRequirement({
253
355
  title,
@@ -261,22 +363,17 @@ export function splitRequirement({
261
363
  teamRun = false,
262
364
  /** 模型按需求写好的开头段,按卡片 key(root / part-1 / verify)索引。 */
263
365
  intros = {},
366
+ maxReworkRounds = 2,
367
+ now = Date.now(),
264
368
  } = {}) {
265
369
  const cleanTitle = clip(String(title ?? ''), 120)
266
370
  if (cleanTitle === '') throw new Error('需求标题不能为空')
267
371
 
268
- const normalized = normalizeCriteria(criteria)
269
- const warnings = []
270
- const rootId = randomUUID()
271
-
272
- // 卡片公共字段:null 表示「不指定」,绝不能写进去(看板按 exactKeys 校验,多余字段直接 400)
273
- const shared = {
274
- ...(workspaceId ? { workspaceId } : {}),
275
- ...(agentPreset ? { mode: agentPreset } : {}),
276
- ...(permission ? { permission } : {}),
277
- }
278
-
372
+ const { criteria: normalized, warnings: criteriaWarnings } = normalizeCriteriaDetailed(criteria)
373
+ const warnings = [...criteriaWarnings]
374
+ const limit = Math.max(0, Math.trunc(Number(maxReworkRounds) || 0))
279
375
  const evidenceOn = requireEvidence === true
376
+
280
377
  /**
281
378
  * 拆不拆子卡,取决于跑法:
282
379
  * - 开了智能体团队:父卡是 Lead,子卡由它在**同一个**会话里派成 teammate,拆得越多越划算;
@@ -285,6 +382,47 @@ export function splitRequirement({
285
382
  */
286
383
  const shouldSplit = teamRun === true && parallel && normalized.length >= 2
287
384
 
385
+ /**
386
+ * 父卡用不用 Lead 会话(teamRun):
387
+ * - 勾了智能体团队:用,父卡当 Lead 派子卡;
388
+ * - 没勾但开了交付门槛:也用——只有「父卡 + 验收卡」两张,没必要各开一条会话;
389
+ * 父卡当 Lead 自己干活,验收卡是同一会话里的 teammate。
390
+ * 所以「智能体团队」仍然只决定拆不拆多张开发卡,不决定验收卡跑在哪。
391
+ */
392
+ const runAsTeam = teamRun === true || (evidenceOn && normalized.length > 0)
393
+
394
+ // 幂等种子:同一份输入 + 同一个 10 分钟窗口 = 同一批卡。
395
+ // 提示词也算进种子:重新让模型写过提示词就是新的一批,否则 requestId 会撞上不同 action。
396
+ const batchKey = createHash('sha256')
397
+ .update(JSON.stringify({
398
+ title: cleanTitle,
399
+ description: description.trim(),
400
+ criteria: normalized.map((item) => [item.id, item.text]),
401
+ parallel: parallel === true,
402
+ teamRun: teamRun === true,
403
+ runAsTeam: runAsTeam === true,
404
+ requireEvidence: evidenceOn,
405
+ workspaceId: workspaceId ?? null,
406
+ agentPreset: agentPreset ?? null,
407
+ permission: permission ?? null,
408
+ introsKey: createHash('sha256').update(JSON.stringify(intros ?? {})).digest('hex').slice(0, 16),
409
+ bucket: Math.floor(now / CREATE_IDEMPOTENCY_MS),
410
+ }))
411
+ .digest('hex')
412
+ const requestBase = `card-dispatch-${batchKey.slice(0, 20)}`
413
+ const rootId = stableId(`${batchKey}:root`)
414
+ let createIndex = 0
415
+ const createEnvelope = (id, input) =>
416
+ envelope({ kind: 'create', id, input }, `${requestBase}-create-${createIndex++}`)
417
+
418
+ // 卡片公共字段:null 表示「不指定」,绝不能写进去(看板按 exactKeys 校验,多余字段直接 400)
419
+ const shared = {
420
+ ...(workspaceId ? { workspaceId } : {}),
421
+ ...(agentPreset ? { mode: agentPreset } : {}),
422
+ ...(permission ? { permission } : {}),
423
+ }
424
+
425
+ const verifyWillExist = evidenceOn && normalized.length > 0
288
426
  const rootInput = {
289
427
  title: cleanTitle,
290
428
  description: description.trim(),
@@ -300,20 +438,29 @@ export function splitRequirement({
300
438
  ...shared,
301
439
  // 智能体团队只在父卡上开:看板见到根卡 teamRun=true 就只起一个 Lead 会话,
302
440
  // 由它把每张子卡派成 teammate。子卡不能再写 teamRun,否则会被当成另一个 Lead。
303
- ...(teamRun ? { teamRun: true } : {}),
441
+ // 交付门槛开着时即使没勾团队也走 Lead 会话:父卡 + 验收卡两张没必要开两条会话。
442
+ ...(runAsTeam ? { teamRun: true } : {}),
304
443
  }
305
444
 
306
- if (teamRun && shouldSplit) {
445
+ if (runAsTeam && shouldSplit) {
307
446
  // 根卡在 teamRun 模式下就是 Lead:顺序只能写在 prompt 里——看板没有依赖字段,
308
447
  // 靠平台排不出「先开发后验收」,只能让 Lead 自己守这个顺序。
309
- rootInput.prompt +=
310
- '\n\n流程:先派发子卡完成开发 → 等子卡交付 → 再让验收卡执行 → 被打回最多两次,第三次停下来交给人。'
311
- } else if (teamRun) {
312
- rootInput.prompt += '\n\n执行方式:这一轮由你自己开团队完成(Lead 起 teammate),父卡不产出报告。'
448
+ // 验收卡已经和子卡一起启动并在等报告,所以这里说「等结论」,不再让它去「执行验收卡」,
449
+ // 否则同一张验收卡会被跑两次。
450
+ // 没生成验收卡时不能再提验收卡,否则执行者会去找一张不存在的卡。
451
+ rootInput.prompt += verifyWillExist
452
+ ? `\n\n流程:先派发子卡完成开发 → 等子卡交付 → 验收卡已在会话里等报告,报告齐全后会自动给出 PASS/REJECT;等它给结论再收尾。被打回最多 ${limit} 次,第 ${limit + 1} 次停下来交给人。`
453
+ : '\n\n流程:先派发子卡完成开发 → 等子卡交付 → 汇总结果。本次没有验收卡。'
454
+ } else if (runAsTeam) {
455
+ // 没拆子卡时根卡**就是干活的那张**(上面可能已经让它写报告),
456
+ // 这里不能再写「父卡不产出报告」——两句会互相打架,验收必打回。
457
+ rootInput.prompt += evidenceOn
458
+ ? '\n\n执行方式:这一轮由你自己(Lead)完成,不派子卡;按上面的交付要求产出报告。验收卡已经在同一会话里等这份报告,写完等它给出结论再收尾。'
459
+ : '\n\n执行方式:这一轮由你自己(Lead)完成,不派子卡。'
313
460
  }
314
461
 
315
462
  const cards = [{ id: rootId, key: 'root', title: cleanTitle, input: rootInput }]
316
- const envelopes = [envelope({ kind: 'create', id: rootId, input: rootInput })]
463
+ const envelopes = [createEnvelope(rootId, rootInput)]
317
464
 
318
465
  if (normalized.length === 0) {
319
466
  warnings.push('这个需求没有验收判据,看板里的执行者不知道做到什么算完;建议补几条。')
@@ -321,11 +468,8 @@ export function splitRequirement({
321
468
 
322
469
  if (shouldSplit) {
323
470
  const groups = groupCriteria(normalized, 4)
324
- if (groups.length < normalized.length) {
325
- // 一条判据一张卡只在判据不多时才有意义,否则一天开八张卡全是计费
326
- }
327
471
  groups.forEach((group, index) => {
328
- const id = randomUUID()
472
+ const id = stableId(`${batchKey}:part-${index + 1}`)
329
473
  const groupTitle = `${cleanTitle}(${index + 1}/${groups.length})${clip(group[0].text, 28)}`
330
474
  const input = {
331
475
  title: groupTitle,
@@ -342,7 +486,7 @@ export function splitRequirement({
342
486
  ...shared,
343
487
  }
344
488
  cards.push({ id, key: `part-${index + 1}`, title: groupTitle, input })
345
- envelopes.push(envelope({ kind: 'create', id, input }))
489
+ envelopes.push(createEnvelope(id, input))
346
490
  })
347
491
  warnings.push(
348
492
  `拆成 ${cards.length} 张卡:父卡 1 张 + 子卡 ${groups.length} 张。子卡在看板里是并行的,所以每张都自带验收判据。`,
@@ -357,33 +501,38 @@ export function splitRequirement({
357
501
  // 它是**另一张卡**而不是父卡的一部分,因为基线 §3.3 要求验证独立于开发;
358
502
  // 同一张卡自己说「我做完了」不算验收。
359
503
  // 和有没有开智能体团队无关:门槛是「必须有人独立核对」的意思,不能因为省一条会话就悄悄失效。
360
- // 代价是没开团队时它就是第 2 条会话,所以要提前把条数说清楚(见下)。
361
504
  let verifyId = null
505
+ let verifyTitle = ''
362
506
  if (evidenceOn && normalized.length > 0) {
363
- verifyId = randomUUID()
507
+ verifyId = stableId(`${batchKey}:verify`)
364
508
  // 拆过的需求逐张列子卡;单卡需求时待验收的就是父卡本身。
365
509
  const targets = shouldSplit
366
510
  ? cards.filter((card) => card.key !== 'root').map((card) => ({ id: card.id, title: card.title }))
367
511
  : cards.map((card) => ({ id: card.id, title: card.title }))
368
- const verifyTitle = `${clip(cleanTitle, 60)}(验收)`
512
+ verifyTitle = `${clip(cleanTitle, 60)}(验收)`
369
513
  const input = {
370
514
  title: verifyTitle,
371
515
  description: `独立验收「${cleanTitle}」:只核对证据,不改实现。`,
372
- prompt: buildVerifyPrompt({ title: cleanTitle, criteria: normalized, targets, rootId }),
516
+ prompt: buildVerifyPrompt({ title: cleanTitle, criteria: normalized, targets, rootId, maxReworkRounds: limit }),
373
517
  parentId: rootId,
374
518
  ...shared,
375
519
  }
376
520
  cards.push({ id: verifyId, key: 'verify', title: verifyTitle, input })
377
- envelopes.push(envelope({ kind: 'create', id: verifyId, input }))
521
+ envelopes.push(createEnvelope(verifyId, input))
378
522
  warnings.push('已附一张验收卡:它只读证据、只给 PASS/REJECT,和开发不是同一张卡(基线 §3.3)。')
523
+ // 验收卡是父卡的子卡,运行父卡时**同一次运行**就会把它带起来,不需要人再点一次。
524
+ // 代价是它可能比开发卡先启动,所以 prompt 里要求它等报告(见 buildVerifyPrompt)。
525
+ warnings.push('验收卡会随父卡一起启动并在报告齐全后自动验收,不需要手动运行;它可能比开发卡先启动,属于正常现象。')
379
526
  }
380
527
 
381
- // 会话条数:团队模式下所有卡都在**同一个** Lead 会话里跑(teammate 是这个会话里的下级),
528
+ // 会话条数:Lead 模式下所有卡都在**同一个** Lead 会话里跑(teammate 是这个会话里的下级),
382
529
  // 所以卡多不等于会话多。这句话得说出来——否则人会以为 6 张卡就是 6 次计费。
383
- if (teamRun === true && cards.length > 1) {
384
- warnings.push(`开智能体团队:这 ${cards.length} 张卡在同一个 Lead 会话里跑,只算 1 条会话。`)
385
- } else if (teamRun !== true && cards.length > 1) {
386
- warnings.push(`没开智能体团队:各卡各开一条会话,这次一共 ${cards.length} 条。`)
530
+ if (runAsTeam && cards.length > 1) {
531
+ warnings.push(teamRun === true
532
+ ? `开智能体团队:这 ${cards.length} 张卡在同一个 Lead 会话里跑,只算 1 条会话。`
533
+ : `交付门槛:父卡 + 验收卡在同一个 Lead 会话里跑(验收卡是 teammate),只算 1 条会话;开发由父卡自己完成。`)
534
+ } else if (!runAsTeam && cards.length > 1) {
535
+ warnings.push(`没开智能体团队:各卡各开一条会话,这次一共 ${cards.length} 条;验收卡会等报告出现后自动验收。`)
387
536
  }
388
537
 
389
538
  return {
@@ -391,7 +540,10 @@ export function splitRequirement({
391
540
  verifyId,
392
541
  cards,
393
542
  envelopes,
394
- run: envelope({ kind: 'run', taskId: rootId }),
543
+ run: envelope(
544
+ { kind: 'run', taskId: rootId },
545
+ `${requestBase}-run-${Math.floor(now / RUN_IDEMPOTENCY_MS)}`,
546
+ ),
395
547
  warnings,
396
548
  }
397
549
  }