cs-bots 0.7.5 → 0.8.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/src/api.js CHANGED
@@ -12,15 +12,15 @@
12
12
  * - **前缀路由**:`/cs-bots/api`(与包名一致,避免和其他插件撞路径)。
13
13
  */
14
14
 
15
- import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
15
+ import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, renameSync } from 'node:fs'
16
16
  import { createHash } from 'node:crypto'
17
17
  import { join, resolve, sep as pathSep } from 'node:path'
18
- import { registerRoute, listPresets, defaultPreset, getAgent, resumeSession, readSessionMessages, attachToWorkspace, readSessionWatermark } from './kernel.js'
18
+ import { registerRoute, listPresets, defaultPreset, getAgent, resumeSession, readSessionMessages, attachToWorkspace, readSessionWatermark, sessionMeta } from './kernel.js'
19
19
  import { createLibrary } from './library.js'
20
20
  import { createDirBrowser } from './dirbrowse.js'
21
21
  import { getServices } from './registry.js'
22
22
  import { createFence } from './fence.js'
23
- import { appendJsonl, readJsonl, statePath } from './store.js'
23
+ import { appendJsonl, readJsonl, statePath, backupState, migrate, readSchemaVersion, writeSchemaVersion } from './store.js'
24
24
  import { deriveStage, attachStages, stageSummary } from './stage.js'
25
25
  import { CMD_WHITELIST } from './verify.js'
26
26
  import { execFile } from 'node:child_process'
@@ -474,6 +474,229 @@ function normalizePhases(raw, code = 'EVOLVE_BAD_PHASES') {
474
474
  })
475
475
  }
476
476
 
477
+ // ══════════════════════════════════════════════════════════════════════════
478
+ // 升级09 · 阶段产出物(契约 §2.2 / §2.3 / §4.2)
479
+ //
480
+ // 一句话:长任务的阶段清单存在 `kind:'longrun'` 行里,执行者每交一个阶段的
481
+ // 产出物就追加一条 `kind:'phase'` 行。清单与产物**分开存**是刻意的——
482
+ // Boss 改阶段清单(§4.2)不该动已经交出来的证据。
483
+ // ══════════════════════════════════════════════════════════════════════════
484
+
485
+ /** 阶段状态(写入端合法值;读取端另有 `pending` = 「未开始」,见 buildPhaseView)。 */
486
+ const PHASE_STATES = ['doing', 'done', 'blocked']
487
+ /** 单阶段产出物条数上限 / 单项长度上限(契约 §2.3)。 */
488
+ const PHASE_ARTIFACTS_MAX = 50
489
+ const PHASE_ARTIFACT_MAX = 2000
490
+ /** 阶段说明长度上限(契约未写死,取与产出物同档的保守值)。 */
491
+ const PHASE_NOTE_MAX = 2000
492
+
493
+ /**
494
+ * 构造一个带 HTTP 状态码的错误(HTTP 与工具面共用同一份,**不写两套判定**)。
495
+ * @param {string} message @param {number} status @param {string} code
496
+ */
497
+ function phaseError(message, status, code) {
498
+ const error = new Error(message)
499
+ error.status = status
500
+ error.code = code
501
+ return error
502
+ }
503
+
504
+ /**
505
+ * 归一化单阶段的产出物清单(契约 §2.3)。
506
+ *
507
+ * 规则:去空、去重、最多 50 项、每项 ≤ 2000 字。
508
+ * **超限选择报错而不是静默截断**:产出物是证据,悄悄丢掉一条比拒绝写入更糟。
509
+ * @param {unknown} list
510
+ * @returns {string[]}
511
+ */
512
+ function normalizePhaseArtifacts(list) {
513
+ if (list === undefined || list === null) return []
514
+ if (!Array.isArray(list)) {
515
+ throw phaseError('artifacts 必须是字符串数组(也可以不传)', 400, 'PHASE_BAD_ARTIFACTS')
516
+ }
517
+ const items = normalizeArtifactItems(list)
518
+ .map((item) => item.trim())
519
+ .filter((item) => item !== '')
520
+ if (items.length > PHASE_ARTIFACTS_MAX) {
521
+ throw phaseError(
522
+ `artifacts 最多 ${PHASE_ARTIFACTS_MAX} 项(收到 ${items.length} 项)——请合并或分批汇报`,
523
+ 400,
524
+ 'PHASE_BAD_ARTIFACTS',
525
+ )
526
+ }
527
+ const seen = new Set()
528
+ const out = []
529
+ for (const item of items) {
530
+ if (Array.from(item).length > PHASE_ARTIFACT_MAX) {
531
+ throw phaseError(
532
+ `artifacts 单项最多 ${PHASE_ARTIFACT_MAX} 字(收到 ${Array.from(item).length} 字)——请放路径/链接,不要塞整篇内容`,
533
+ 400,
534
+ 'PHASE_BAD_ARTIFACTS',
535
+ )
536
+ }
537
+ if (seen.has(item)) continue
538
+ seen.add(item)
539
+ out.push(item)
540
+ }
541
+ return out
542
+ }
543
+
544
+ /** 读某任务的阶段产出物行(按 `at` 升序;同 index 后写的胜出)。 */
545
+ function readPhaseRows(services, taskId) {
546
+ const rows = readTaskMeta(services, taskId).filter(
547
+ (row) => row.kind === 'phase'
548
+ && Number.isInteger(row.index)
549
+ && row.index >= 0,
550
+ )
551
+ const byIndex = new Map()
552
+ for (const row of rows) byIndex.set(row.index, row) // 顺序已按 at 升序 ⇒ 后写覆盖前写
553
+ return byIndex
554
+ }
555
+
556
+ /**
557
+ * 拼出契约 §2.2 的完整视图(HTTP 读端点与写端点的回填共用同一份)。
558
+ *
559
+ * @param {object} services
560
+ * @param {object} task board 任务快照
561
+ * @returns {{ taskId: string, phases: object[], currentIndex: number,
562
+ * totalArtifacts: number, isLongrun: boolean, note: string }}
563
+ */
564
+ function buildPhaseView(services, task) {
565
+ const longrun = readLongrunMeta(services, task.id)
566
+ const titles = longrunPhaseList(longrun)
567
+ const isLongrun = Boolean(longrun)
568
+ if (!isLongrun) {
569
+ return {
570
+ taskId: task.id,
571
+ phases: [],
572
+ currentIndex: 0,
573
+ totalArtifacts: 0,
574
+ isLongrun: false,
575
+ note: '这不是长任务,没有分阶段产物;总产出物见下方',
576
+ }
577
+ }
578
+ const byIndex = readPhaseRows(services, task.id)
579
+ // 只剩「清单里还存在的下标」:Boss 把清单改短后,越界的旧产物行**保留但不展示**
580
+ // (契约 §4.2 —— 不删证据,只是不再显示)。
581
+ const phases = titles.map((title, index) => {
582
+ const row = byIndex.get(index)
583
+ return {
584
+ index,
585
+ title,
586
+ // 没汇报过的阶段给 `pending`(UI 徽标「未开始」)—— 不伪造成 doing/done
587
+ state: PHASE_STATES.includes(row?.state) ? row.state : 'pending',
588
+ artifacts: Array.isArray(row?.artifacts) ? [...row.artifacts] : [],
589
+ note: typeof row?.note === 'string' ? row.note : '',
590
+ by: typeof row?.by === 'string' ? row.by : null,
591
+ at: Number.isFinite(Number(row?.at)) ? Number(row.at) : null,
592
+ }
593
+ })
594
+ const firstUnfinished = phases.findIndex((phase) => phase.state !== 'done')
595
+ const totalArtifacts = phases.reduce((sum, phase) => sum + phase.artifacts.length, 0)
596
+ const doneCount = phases.filter((phase) => phase.state === 'done').length
597
+ return {
598
+ taskId: task.id,
599
+ phases,
600
+ currentIndex: firstUnfinished === -1 ? phases.length : firstUnfinished,
601
+ totalArtifacts,
602
+ isLongrun: true,
603
+ note: `长任务共 ${phases.length} 个阶段,已完成 ${doneCount} 个;共 ${totalArtifacts} 个阶段产出物`,
604
+ }
605
+ }
606
+
607
+ /**
608
+ * 写一条阶段产出物(契约 §2.3)。
609
+ *
610
+ * **单一实现**:HTTP `POST /tasks/phase` 与工具 `bot_task_phase`(C 的 tools.js)
611
+ * 都走这里 —— 两处各写一遍,早晚漂移成「页面能写、Bot 写不进」这种鬼故事。
612
+ *
613
+ * @param {object} services 域服务集合(需 board)
614
+ * @param {string} taskId
615
+ * @param {{ index: number, state?: string, artifacts?: string[], note?: string,
616
+ * by?: string, expectedRev?: number }} patch
617
+ * @returns {{ ok: true, taskId: string, phase: object, phases: object[],
618
+ * currentIndex: number, totalArtifacts: number, isLongrun: boolean }}
619
+ */
620
+ function writePhaseRow(services, taskId, patch = {}) {
621
+ if (typeof taskId !== 'string' || taskId.trim() === '') {
622
+ throw phaseError('id 必填(任务 id,如 t-36)', 400, 'PHASE_MISSING_ID')
623
+ }
624
+ const id = taskId.trim()
625
+ const board = services?.board
626
+ if (!board || typeof board.get !== 'function') {
627
+ throw phaseError('board 服务未装配(无法校验任务与阶段清单)', 503, 'BOARD_UNAVAILABLE')
628
+ }
629
+ const task = board.get(id)
630
+ if (!task) throw phaseError(`任务 ${id} 不存在`, 404, 'PHASE_TASK_NOT_FOUND')
631
+
632
+ const longrun = readLongrunMeta(services, id)
633
+ const titles = longrunPhaseList(longrun)
634
+ if (!longrun || titles.length === 0) {
635
+ throw phaseError(
636
+ `任务 ${id} 不是长任务(没有阶段清单)——请先在「转长任务」里定阶段,或用「编辑阶段清单」设置`,
637
+ 400,
638
+ 'PHASE_NOT_LONGRUN',
639
+ )
640
+ }
641
+
642
+ // ── CAS(可选):给了 expectedRev 就必须对得上,不匹配绝不静默覆盖 ──
643
+ if (patch.expectedRev !== undefined && patch.expectedRev !== null) {
644
+ if (!Number.isInteger(patch.expectedRev)) {
645
+ throw phaseError(`expectedRev 必须是整数(当前 rev=${task.rev})`, 400, 'PHASE_BAD_FIELD')
646
+ }
647
+ if (patch.expectedRev !== task.rev) {
648
+ throw phaseError(
649
+ `CAS 冲突:任务 ${id} 期望 rev=${patch.expectedRev},实际 rev=${task.rev} —— 拒绝静默覆盖,请刷新后重试`,
650
+ 409,
651
+ 'PHASE_REV_CONFLICT',
652
+ )
653
+ }
654
+ }
655
+
656
+ const index = patch.index
657
+ if (!Number.isInteger(index)) {
658
+ throw phaseError(`index 必须是整数(当前合法范围 0..${titles.length - 1})`, 400, 'PHASE_BAD_INDEX')
659
+ }
660
+ if (index < 0 || index > titles.length - 1) {
661
+ throw phaseError(
662
+ `index=${index} 越界:合法范围是 0..${titles.length - 1}`
663
+ + `(当前阶段清单共 ${titles.length} 个阶段:${titles.map((t, i) => `${i}=${t}`).join(';')})`,
664
+ 400,
665
+ 'PHASE_BAD_INDEX',
666
+ )
667
+ }
668
+
669
+ const state = patch.state === undefined || patch.state === null ? 'done' : patch.state
670
+ if (!PHASE_STATES.includes(state)) {
671
+ throw phaseError(
672
+ `state=${JSON.stringify(patch.state)} 非法(合法:${PHASE_STATES.join(' | ')})`,
673
+ 400,
674
+ 'PHASE_BAD_STATE',
675
+ )
676
+ }
677
+
678
+ const artifacts = normalizePhaseArtifacts(patch.artifacts)
679
+ let note = ''
680
+ if (patch.note !== undefined && patch.note !== null) {
681
+ if (typeof patch.note !== 'string') {
682
+ throw phaseError('note 必须是字符串(也可以不传)', 400, 'PHASE_BAD_NOTE')
683
+ }
684
+ note = patch.note.trim()
685
+ if (Array.from(note).length > PHASE_NOTE_MAX) {
686
+ throw phaseError(`note 最多 ${PHASE_NOTE_MAX} 字(收到 ${Array.from(note).length} 字)`, 400, 'PHASE_BAD_NOTE')
687
+ }
688
+ }
689
+ const by = typeof patch.by === 'string' && patch.by.trim() ? patch.by.trim() : 'ui'
690
+ const at = Date.now()
691
+
692
+ // 落盘失败 ⇒ appendTaskMeta 抛 503(**绝不静默丢证据**)
693
+ appendTaskMeta(services, { kind: 'phase', taskId: id, index, state, artifacts, note, by, at })
694
+
695
+ const view = buildPhaseView(services, task)
696
+ return { ok: true, taskId: id, phase: view.phases[index], phases: view.phases,
697
+ currentIndex: view.currentIndex, totalArtifacts: view.totalArtifacts, isLongrun: view.isLongrun }
698
+ }
699
+
477
700
  /** 变更**前**的任务快照(契约 §2.2 reopen 步骤 1)。 */
478
701
  function buildTaskSnapshot(task) {
479
702
  return {
@@ -907,6 +1130,620 @@ function buildExecutorStatus(ctx, services, task) {
907
1130
  * @param {string} by 谁触发的(用于留痕)
908
1131
  * @returns {Promise<{ team?: object, created?: boolean, reason?: string } | undefined>}
909
1132
  */
1133
+ // ════════════════════════════════════════════════════════════════════════════
1134
+ // 升级接续(契约 §8.2):预检 / 迁移
1135
+ // ════════════════════════════════════════════════════════════════════════════
1136
+
1137
+ /** 升级 09 落地的 schema 版本(0 = 升级前,2 = 本版)。 */
1138
+ const UPGRADE_SCHEMA_TO = 2
1139
+ /**
1140
+ * 「Boss 本人」的身份集合。
1141
+ * `user` 是 api.js 的 OWNER,`ui` 是页面层,`person` 是 board 里的人口径 ——
1142
+ * 三者在旧数据里都代表「Boss 亲手做的」,判定必须一起认,否则迁移会漏。
1143
+ */
1144
+ const BOSS_IDS = new Set(['ui', 'user', 'person'])
1145
+ /** 契约 §8.2 强制的回填备注原文(一个字都不能改,验收要 grep)。 */
1146
+ const UPGRADE_PHASE_NOTE = '升级迁移:按总产出物回填'
1147
+
1148
+ /** 某身份是不是 Boss。 */
1149
+ function isBossId(id) {
1150
+ return typeof id === 'string' && BOSS_IDS.has(id)
1151
+ }
1152
+
1153
+ /**
1154
+ * 读迁移要用到的全部原始事实(**只读**,绝不写盘)。
1155
+ *
1156
+ * ## 为什么单独抽出来
1157
+ * `preflight`(只读体检)与 `migrate`(真写)必须基于**同一份判定**,
1158
+ * 否则「预检说会补 3 条、迁移后补了 5 条」这种前后不一致会直接毁掉 Boss 的信任。
1159
+ */
1160
+ function collectUpgradeFacts(services) {
1161
+ const dir = anchorStateDir(services) ?? null
1162
+ const facts = {
1163
+ stateDir: dir,
1164
+ boardFile: dir ? statePath(dir, 'board.jsonl') : null,
1165
+ metaFile: dir ? statePath(dir, TASK_META_FILE) : null,
1166
+ notesFile: dir ? statePath(dir, TASK_NOTES_FILE) : null,
1167
+ wakesFile: dir ? statePath(dir, 'wakes.jsonl') : null,
1168
+ boardEvents: [],
1169
+ boardBad: 0,
1170
+ metaRows: [],
1171
+ metaBad: 0,
1172
+ notes: 0,
1173
+ wakes: 0,
1174
+ tasks: [],
1175
+ tasksSource: 'memory',
1176
+ }
1177
+ if (!dir) return facts
1178
+
1179
+ // 事件流(真相源)。坏行**不静默**:交给 store.migrate 去拒绝不完整数据。
1180
+ if (facts.boardFile && existsSync(facts.boardFile)) {
1181
+ const parsed = readJsonl(facts.boardFile)
1182
+ facts.boardEvents = parsed.rows
1183
+ facts.boardBad = parsed.bad ?? 0
1184
+ }
1185
+ if (facts.metaFile && existsSync(facts.metaFile)) {
1186
+ const parsed = readJsonl(facts.metaFile)
1187
+ facts.metaRows = parsed.rows
1188
+ facts.metaBad = parsed.bad ?? 0
1189
+ }
1190
+ if (facts.notesFile && existsSync(facts.notesFile)) {
1191
+ facts.notes = readJsonl(facts.notesFile).rows.length
1192
+ }
1193
+ if (facts.wakesFile && existsSync(facts.wakesFile)) {
1194
+ facts.wakes = readJsonl(facts.wakesFile).rows.length
1195
+ }
1196
+
1197
+ // 任务态:优先用内存(已 hydrate 的最新态);事件流存在但内存为空 ⇒ 退回落盘推演。
1198
+ try {
1199
+ const live = services?.board?.list?.({}) ?? []
1200
+ if (Array.isArray(live) && live.length > 0) facts.tasks = live
1201
+ } catch { /* 落到下面的推演 */ }
1202
+ if (facts.tasks.length === 0 && facts.boardEvents.length > 0) {
1203
+ facts.tasksSource = 'jsonl'
1204
+ const byId = new Map()
1205
+ for (const ev of facts.boardEvents) {
1206
+ if (ev && typeof ev.taskId === 'string' && ev.after && typeof ev.after.id === 'string') {
1207
+ byId.set(ev.taskId, ev.after)
1208
+ }
1209
+ }
1210
+ facts.tasks = [...byId.values()]
1211
+ }
1212
+ return facts
1213
+ }
1214
+
1215
+ /** 某任务是否「Boss 碰过」—— 契约 §8.2:`createdBy` / `mandate.sender` / 历史 `byId`。 */
1216
+ function bossTouchedOf(facts, services, task) {
1217
+ if (isBossId(task?.createdBy)) return true
1218
+ if (isBossId(task?.mandate?.sender)) return true
1219
+ // 历史(内存里有 history 就查内存,否则查事件流)
1220
+ if (typeof services?.board?.history === 'function') {
1221
+ try {
1222
+ const rows = services.board.history(task.id)
1223
+ if (Array.isArray(rows) && rows.some((e) => isBossId(e?.byId))) return true
1224
+ } catch { /* 落到事件流 */ }
1225
+ }
1226
+ return facts.boardEvents.some((e) => e?.taskId === task.id && isBossId(e?.byId))
1227
+ }
1228
+
1229
+ /** 任务详情里「旧数据没有」的字段(契约 §8.1 的兼容读清单)。 */
1230
+ const LEGACY_CANDIDATES = Object.freeze([
1231
+ 'bossTouched', 'progress', 'labels', 'writeScopes', 'triaged', 'blockedBy', 'progressBy',
1232
+ ])
1233
+
1234
+ /** 汇总「哪些旧字段缺、缺多少条」—— 这是 preflight 里 Boss 最需要看的那一段。 */
1235
+ function legacyFieldReport(tasks) {
1236
+ const out = []
1237
+ for (const field of LEGACY_CANDIDATES) {
1238
+ let missing = 0
1239
+ for (const task of tasks) if (task?.[field] === undefined) missing += 1
1240
+ if (missing > 0) out.push({ field, missing, total: tasks.length })
1241
+ }
1242
+ return out
1243
+ }
1244
+
1245
+ /**
1246
+ * 升级预检(契约 §8.2 `GET /upgrade/preflight`)—— **只读,一个字节都不写**。
1247
+ *
1248
+ * 回答 Boss 三个问题:现在有多少数据?升级会动什么?有什么风险?
1249
+ */
1250
+ function upgradePreflight(services) {
1251
+ const facts = collectUpgradeFacts(services)
1252
+ const tasks = facts.tasks
1253
+
1254
+ let needBoss = 0
1255
+ for (const task of tasks) {
1256
+ if (task?.bossTouched !== true && bossTouchedOf(facts, services, task)) needBoss += 1
1257
+ }
1258
+
1259
+ // 「已完成的长任务但没有阶段行」= 契约 §8.2 第 2 条要回填的那批
1260
+ const longrunTasks = tasks.filter((t) => t && readLongrunMeta(services, t.id))
1261
+ const phaseRowsByTask = new Map()
1262
+ for (const row of facts.metaRows) {
1263
+ if (row?.kind !== 'phase' || typeof row.taskId !== 'string') continue
1264
+ phaseRowsByTask.set(row.taskId, (phaseRowsByTask.get(row.taskId) ?? 0) + 1)
1265
+ }
1266
+ const needPhase = longrunTasks.filter(
1267
+ (t) => t.status === 'completed' && (phaseRowsByTask.get(t.id) ?? 0) === 0,
1268
+ ).length
1269
+
1270
+ const willDo = []
1271
+ if (needBoss > 0) willDo.push(`为 ${needBoss} 个任务补 bossTouched`)
1272
+ if (needPhase > 0) willDo.push(`为 ${needPhase} 个已完成长任务按总产出物回填阶段产物`)
1273
+ if (tasks.length > 0) willDo.push(`把 board 事件流 schema 版本记到 ${UPGRADE_SCHEMA_TO}(现有任务 rev/id/字段原样保留)`)
1274
+ if (willDo.length === 0) willDo.push('无需迁移:数据已是新版形态')
1275
+
1276
+ // 风险:只说真的会挡路的那几条,不凑数
1277
+ const risks = []
1278
+ if (facts.boardBad > 0) {
1279
+ risks.push(`board.jsonl 有 ${facts.boardBad} 行无法解析(撕裂写/损坏)——迁移会拒绝在不完整数据上动手,请先修文件`)
1280
+ }
1281
+ if (facts.metaBad > 0) {
1282
+ risks.push(`task-meta.jsonl 有 ${facts.metaBad} 行无法解析——阶段产物回填可能漏读,建议先备份核对`)
1283
+ }
1284
+ if (!facts.stateDir) risks.push('状态目录不可用:既不能预检也不能迁移')
1285
+ if (facts.tasksSource === 'jsonl') risks.push('内存里没有任务(板尚未 hydrate)——本报告由事件流推演,迁移前建议先重启一次让板加载完整')
1286
+ if (risks.length === 0) risks.push('无阻塞风险(迁移前仍会自动整目录备份)')
1287
+
1288
+ return {
1289
+ ok: true,
1290
+ stateDir: facts.stateDir,
1291
+ schemaVersion: facts.stateDir ? readSchemaVersion(facts.stateDir, 'board') : 0,
1292
+ targetVersion: UPGRADE_SCHEMA_TO,
1293
+ counts: {
1294
+ tasks: tasks.length,
1295
+ longrun: longrunTasks.length,
1296
+ phaseRows: facts.metaRows.filter((r) => r?.kind === 'phase').length,
1297
+ notes: facts.notes,
1298
+ wakes: facts.wakes,
1299
+ boardEvents: facts.boardEvents.length,
1300
+ bossTouched: tasks.filter((t) => t?.bossTouched === true).length,
1301
+ },
1302
+ legacy: legacyFieldReport(tasks),
1303
+ willDo,
1304
+ risks,
1305
+ backupHint: facts.stateDir
1306
+ ? `迁移前会自动整目录备份到 ${facts.stateDir}.bak-<时间>-upgrade09;要手动回滚就 restoreState(stateDir, 备份路径, { confirm: true })`
1307
+ : '状态目录不可用,无法备份',
1308
+ }
1309
+ }
1310
+
1311
+ /**
1312
+ * 升级迁移(契约 §8.2 `POST /upgrade/migrate`)—— 幂等、先备份、**保留 rev/id/字段**。
1313
+ *
1314
+ * ## 为什么不能「重写整个 board 文件」
1315
+ * `board.jsonl` 是 CAS 幂等键与 rev 语义的真相源。若整体覆盖重写,
1316
+ * 稍有字段遗漏就会让任务的 `rev` 与前端拿到的对不上 —— Boss 需求 9
1317
+ * 「任务原样存活」当场破功。所以这里**逐条合并**:只往事件里补缺失字段,
1318
+ * `rev` / `id` / 其余字段**一个字不动**,然后用 `store.migrate` 原子写回。
1319
+ *
1320
+ * ## 幂等
1321
+ * schema 版本已是目标值 ⇒ `store.migrate` 返回 `migrated:false`,不重写文件;
1322
+ * 阶段回填按「该任务是否已有 phase 行」判定,第二次跑必然是 0 条。
1323
+ *
1324
+ * ## 失败
1325
+ * 先 `backupState()`。任何一步失败都返回明确理由 + 备份路径,
1326
+ * 让 Boss 能一键回滚,而不是卡在「写了一半」的状态。
1327
+ *
1328
+ * @returns {Promise<object>}
1329
+ */
1330
+ async function upgradeMigrate(services, by) {
1331
+ const facts = collectUpgradeFacts(services)
1332
+ if (!facts.stateDir) {
1333
+ const error = new Error('状态目录不可用:无法备份,拒绝迁移')
1334
+ error.status = 500
1335
+ error.code = 'UPGRADE_NO_STATE_DIR'
1336
+ throw error
1337
+ }
1338
+
1339
+ // ① 先备份(契约硬要求)。备份失败 ⇒ 一个字节都不动。
1340
+ //
1341
+ // ⚠️ label 带上次幂等迁移的计数:`store.backupState` 的目标路径只精确到**秒**
1342
+ //(`<dir>.bak-YYYYMMDD-HHmmss-<label>`),同一秒内跑两次迁移会**覆盖同一份备份** ——
1343
+ // 第二次就把「升级前的原始状态」盖成「升级后的状态」,回滚点当场消失。
1344
+ // 所以这里按「本次真正要动的任务数 + 当前毫秒」做标签,保证两次备份路径必不相同。
1345
+ const backupTag = `upgrade09-${facts.tasks.length}-${Date.now()}`
1346
+ const backup = backupState(facts.stateDir, backupTag)
1347
+ if (!backup?.ok) {
1348
+ const error = new Error(`迁移中止:备份失败(${backup?.reason ?? '未知原因'})——未写入任何改动`)
1349
+ error.status = 500
1350
+ error.code = 'UPGRADE_BACKUP_FAILED'
1351
+ throw error
1352
+ }
1353
+
1354
+ const report = {
1355
+ ok: true,
1356
+ backupPath: backup.path,
1357
+ bossTouched: { updated: 0, skipped: 0 },
1358
+ phases: { backfilled: 0, tasks: [] },
1359
+ schema: null,
1360
+ replayed: null,
1361
+ }
1362
+
1363
+ // ② bossTouched 回填:逐条合并进**每个任务的最后一条事件**的 `after`,
1364
+ // rev/id/其它字段原样保留(这是「不破坏 rev 语义」的关键)。
1365
+ const needBoss = new Map()
1366
+ for (const task of facts.tasks) {
1367
+ if (!task || typeof task.id !== 'string') continue
1368
+ if (task.bossTouched === true) { report.bossTouched.skipped += 1; continue }
1369
+ const touched = bossTouchedOf(facts, services, task)
1370
+ if (touched) needBoss.set(task.id, true)
1371
+ else needBoss.set(task.id, false)
1372
+ }
1373
+
1374
+ // 只对「确实要补 true」的任务动手;判 false 的不写(少改动 = 少风险)
1375
+ const targets = new Set([...needBoss.entries()].filter(([, v]) => v === true).map(([k]) => k))
1376
+
1377
+ if (facts.boardEvents.length === 0) {
1378
+ // 快照-only 的状态目录(没有 board.jsonl):**不建空 jsonl**。
1379
+ // `store.migrate` 会写出一个空的真相源文件,虽然 hydrate 仍会回落到快照,
1380
+ // 但「空 jsonl + 有快照」是后续所有操作的雷区(任何一次 jsonl 写入都会以
1381
+ // 「空板」为基线)。这里直接记 schema 版本,并把这件事报给 Boss。
1382
+ const ver = facts.stateDir ? writeSchemaVersion(facts.stateDir, 'board', UPGRADE_SCHEMA_TO) : false
1383
+ report.schema = { ok: true, migrated: false, version: ver ? UPGRADE_SCHEMA_TO : readSchemaVersion(facts.stateDir, 'board'), skipped: '无 board.jsonl(快照-only),仅记录 schema 版本' }
1384
+ if (targets.size > 0) report.bossTouched.pending = targets.size
1385
+ } else {
1386
+ // 把「每个任务的最后一条事件」的行号先算出来(只认字段齐全的事件)
1387
+ const lastIndex = new Map()
1388
+ facts.boardEvents.forEach((ev, index) => {
1389
+ if (ev && typeof ev.taskId === 'string' && ev.after && typeof ev.after.id === 'string') {
1390
+ lastIndex.set(ev.taskId, index)
1391
+ }
1392
+ })
1393
+ const currentVersion = readSchemaVersion(facts.stateDir, 'board')
1394
+
1395
+ if (targets.size > 0) {
1396
+ // ⚠️ 为什么这里可能**不能**直接走 store.migrate:它是版本门控的 ——
1397
+ // 当前版本已等于 to 时它直接 `migrated:false` 且**完全不调 migrateFn**。
1398
+ // 首次迁移跑完后 schema 已是 v2,此时若**又新增**了 Boss 建的任务
1399
+ // (新任务没有 bossTouched),再跑迁移就会被版本门挡掉 ⇒ 永远补不上。
1400
+ // 所以:版本还没到 v2 时走 store.migrate(复用它的原子写 + 坏行保护);
1401
+ // 版本已到 v2 但仍有要补的任务 ⇒ 必须绕过门控自己写,否则静默漏补。
1402
+ const merge = (rows) => {
1403
+ for (let i = 0; i < rows.length; i += 1) {
1404
+ const ev = rows[i]
1405
+ if (!ev || !targets.has(ev.taskId)) continue
1406
+ if (lastIndex.get(ev.taskId) !== i) continue
1407
+ if (!ev.after || typeof ev.after !== 'object') continue
1408
+ if (ev.after.bossTouched === true) continue
1409
+ // ⚠️ 只加一个字段;rev / id / 其余字段**一律不动**
1410
+ ev.after.bossTouched = true
1411
+ report.bossTouched.updated += 1
1412
+ }
1413
+ return rows
1414
+ }
1415
+
1416
+ if (currentVersion === UPGRADE_SCHEMA_TO) {
1417
+ // ⚠️ 绕门控前必须自查坏行(等价于 store.migrate 内部那道抛错保护)。
1418
+ // 原因:`facts.boardEvents` 来自 `readJsonl()`,它**会跳过解析不了的坏行**
1419
+ // 只把条数记进 `facts.boardBad`。下面是把整份事件流在内存里改完再整体重写,
1420
+ // 一旦文件里真有坏行,坏行会被**永久抹掉** —— 而这条分支恰恰是最常走的
1421
+ // (schema 已 v2 之后每次迁移都走它)。宁可拒绝,也不静默丢证据。
1422
+ if (facts.boardBad > 0) {
1423
+ const error = new Error(
1424
+ `迁移中止:board.jsonl 有 ${facts.boardBad} 行无法解析——`
1425
+ + '拒绝在不完整数据上重写(否则坏行会被永久抹掉)。'
1426
+ + `请先修文件,或从备份 ${backup.path} 恢复。未写入任何改动。`,
1427
+ )
1428
+ error.status = 500
1429
+ error.code = 'UPGRADE_BAD_ROWS'
1430
+ throw error
1431
+ }
1432
+ // 绕门控:按 store.migrate 的同一套原子写形态(tmp → rename)改回去
1433
+ const jsonlPath = statePath(facts.stateDir, 'board.jsonl')
1434
+ const nextRows = merge(facts.boardEvents.slice())
1435
+ const tmp = `${jsonlPath}.tmp-upgrade09`
1436
+ writeFileSync(tmp, nextRows.map((r) => JSON.stringify(r)).join('\n') + (nextRows.length ? '\n' : ''), 'utf8')
1437
+ renameSync(tmp, jsonlPath)
1438
+ report.schema = { ok: true, migrated: true, version: UPGRADE_SCHEMA_TO, bypassedVersionGate: true }
1439
+ report.bossTouched.bypassedGate = true
1440
+ } else {
1441
+ report.schema = migrate(facts.stateDir, {
1442
+ name: 'board',
1443
+ to: UPGRADE_SCHEMA_TO,
1444
+ migrateFn: merge,
1445
+ })
1446
+ }
1447
+ } else {
1448
+ // 没有要补的任务,但仍要把 schema 版本记上(幂等:已是最新则不改文件)
1449
+ report.schema = migrate(facts.stateDir, {
1450
+ name: 'board', to: UPGRADE_SCHEMA_TO, migrateFn: (rows) => rows,
1451
+ })
1452
+ }
1453
+ }
1454
+
1455
+ // ③ 已完成长任务 + 没有任何 phase 行 ⇒ 按 task.artifacts 回填一条末阶段产物
1456
+ const phaseRowsByTask = new Map()
1457
+ for (const row of facts.metaRows) {
1458
+ if (row?.kind !== 'phase' || typeof row.taskId !== 'string') continue
1459
+ phaseRowsByTask.set(row.taskId, (phaseRowsByTask.get(row.taskId) ?? 0) + 1)
1460
+ }
1461
+ for (const task of facts.tasks) {
1462
+ if (!task || task.status !== 'completed') continue
1463
+ if ((phaseRowsByTask.get(task.id) ?? 0) > 0) continue // 契约:已有 phase 行不动
1464
+ const longrun = readLongrunMeta(services, task.id)
1465
+ const titles = longrunPhaseList(longrun)
1466
+ if (titles.length === 0) continue
1467
+ const artifacts = Array.isArray(task.artifacts) ? task.artifacts.filter(
1468
+ (a) => typeof a === 'string' && a.trim() !== '',
1469
+ ) : []
1470
+ const record = {
1471
+ kind: 'phase',
1472
+ taskId: task.id,
1473
+ index: titles.length - 1,
1474
+ state: 'done',
1475
+ artifacts,
1476
+ note: UPGRADE_PHASE_NOTE,
1477
+ by: by,
1478
+ at: Date.now(),
1479
+ }
1480
+ if (!appendJsonl(facts.metaFile, record)) {
1481
+ const error = new Error(
1482
+ `迁移中止:阶段产物回填落盘失败(${facts.metaFile})——`
1483
+ + `已备份在 ${backup.path},可用 restoreState 回滚`,
1484
+ )
1485
+ error.status = 500
1486
+ error.code = 'UPGRADE_PHASE_APPEND_FAILED'
1487
+ throw error
1488
+ }
1489
+ report.phases.backfilled += 1
1490
+ report.phases.tasks.push(task.id)
1491
+ }
1492
+
1493
+ // ④ 内存态跟磁盘对齐:迁移改了事件流,板必须重放,否则后续快照会用旧内存覆盖认知
1494
+ try {
1495
+ if (typeof services?.board?.replay === 'function') report.replayed = services.board.replay()
1496
+ } catch (error) {
1497
+ report.replayError = `迁移已落盘,但内存重放失败:${error?.message ?? error}——建议重启以加载完整状态`
1498
+ }
1499
+
1500
+ return report
1501
+ }
1502
+
1503
+ /**
1504
+ * 给团队自动配一个「议事厅」(契约 §3)。
1505
+ *
1506
+ * ## 为什么
1507
+ * 建了团队却没人知道去哪儿说话 —— 团队页只是名单,真正的协作发生在房间。
1508
+ * 所以建团队时顺手把房间建好、把人拉全、把关联记到团队上。
1509
+ *
1510
+ * ## 失败不阻断
1511
+ * 房间服务未装配、房间重名、房间目录不可写……都只回 `roomError` 一句人话。
1512
+ * **团队已经建成了**,因为「房间没配上」就回滚团队,等于用次要功能否掉主要功能。
1513
+ *
1514
+ * ## mode 必须是 admin
1515
+ * `room.js` 只在 `mode:'admin'` 下接受 `adminId`(`everyone` 里传会直接
1516
+ * `ROOM_ADMIN_ID_NOT_APPLICABLE`)。契约要求 `adminId = leadId`,
1517
+ * 所以这里固定用 admin 模式 —— 议事厅由负责人主持,语义也对。
1518
+ *
1519
+ * @param {object} services
1520
+ * @param {object} team teams.create 的返回值(含 id / name / leadId / members)
1521
+ * @param {string} by
1522
+ * @returns {{ room?: object, roomError?: string, attached?: boolean }}
1523
+ */
1524
+ async function ensureTeamRoom(services, team, by) {
1525
+ if (!team || typeof team.id !== 'string') return {}
1526
+ const roomSvc = services?.room
1527
+ if (!roomSvc || typeof roomSvc.create !== 'function') {
1528
+ return { roomError: '房间服务未装配(services.room 缺失)——团队已建好,可稍后在房间页手动建' }
1529
+ }
1530
+
1531
+ // 幂等:团队已有关联房间 ⇒ 不重复建(重复调 /teams 保存不该刷出一串议事厅)
1532
+ if (typeof team.roomId === 'string' && team.roomId) {
1533
+ try {
1534
+ const existing = await roomSvc.get(team.roomId)
1535
+ if (existing) return { room: { id: existing.id, name: existing.name }, attached: false }
1536
+ } catch { /* 房间没了就往下重建 */ }
1537
+ }
1538
+
1539
+ const lead = team.leadId
1540
+ const roomName = `${team.name} · 议事厅`
1541
+ // 负责人必须在成员里(admin 模式下 he 是唯一能发言的人;不在成员表里就等于没人能说话)
1542
+ const members = [...new Set([...(lead ? [lead] : []), ...(Array.isArray(team.members) ? team.members : [])])]
1543
+
1544
+ try {
1545
+ const room = await roomSvc.create({
1546
+ name: roomName,
1547
+ members,
1548
+ mode: 'admin',
1549
+ ...(lead ? { adminId: lead } : {}),
1550
+ purpose: `团队「${team.name}」的自动议事厅:负责人主持,成员在此对齐`,
1551
+ byId: by,
1552
+ })
1553
+ // 关联落库(失败只告警:房间已经能用,关联丢了顶多下次再建一间)
1554
+ let attachError
1555
+ if (services.teams && typeof services.teams.attachRoom === 'function') {
1556
+ try {
1557
+ await services.teams.attachRoom({ id: team.id, roomId: room.id, by })
1558
+ } catch (error) {
1559
+ attachError = `议事厅已建好(${room.id})但未能记到团队上:${error?.message ?? error}`
1560
+ }
1561
+ }
1562
+ return {
1563
+ room: { id: room.id, name: room.name },
1564
+ attached: !attachError,
1565
+ ...(attachError ? { roomError: attachError } : {}),
1566
+ }
1567
+ } catch (error) {
1568
+ return {
1569
+ roomError: `自动建议事厅失败:${error?.message ?? error}`
1570
+ + '——团队已建好,去「房间」页手动建一个并在团队页关联即可',
1571
+ }
1572
+ }
1573
+ }
1574
+
1575
+ /**
1576
+ * 发消息/唤醒时的「自动激活」(契约 §7.2)。
1577
+ *
1578
+ * ## 现场
1579
+ * Boss 给一个停在 pending 的任务发补充说明,说明存下来了、也唤醒了执行者,
1580
+ * 但任务状态还是 pending —— 因为**没人接活**,执行者收到消息也没活可干;
1581
+ * 已经 in_progress 的任务则因为租约到期被系统收回,Boss 一发消息就发现
1582
+ * 「怎么又变回待领了」。两件事的共同点是:**人发了消息,机器没有跟着动**。
1583
+ *
1584
+ * ## 做法
1585
+ * - `in_progress` + 有负责人 ⇒ 自动续租(心跳)。失败**不回滚**刚存的补充说明
1586
+ * (说明是人的话,不能因为续租失败就丢掉),只把原因回给页面。
1587
+ * - `pending` + 有负责人 ⇒ 自动接活(claim),成功后由调用方继续唤醒。
1588
+ * - `blocked` ⇒ **不自动解除**(解除阻塞是人的判断,不是机器的),
1589
+ * 只回一句「需要先解除阻塞」。
1590
+ *
1591
+ * 租约本身保留(不引入新状态),只是页面不再把它当回事。
1592
+ *
1593
+ * @param {object} services
1594
+ * @param {object} task board 任务快照
1595
+ * @param {string} by 操作者
1596
+ * @returns {Promise<{ claimed?: object, heartbeat?: object, reason?: string }>}
1597
+ */
1598
+ async function autoActivate(services, task, by) {
1599
+ const out = {}
1600
+ if (!task || typeof task.id !== 'string') return out
1601
+ const board = services?.board
1602
+ if (!board || typeof board.update !== 'function') {
1603
+ return { reason: 'board 服务未装配,无法自动激活' }
1604
+ }
1605
+ const ownerId = task.ownerId ?? task.assignee ?? null
1606
+ if (!ownerId) {
1607
+ return { reason: '任务还没有负责人,先指派给一个 Bot 才能自动激活' }
1608
+ }
1609
+
1610
+ // blocked:**绝不自动解除** —— 解除阻塞是人的判断
1611
+ if (task.status === 'blocked') {
1612
+ return { reason: '任务处于阻塞状态,需要先解除阻塞:点「解除阻塞」,再发消息' }
1613
+ }
1614
+ if (task.status === 'completed' || task.status === 'deleted') {
1615
+ return { reason: `任务已${task.status === 'completed' ? '完成' : '删除'},无需激活` }
1616
+ }
1617
+
1618
+ // ① 已在进行中:续租(心跳)。失败不改变任何已落盘的事实。
1619
+ if (task.status === 'in_progress' && task.lease) {
1620
+ try {
1621
+ await board.update({ id: task.id, expectedRev: task.rev, action: 'heartbeat', byId: ownerId })
1622
+ return { heartbeat: { ownerId, ok: true } }
1623
+ } catch (error) {
1624
+ return {
1625
+ heartbeat: { ownerId, ok: false },
1626
+ reason: `续租失败:${error?.message ?? error}`
1627
+ + '(补充说明已保存,任务状态未改)',
1628
+ }
1629
+ }
1630
+ }
1631
+
1632
+ // ② 待领 / 在进行中但租约已丢:替他接活
1633
+ if (task.status === 'pending' || task.status === 'in_progress') {
1634
+ try {
1635
+ await board.update({ id: task.id, expectedRev: task.rev, action: 'claim', byId: ownerId })
1636
+ return { claimed: { ownerId, ok: true } }
1637
+ } catch (error) {
1638
+ return { reason: `自动接活失败:${error?.message ?? error}` }
1639
+ }
1640
+ }
1641
+
1642
+ return out
1643
+ }
1644
+
1645
+ /**
1646
+ * 任务诊断(契约 §5):把「为什么这个任务没产出」翻译成一句人话 + 一个下一步按钮。
1647
+ *
1648
+ * ## 现场
1649
+ * Boss 报:「调研专家《调研专家测试001》已多次指派,subagent 持续失败,无产出。」
1650
+ * 查 `wakes.jsonl` 全是 `result:'failed' / detail:'agent-not-live'` ——
1651
+ * 也就是**执行者会话根本不在**,于是唤醒全部失败、任务永远停在 pending、
1652
+ * 页面上什么都看不见(静默失败)。
1653
+ *
1654
+ * ## 做法
1655
+ * 不猜。逐项读真实服务:roster(Bot 是谁)、getAgent(会话在不在)、
1656
+ * `agent.model.current()`(有没有模型路由)、dispatcher.list(唤醒有没有失败)。
1657
+ * 每一项的失败都映射成中文理由 + 下一步。输出 `verdict`:
1658
+ * `ok` = 可以干活;`blocked` = 有明确的卡点(点建议里的按钮就能解)。
1659
+ *
1660
+ * @param {object} ctx @param {object} services @param {object} task
1661
+ */
1662
+ async function diagnoseTask(ctx, services, task) {
1663
+ const taskId = task?.id ?? task?.taskId ?? '(未知)'
1664
+ const ownerId = task?.ownerId ?? task?.assignee ?? null
1665
+
1666
+ // ── 1) 执行者是谁(roster)──────────────────────────────────────────
1667
+ let botName = ownerId ? String(ownerId).slice(-8) : ''
1668
+ let rosterPreset
1669
+ try {
1670
+ const bots = services?.roster?.list?.() ?? []
1671
+ const hit = bots.find((b) => b?.sessionId === ownerId)
1672
+ if (hit?.name) botName = hit.name
1673
+ if (hit?.preset) rosterPreset = String(hit.preset)
1674
+ } catch { /* roster 不在就继续,名字退化成 id 缩写 */ }
1675
+
1676
+ // ── 2) 会话在不在(getAgent 是真查,不判 roster.state 这种缓存)──────
1677
+ let live = false
1678
+ try { live = Boolean(getAgent(ctx, ownerId)) } catch { live = false }
1679
+
1680
+ // ── 3) 模型路由(assertDeliverable 同一套判定 —— 不写两遍)─────────
1681
+ const deliverable = live ? assertDeliverable(ctx, ownerId) : { ok: true, reason: '(会话不在线,先恢复会话再判定模型路由)' }
1682
+
1683
+ // ── 4) preset 是否真的能解析(老数据可能存了个已删的 preset id)─────
1684
+ let presetResolved = true
1685
+ try {
1686
+ const session = sessionMeta(ctx, ownerId)
1687
+ const wanted = rosterPreset ?? (typeof session?.agentPreset === 'string' ? session.agentPreset : undefined)
1688
+ if (wanted) {
1689
+ const known = await listPresets(ctx)
1690
+ presetResolved = Array.isArray(known) && known.some((p) => p && (p.id === wanted || p.name === wanted))
1691
+ }
1692
+ } catch { presetResolved = rosterPreset !== undefined }
1693
+ const hasModelRoute = Boolean(deliverable?.ok)
1694
+
1695
+ // ── 5) 唤醒记录(真实落盘的 wakes.jsonl)───────────────────────────
1696
+ const wakes = readTaskWakes(services, taskId)
1697
+ .slice(-10)
1698
+ .map((row) => {
1699
+ const at = row?.updatedAt ?? row?.sentAt ?? row?.scheduledAt ?? null
1700
+ return {
1701
+ at: Number.isFinite(Number(at)) ? Number(at) : null,
1702
+ result: row?.result ?? 'unknown',
1703
+ reason: row?.detail ?? row?.reason ?? null,
1704
+ }
1705
+ })
1706
+
1707
+ // ── 6) verdict + advice(两句:一句说清,一句说下一步点哪)─────────
1708
+ const blockers = []
1709
+ if (!live) blockers.push('agent-not-live')
1710
+ if (deliverable && deliverable.ok === false) blockers.push('undeliverable')
1711
+ if (!presetResolved) blockers.push('preset-unresolved')
1712
+ if (wakes.some((w) => w.result === 'failed')) blockers.push('wake-failed')
1713
+
1714
+ const verdict = blockers.length ? 'blocked' : 'ok'
1715
+ let advice
1716
+ if (!live) {
1717
+ advice = `执行者「${botName}」的会话当前不在线,唤醒消息投不出去 —— 点「重新派活」重新拉起会话;若连续失败,去「Bot 注册表」看它是否已退役或被回收。`
1718
+ } else if (deliverable && deliverable.ok === false) {
1719
+ advice = `「${botName}」没有模型路由,唤醒后第一轮必败 —— 去「Bot 档案」给它选一个模型,保存后再点「重新派活」。`
1720
+ } else if (!presetResolved) {
1721
+ advice = `「${botName}」用的预制体(preset)已不存在 —— 去「Bot 档案」换一个预制体,否则模型不会加载。`
1722
+ } else if (wakes.some((w) => w.result === 'failed')) {
1723
+ advice = `最近有唤醒失败记录 —— 先点「重新派活」重发,再点「刷新」确认状态;若仍然失败,看下面「唤醒记录」里的 reason 逐条排查。`
1724
+ } else {
1725
+ advice = `执行者「${botName}」已在线且模型路由正常 —— 直接点「唤醒」重发任务即可。`
1726
+ }
1727
+
1728
+ return {
1729
+ ok: true,
1730
+ taskId,
1731
+ ownerId,
1732
+ bot: {
1733
+ name: botName,
1734
+ hasSession: live,
1735
+ preset: rosterPreset ?? null,
1736
+ presetResolved,
1737
+ hasModelRoute,
1738
+ },
1739
+ live: { ok: live, reason: live ? '会话在线' : '会话不在线(agent-not-live)' },
1740
+ deliverable,
1741
+ wakes,
1742
+ verdict,
1743
+ advice,
1744
+ }
1745
+ }
1746
+
910
1747
  async function ensureTeamForBot(services, botSessionId, by) {
911
1748
  const teams = services.teams
912
1749
  if (!teams || typeof teams.create !== 'function' || typeof botSessionId !== 'string' || !botSessionId) {
@@ -1349,8 +2186,22 @@ export function installApi(ctx, { warn } = {}) {
1349
2186
  // 现在所有请求先过 fence.judgeHttp:宿主不认证就一律 403。
1350
2187
  const fence = createFence({ ctx, warn })
1351
2188
 
2189
+ // ── 升级09 §2.3:把「写阶段产出物」挂到 services 上 ─────────────────────
2190
+ // `src/tools.js` 的 `bot_task_phase`(C 交付)读的是 `services.phaseWrite`;
2191
+ // HTTP `POST /tasks/phase` 与它调用**同一份** writePhaseRow,
2192
+ // 不写两套校验 —— 否则「页面能写、Bot 写不进」这种漂移必现。
2193
+ // 注入放在这里(而不是 index.js)是因为写入逻辑属于 API 层,
2194
+ // 且必须复用 api.js 内那份 appendTaskMeta 实现。
2195
+ const mountPhaseWrite = (services) => {
2196
+ if (!services || typeof services !== 'object') return
2197
+ services.phaseWrite = (taskId, patch) => writePhaseRow(services, taskId, patch)
2198
+ }
2199
+ mountPhaseWrite(getServices(ctx))
2200
+
1352
2201
  const handler = async (request, response) => {
1353
2202
  const services = getServices(ctx)
2203
+ // 幂等重挂:services 若在 installApi 之后才注册,这里补上(写同一个 key)
2204
+ mountPhaseWrite(services)
1354
2205
  // 鉴权先于一切业务逻辑(连 URL 解析都不做,避免攻击面)
1355
2206
  const verdict = fence.judgeHttp(request)
1356
2207
  if (!verdict.ok) {
@@ -1369,6 +2220,18 @@ export function installApi(ctx, { warn } = {}) {
1369
2220
 
1370
2221
  try {
1371
2222
  // ── 读:状态总览 ──────────────────────────────────────────────────────
2223
+ // ── 升级接续(契约 §8.2):先看后动,动前先备份 ──────────────────────
2224
+ if (request.method === 'GET' && path === '/upgrade/preflight') {
2225
+ sendJson(response, 200, upgradePreflight(services))
2226
+ return
2227
+ }
2228
+ if (request.method === 'POST' && path === '/upgrade/migrate') {
2229
+ const body = (await readBody(request)) ?? {}
2230
+ const by = typeof body.by === 'string' && body.by.trim() ? body.by.trim() : OWNER
2231
+ sendJson(response, 200, await upgradeMigrate(services, by))
2232
+ return
2233
+ }
2234
+
1372
2235
  if (request.method === 'GET' && path === '/status') {
1373
2236
  const out = { ok: true, services: {}, compat: services.compat ?? null }
1374
2237
  // 状态面要覆盖**全部**域服务:新增模块若不上报,页面「系统体检」就会
@@ -2197,6 +3060,95 @@ export function installApi(ctx, { warn } = {}) {
2197
3060
  return
2198
3061
  }
2199
3062
 
3063
+ // ── 阶段产出物(契约 §2.2 / §2.3 / §4.2)────────────────────────────
3064
+ if (request.method === 'GET' && path === '/tasks/phases') {
3065
+ const id = qp(url, 'id')
3066
+ if (!id) {
3067
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
3068
+ return
3069
+ }
3070
+ const task = await withService(services, 'board', (b) => b.get(id))
3071
+ if (!task) {
3072
+ sendJson(response, 404, { ok: false, reason: `任务 ${id} 不存在` })
3073
+ return
3074
+ }
3075
+ sendJson(response, 200, { ok: true, ...buildPhaseView(services, task) })
3076
+ return
3077
+ }
3078
+
3079
+ if (request.method === 'POST' && path === '/tasks/phase') {
3080
+ const body = (await readBody(request)) ?? {}
3081
+ const out = writePhaseRow(services, body.id, {
3082
+ index: Number.isInteger(body.index) ? body.index : Number(body.index),
3083
+ state: body.state,
3084
+ artifacts: body.artifacts,
3085
+ note: body.note,
3086
+ by: body.by ?? OWNER,
3087
+ ...(body.expectedRev === undefined ? {} : { expectedRev: body.expectedRev }),
3088
+ })
3089
+ sendJson(response, 200, out)
3090
+ return
3091
+ }
3092
+
3093
+ if (request.method === 'POST' && path === '/tasks/phases') {
3094
+ const body = (await readBody(request)) ?? {}
3095
+ const id = typeof body.id === 'string' ? body.id.trim() : ''
3096
+ if (!id) {
3097
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
3098
+ return
3099
+ }
3100
+ const task = await withService(services, 'board', (b) => b.get(id))
3101
+ if (!task) {
3102
+ sendJson(response, 404, { ok: false, reason: `任务 ${id} 不存在` })
3103
+ return
3104
+ }
3105
+ if (body.expectedRev !== undefined && body.expectedRev !== null) {
3106
+ if (!Number.isInteger(body.expectedRev)) {
3107
+ sendJson(response, 400, { ok: false, reason: `expectedRev 必须是整数(当前 rev=${task.rev})` })
3108
+ return
3109
+ }
3110
+ if (body.expectedRev !== task.rev) {
3111
+ sendJson(response, 409, {
3112
+ ok: false,
3113
+ reason: `CAS 冲突:任务 ${id} 期望 rev=${body.expectedRev},实际 rev=${task.rev} —— 请刷新后重试`,
3114
+ })
3115
+ return
3116
+ }
3117
+ }
3118
+ const phases = normalizePhases(body.phases, 'TASK_BAD_PHASES')
3119
+ const by = typeof body.by === 'string' && body.by.trim() ? body.by.trim() : OWNER
3120
+ let note = 'Boss 调整阶段清单'
3121
+ if (body.note !== undefined && body.note !== null) {
3122
+ if (typeof body.note !== 'string') {
3123
+ sendJson(response, 400, { ok: false, reason: 'note 必须是字符串(也可以不传)' })
3124
+ return
3125
+ }
3126
+ const text = body.note.trim()
3127
+ if (Array.from(text).length > LONGRUN_PHASE_MAX) {
3128
+ sendJson(response, 400, {
3129
+ ok: false,
3130
+ reason: `note 最多 ${LONGRUN_PHASE_MAX} 字(收到 ${Array.from(text).length} 字)`,
3131
+ })
3132
+ return
3133
+ }
3134
+ if (text) note = text
3135
+ }
3136
+ // 只追加一条 `kind:'longrun'` 行 —— `kind:'phase'` 行**一根都不动**(契约 §4.2):
3137
+ // Boss 改的是计划,已交的证据不因此消失;清单变短时越界产物只是不再展示。
3138
+ appendTaskMeta(services, { kind: 'longrun', taskId: id, phases, note, by, at: Date.now() })
3139
+ const view = buildPhaseView(services, { ...task, id })
3140
+ sendJson(response, 200, {
3141
+ ok: true,
3142
+ taskId: id,
3143
+ phases: view.phases,
3144
+ currentIndex: view.currentIndex,
3145
+ totalArtifacts: view.totalArtifacts,
3146
+ isLongrun: view.isLongrun,
3147
+ note,
3148
+ })
3149
+ return
3150
+ }
3151
+
2200
3152
  // ── 任务:详情穿透 / 指派 / 终止 / 编辑 ──────────────────────────────
2201
3153
  if (request.method === 'GET' && path === '/tasks/detail') {
2202
3154
  const id = qp(url, 'id')
@@ -2221,6 +3173,21 @@ export function installApi(ctx, { warn } = {}) {
2221
3173
  return
2222
3174
  }
2223
3175
 
3176
+ if (request.method === 'GET' && path === '/tasks/diagnose') {
3177
+ const id = qp(url, 'id')
3178
+ if (!id) {
3179
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
3180
+ return
3181
+ }
3182
+ const task = await withService(services, 'board', (b) => b.get(id))
3183
+ if (!task) {
3184
+ sendJson(response, 404, { ok: false, reason: `任务 ${id} 不存在` })
3185
+ return
3186
+ }
3187
+ sendJson(response, 200, await diagnoseTask(ctx, services, task))
3188
+ return
3189
+ }
3190
+
2224
3191
  if (request.method === 'POST' && path === '/tasks/assign') {
2225
3192
  const body = (await readBody(request)) ?? {}
2226
3193
  if (!body.id || !body.ownerId) {
@@ -2431,6 +3398,8 @@ export function installApi(ctx, { warn } = {}) {
2431
3398
 
2432
3399
  let wake = null
2433
3400
  let revive
3401
+ // 契约 §7.2:人发了话,机器要跟着动(详见 autoActivate 注释)
3402
+ const activated = await autoActivate(services, task, note.by)
2434
3403
  if (shouldWake) {
2435
3404
  // 契约 bots05 §2.3:投递前先确保 owner 会话 live(失败不阻断,wake 会如实报错)。
2436
3405
  revive = await ensureLive(ctx, task.ownerId)
@@ -2447,7 +3416,7 @@ export function installApi(ctx, { warn } = {}) {
2447
3416
  })
2448
3417
  armDeliveryWatch(services, task.id, task.ownerId, noteBaseline, wake)
2449
3418
  }
2450
- sendJson(response, 200, { ok: true, note, wake, ...reviveFields(revive) })
3419
+ sendJson(response, 200, { ok: true, note, wake, activated, ...reviveFields(revive) })
2451
3420
  return
2452
3421
  }
2453
3422
 
@@ -2588,10 +3557,16 @@ export function installApi(ctx, { warn } = {}) {
2588
3557
  // dispatcher 缺失 → 503(先于复活副作用,避免「复活了却没地方投递」)。
2589
3558
  const dispatcher = await withService(services, 'dispatcher', (d) => d)
2590
3559
  const revive = await ensureLive(ctx, task.ownerId)
3560
+ // 契约 §7.2:唤醒前顺手把租约续上(进行中 ⇒ 心跳;待领 ⇒ 自动接活)。
3561
+ // ⚠️ 顺序很重要(回归 R15):**先算 dedupKey,再 autoActivate**。
3562
+ // autoActivate 会 bump rev(心跳/认领都写事件),若先激活再算键,
3563
+ // `force:false` 的幂等键 `task-wake:<id>:<rev>` 就会跟着漂,
3564
+ // 契约 §8「同一任务同一 rev 的重试只唤醒一次」被破坏。
2591
3565
  const force = body.force !== false
2592
3566
  const dedupKey = typeof body.dedupKey === 'string' && body.dedupKey.trim()
2593
3567
  ? body.dedupKey.trim()
2594
3568
  : (force ? `task-wake:${task.id}:${Date.now()}` : `task-wake:${task.id}:${task.rev}`)
3569
+ const activated = await autoActivate(services, task, task.ownerId)
2595
3570
  // 2026-11-09:同一道投递能力预检(手动重试也不该往死会话里投)。
2596
3571
  const deliverable = assertDeliverable(ctx, task.ownerId)
2597
3572
  if (!deliverable.ok) {
@@ -2602,6 +3577,7 @@ export function installApi(ctx, { warn } = {}) {
2602
3577
  ownerId: task.ownerId,
2603
3578
  live: revive.live,
2604
3579
  revived: revive.revived,
3580
+ activated,
2605
3581
  marked,
2606
3582
  wake: { result: 'failed', reason: `undeliverable:${deliverable.reason}`, target: task.ownerId },
2607
3583
  })
@@ -2630,6 +3606,7 @@ export function installApi(ctx, { warn } = {}) {
2630
3606
  ownerId: task.ownerId,
2631
3607
  live: revive.live,
2632
3608
  revived: revive.revived,
3609
+ activated,
2633
3610
  ...(revive.live ? {} : { reviveReason: revive.reason ?? 'revive-failed' }),
2634
3611
  wake,
2635
3612
  })
@@ -3964,14 +4941,35 @@ export function installApi(ctx, { warn } = {}) {
3964
4941
  const body = (await readBody(request)) ?? {}
3965
4942
  const out = await withService(services, 'teams', (t) =>
3966
4943
  t.create({ ...body, by: body.by ?? OWNER })).catch(markTeamError)
3967
- sendJson(response, 200, { ok: true, team: out })
4944
+ // 契约 §3:建团队就配议事厅。失败不阻断团队创建,只回一句 roomError。
4945
+ const roomOut = await ensureTeamRoom(services, out, body.by ?? OWNER)
4946
+ sendJson(response, 200, {
4947
+ ok: true, team: out,
4948
+ ...(roomOut.room ? { room: roomOut.room } : {}),
4949
+ ...(roomOut.roomError ? { roomError: roomOut.roomError } : {}),
4950
+ })
3968
4951
  return
3969
4952
  }
3970
4953
  if (request.method === 'POST' && path === '/teams/add') {
3971
4954
  const body = (await readBody(request)) ?? {}
4955
+ const by = body.by ?? OWNER
3972
4956
  const out = await withService(services, 'teams', (t) =>
3973
- t.addMember({ ...body, by: body.by ?? OWNER })).catch(markTeamError)
3974
- sendJson(response, 200, { ok: true, team: out })
4957
+ t.addMember({ ...body, by })).catch(markTeamError)
4958
+ if (out?.ok === false) {
4959
+ sendJson(response, out.status ?? 400, out)
4960
+ return
4961
+ }
4962
+ // 契约 §3:新成员也要进议事厅(否则加了人、人却不在那个房间)
4963
+ let roomError
4964
+ if (typeof out?.roomId === 'string' && out.roomId && body.memberId) {
4965
+ try {
4966
+ await withService(services, 'room', (r) =>
4967
+ r.join({ roomId: out.roomId, sessionId: body.memberId, byId: by }))
4968
+ } catch (error) {
4969
+ roomError = `成员已加入团队,但未能拉进议事厅(${out.roomId}):${error?.message ?? error}`
4970
+ }
4971
+ }
4972
+ sendJson(response, 200, { ok: true, team: out, ...(roomError ? { roomError } : {}) })
3975
4973
  return
3976
4974
  }
3977
4975
  if (request.method === 'POST' && path === '/teams/remove') {