cs-bots 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/LICENSE +21 -0
- package/README.md +164 -2
- package/cordis.patch.agent-team.yml +31 -0
- package/cordis.patch.yml +30 -0
- package/install.sh +218 -0
- package/package.json +57 -4
- package/src/api.js +706 -0
- package/src/board.js +1056 -0
- package/src/botprofile.js +331 -0
- package/src/client.js +2089 -0
- package/src/compat.js +196 -0
- package/src/dirbrowse.js +555 -0
- package/src/dispatcher.js +651 -0
- package/src/index.js +311 -0
- package/src/kernel.js +480 -0
- package/src/library.js +740 -0
- package/src/memory.js +1905 -0
- package/src/registry.js +34 -0
- package/src/room.js +1550 -0
- package/src/roster.js +1119 -0
- package/src/settings.js +107 -0
- package/src/store.js +243 -0
- package/src/tools.js +469 -0
package/src/roster.js
ADDED
|
@@ -0,0 +1,1119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cs-bots RosterService —— Bot 注册表 + **目录隔离**。
|
|
3
|
+
*
|
|
4
|
+
* 设计依据:`multiagent/10-初步架构设计方案-v1.md` §3.1(RosterService)。
|
|
5
|
+
*
|
|
6
|
+
* ── 为什么必须自建(不是重复造轮子,是官方给不了)──────────────────────────
|
|
7
|
+
* 1. **官方 roster 是 flat immutable**(`12-硬约束清单.md` A3,官方 README:205 原文
|
|
8
|
+
* "Flat immutable roster — there is no nested Team, rename, deletion, or name reuse.")
|
|
9
|
+
* ⇒ 官方**没有改名、没有删除、名字永不复用**。`retire` / `renameMember` 实测 0 命中。
|
|
10
|
+
* ⇒ 本模块的 `rename` / `retire` 是官方**结构上做不到**的能力。
|
|
11
|
+
* 2. **官方 `spawn_teammate` 无法指定工作目录**(A1/A2,`SpawnTeammateRequest` 无 `cwd`
|
|
12
|
+
* 字段,teammate 强制继承父会话 cwd),而 **cwd 同时就是 fs 写入边界**。
|
|
13
|
+
* ⇒ 持久 Bot 必须走 **root agent**:`kernel.createSession(ctx,{sessionId,cwd})`
|
|
14
|
+
* (`kernel.js:249-287` 明确标注这是「唯一能指定 cwd 的路径」)。
|
|
15
|
+
* ⇒ 所以 `cwd` 在本模块是**必交付能力**,不是可选字段(`11-融合最终稿-v1.md` §0.2 第 1 条:
|
|
16
|
+
* "`RosterService.cwd` 从『字段』升格为『必交付能力』")。
|
|
17
|
+
* 3. `retired` 是**终态但释放名额**:名字可复用、cwd 可回收(§3.1 "关键" 段)。
|
|
18
|
+
*
|
|
19
|
+
* ── 相对 §3.1 草案的三处**必要增量**(不改既有字段语义,纯增量)──
|
|
20
|
+
* `rev` —— 草案未列,但 `expectedRev` 的 CAS 要求 Bot 自身携带版本号
|
|
21
|
+
* (与 Task.rev 同理)。没有它 CAS 无从比较。
|
|
22
|
+
* `createdBy` / `retiredReason` —— 审计用(谁是创建者/为何退役),便于 `list()` 直接展示。
|
|
23
|
+
* `claim` / `release` —— 草案只给了 `lease` 字段与 `heartbeat` / `expireLeases`,
|
|
24
|
+
* 但**没有获取租约的入口**(租约无从产生 ⇒ 心跳是死代码)。
|
|
25
|
+
* 故补上最小闭环,语义与 board.js 的 claim/release 对齐。
|
|
26
|
+
*
|
|
27
|
+
* ── 三条不变量(全模块机械保证,照 board.js 的做法)──
|
|
28
|
+
* 1. **CAS 优先**:每次变更带 `expectedRev`,不匹配即抛错,绝不静默覆盖。
|
|
29
|
+
* 2. **事件流是真相源**:先 append `roster.jsonl`(失败即抛错,内存原封不动),
|
|
30
|
+
* 再更新内存,最后原子写 `roster.snap.json`(快照失败只降级 warn——事件流已落盘,
|
|
31
|
+
* `replay()` 能完整重建)。
|
|
32
|
+
* 3. **绝不静默失败**:缺字段、同名、非法 cwd、会话创建失败、操作已退役 Bot,
|
|
33
|
+
* 一律抛**结构化错误**(`RosterError` + 机读 `code`)。
|
|
34
|
+
*
|
|
35
|
+
* ── 名称唯一性与「名额释放」的关系(重要,勿简化)──
|
|
36
|
+
* 唯一性只在**未退役**的 Bot 之间生效(`nameIndex` 不含 retired)。
|
|
37
|
+
* ⇒ `rename` 后旧名立刻可被新 Bot 复用;
|
|
38
|
+
* ⇒ `retire` 后同名的**新** Bot 可创建(这就是草案说的「名字释放」)。
|
|
39
|
+
* 比较口径:`trim()` + NFKC 归一 + `toLowerCase()`(**大小写不敏感**,见 `nameKey()`)。
|
|
40
|
+
*
|
|
41
|
+
* 依赖面:node 内置(`node:crypto` / `node:path`)+ `./store.js` + `./kernel.js`。
|
|
42
|
+
* **不 import 任何 `@deepseek-ai/*`**(`test/ci-zero-dep.sh` 检查 1 机械强制)。
|
|
43
|
+
*
|
|
44
|
+
* `kernel.js` 用**静态 import**:它自身对宿主包是惰性 `createRequire`(`kernel.js:17-25`),
|
|
45
|
+
* 裸检出(无 node_modules)下照样能加载 —— 与 `src/dispatcher.js:74` 的既有做法一致
|
|
46
|
+
* (`src/board.js` 头部的动态导入注释是 2026-11-01 早期的判断,disp 已证伪;本文件不复制它)。
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { randomUUID } from 'node:crypto'
|
|
50
|
+
import { isAbsolute, resolve, sep } from 'node:path'
|
|
51
|
+
|
|
52
|
+
import {
|
|
53
|
+
appendJsonl,
|
|
54
|
+
ensureDir,
|
|
55
|
+
readJson,
|
|
56
|
+
readJsonl,
|
|
57
|
+
statePath,
|
|
58
|
+
writeJsonAtomic,
|
|
59
|
+
} from './store.js'
|
|
60
|
+
import * as kernel from './kernel.js'
|
|
61
|
+
import { normalizeProfile, writeAgentsFile, readAgentsFile, writeSkill } from './botprofile.js'
|
|
62
|
+
|
|
63
|
+
// ── 常量 ────────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/** 唯一合法状态集合(★ `retired` 是终态但**释放名额**,见文件头)。 */
|
|
66
|
+
const STATES = ['idle', 'running', 'parked', 'failed', 'retired']
|
|
67
|
+
/** 未退役状态(唯一性/可操作性判定用)。 */
|
|
68
|
+
const LIVE_STATES = STATES.filter((s) => s !== 'retired')
|
|
69
|
+
/** 合法权限档位(§3.1)。 */
|
|
70
|
+
const PERMISSIONS = ['read-only', 'workspace-write', 'full']
|
|
71
|
+
/** 权限缺省:Bot 需要在自己 cwd 内干活,故缺省给写权限(cwd 即写入边界)。 */
|
|
72
|
+
const DEFAULT_PERMISSION = 'workspace-write'
|
|
73
|
+
/** `label` 上限(§3.1)。 */
|
|
74
|
+
const MAX_LABEL = 24
|
|
75
|
+
/** 租约缺省 TTL(ms),与 board 的 300s 对齐。 */
|
|
76
|
+
const DEFAULT_LEASE_MS = 300_000
|
|
77
|
+
/** 事件动作集合(审计流可枚举)。 */
|
|
78
|
+
const ACTIONS = ['create', 'rename', 'retire', 'set_cwd', 'claim', 'release', 'heartbeat', 'lease_expired']
|
|
79
|
+
|
|
80
|
+
// ── 错误 ────────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/** 注册表结构化错误。所有失败都走它,**绝不静默**。 */
|
|
83
|
+
export class RosterError extends Error {
|
|
84
|
+
/**
|
|
85
|
+
* @param {string} message 人类可读、含定位信息(bot id / rev / 字段名)
|
|
86
|
+
* @param {string} [code]
|
|
87
|
+
* @param {object} [details]
|
|
88
|
+
*/
|
|
89
|
+
constructor(message, code = 'ROSTER_ERROR', details) {
|
|
90
|
+
super(message)
|
|
91
|
+
this.name = 'RosterError'
|
|
92
|
+
this.code = code
|
|
93
|
+
if (details !== undefined) this.details = details
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── 小工具 ──────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 深拷贝 + **JSON 归一**(去掉值为 undefined 的键)。
|
|
101
|
+
* 必须走 JSON 而不是 structuredClone:否则内存态会比磁盘态多出 `{lease: undefined}`
|
|
102
|
+
* 这类键,导致 `replay()` 重建后的 `list()` 与原值不等(board.js:94-102 同类踩坑)。
|
|
103
|
+
*/
|
|
104
|
+
function clone(value) {
|
|
105
|
+
if (value === undefined || value === null) return value
|
|
106
|
+
return JSON.parse(JSON.stringify(value))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 非空字符串字段校验(缺字段/类型错的**唯一**出口)。 */
|
|
110
|
+
function requireString(value, field, where) {
|
|
111
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
112
|
+
throw new RosterError(
|
|
113
|
+
`roster.${where}:字段 ${field} 缺失或类型错误(要求非空字符串,收到 ${JSON.stringify(value)})`,
|
|
114
|
+
'ROSTER_MISSING_FIELD',
|
|
115
|
+
{ field, where },
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
return value
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 可选字符串字段校验(`null` 视为「显式清空」,由调用方处理)。 */
|
|
122
|
+
function optionalString(value, field, where) {
|
|
123
|
+
if (value === undefined || value === null) return undefined
|
|
124
|
+
if (typeof value !== 'string') {
|
|
125
|
+
throw new RosterError(
|
|
126
|
+
`roster.${where}:字段 ${field} 类型错误(要求字符串,收到 ${JSON.stringify(value)})`,
|
|
127
|
+
'ROSTER_BAD_FIELD',
|
|
128
|
+
{ field, where },
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
return value
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 名称归一(**大小写不敏感**的唯一性口径)。 */
|
|
135
|
+
function nameKey(name) {
|
|
136
|
+
return String(name).normalize('NFKC').trim().toLowerCase()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 时间参数归一:缺省 now;支持 ms 数字与 Date。 */
|
|
140
|
+
function normalizeNow(value, where) {
|
|
141
|
+
if (value === undefined || value === null) return Date.now()
|
|
142
|
+
if (value instanceof Date) return value.getTime()
|
|
143
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
144
|
+
throw new RosterError(
|
|
145
|
+
`roster.${where}:now 必须是毫秒时间戳或 Date,收到 ${JSON.stringify(value)}`,
|
|
146
|
+
'ROSTER_BAD_NOW',
|
|
147
|
+
{ where },
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 可选整数(租约 TTL 等)。 */
|
|
152
|
+
function optionalNumber(value, field, where) {
|
|
153
|
+
if (value === undefined || value === null) return undefined
|
|
154
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
155
|
+
throw new RosterError(
|
|
156
|
+
`roster.${where}:字段 ${field} 类型错误(要求有限数字,收到 ${JSON.stringify(value)})`,
|
|
157
|
+
'ROSTER_BAD_FIELD',
|
|
158
|
+
{ field, where },
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
return value
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 权限档位校验。 */
|
|
165
|
+
function normalizePermission(value, where) {
|
|
166
|
+
if (value === undefined || value === null) return DEFAULT_PERMISSION
|
|
167
|
+
if (!PERMISSIONS.includes(value)) {
|
|
168
|
+
throw new RosterError(
|
|
169
|
+
`roster.${where}:非法 permission=${JSON.stringify(value)}(合法值:${PERMISSIONS.join(' | ')})`,
|
|
170
|
+
'ROSTER_BAD_PERMISSION',
|
|
171
|
+
{ permission: value, allowed: PERMISSIONS },
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
return value
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** `label` 校验(≤24)。 */
|
|
178
|
+
function normalizeLabel(value, where) {
|
|
179
|
+
const raw = optionalString(value, 'label', where)
|
|
180
|
+
if (raw === undefined) return undefined
|
|
181
|
+
if (raw.length > MAX_LABEL) {
|
|
182
|
+
throw new RosterError(
|
|
183
|
+
`roster.${where}:label 超长(${raw.length} > ${MAX_LABEL} 字符上限)`,
|
|
184
|
+
'ROSTER_LABEL_TOO_LONG',
|
|
185
|
+
{ length: raw.length, max: MAX_LABEL },
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
return raw
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 状态筛选值校验。 */
|
|
192
|
+
function normalizeStateFilter(value) {
|
|
193
|
+
if (value === undefined || value === null) return undefined
|
|
194
|
+
if (!STATES.includes(value)) {
|
|
195
|
+
throw new RosterError(
|
|
196
|
+
`roster.list:非法 state=${JSON.stringify(value)}(合法值:${STATES.join(' | ')})`,
|
|
197
|
+
'ROSTER_BAD_STATE',
|
|
198
|
+
{ state: value, allowed: STATES },
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
return value
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 平台状态目录根:`$DSH_HOME`(与 `kernel.stateDir()` 同一口径,`kernel.js:365-368`)。
|
|
206
|
+
* **每次调用时求值**(测试会改 env;构造时缓存会让「同一实例两种结果」不可解释)。
|
|
207
|
+
* @returns {string}
|
|
208
|
+
*/
|
|
209
|
+
function dshHomeRoot() {
|
|
210
|
+
const home = process.env.DSH_HOME || `${process.env.HOME ?? '.'}/.dsh`
|
|
211
|
+
return resolve(home)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* cwd 校验(**本模块的核心价值之一:目录隔离**)。
|
|
216
|
+
*
|
|
217
|
+
* 三条硬要求,任一不满足即抛:
|
|
218
|
+
* 1. 必填 + 必须是**绝对路径**(相对路径会随进程 cwd 漂移 ⇒ 写入边界不可预测);
|
|
219
|
+
* 2. **不得指向 `$DSH_HOME`**(那是宿主数据/凭据所在,Bot 写进去就是数据污染);
|
|
220
|
+
* 3. 归一化后不得**落在 `$DSH_HOME` 子树内**(同上,`~/.dsh/cs-bots/x` 一样能污染)。
|
|
221
|
+
*
|
|
222
|
+
* 另加一条**非阻塞**提示:cwd 是 `$DSH_HOME` 的**祖先**时 warn(该 cwd 下的 Bot 理论上能
|
|
223
|
+
* 顺着子路径写进宿主状态目录)——不拒绝,因为那可能正是用户的正常家目录,拒绝属过度拦截。
|
|
224
|
+
*
|
|
225
|
+
* @param {unknown} value
|
|
226
|
+
* @param {string} where
|
|
227
|
+
* @returns {string} 归一化绝对路径
|
|
228
|
+
*/
|
|
229
|
+
function assertBotCwd(value, where) {
|
|
230
|
+
const raw = requireString(value, 'cwd', where)
|
|
231
|
+
if (!isAbsolute(raw)) {
|
|
232
|
+
throw new RosterError(
|
|
233
|
+
`roster.${where}:cwd 必须是**绝对路径**(收到 ${JSON.stringify(raw)})——`
|
|
234
|
+
+ '相对路径会随进程工作目录漂移,无法作为 Bot 的稳定写入边界',
|
|
235
|
+
'ROSTER_BAD_CWD',
|
|
236
|
+
{ cwd: raw, where },
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
const cwd = resolve(raw)
|
|
240
|
+
const home = dshHomeRoot()
|
|
241
|
+
if (cwd === home) {
|
|
242
|
+
throw new RosterError(
|
|
243
|
+
`roster.${where}:cwd 不得指向 $DSH_HOME(${home})——那是宿主数据/凭据目录,Bot 写进去会污染宿主状态`,
|
|
244
|
+
'ROSTER_CWD_IN_DSH_HOME',
|
|
245
|
+
{ cwd, dshHome: home, where },
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
if (cwd.startsWith(home + sep)) {
|
|
249
|
+
throw new RosterError(
|
|
250
|
+
`roster.${where}:cwd(${cwd})位于 $DSH_HOME(${home})子树内——`
|
|
251
|
+
+ '宿主状态目录内的写入同样构成污染,请给 Bot 独立的工作目录',
|
|
252
|
+
'ROSTER_CWD_IN_DSH_HOME',
|
|
253
|
+
{ cwd, dshHome: home, where },
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
return cwd
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 解析状态目录:`config.stateDir` > `kernel.stateDir()` > 抛错。
|
|
261
|
+
* **不做静默兜底**:两条路都不通时必须让调用方看见。
|
|
262
|
+
* @param {{ stateDir?: string }} config
|
|
263
|
+
* @returns {string}
|
|
264
|
+
*/
|
|
265
|
+
function resolveStateDir(config) {
|
|
266
|
+
if (typeof config?.stateDir === 'string' && config.stateDir.trim() !== '') {
|
|
267
|
+
return resolve(config.stateDir.trim())
|
|
268
|
+
}
|
|
269
|
+
if (typeof kernel?.stateDir === 'function') return resolve(kernel.stateDir())
|
|
270
|
+
throw new RosterError(
|
|
271
|
+
'roster.createRoster:既未提供 config.stateDir,也无法从 kernel.js 取得 stateDir。'
|
|
272
|
+
+ '裸检出(无宿主包)下必须显式传 config.stateDir。',
|
|
273
|
+
'ROSTER_NO_STATE_DIR',
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** 从 AgentHandle 取会话 id(宿主返回优先,绝不静默改写调用方给的 id)。 */
|
|
278
|
+
function pickSessionId(handle, requested) {
|
|
279
|
+
if (typeof handle?.sessionId === 'string' && handle.sessionId !== '') return handle.sessionId
|
|
280
|
+
if (typeof handle?.id === 'string' && handle.id !== '') return handle.id
|
|
281
|
+
return requested
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── 工厂 ────────────────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* 创建一个注册表实例。
|
|
288
|
+
*
|
|
289
|
+
* @param {{ logger?: { info?: Function, warn?: Function }, agents?: object, get?: Function }} [ctx]
|
|
290
|
+
* 宿主上下文(透传给 `kernel.createSession`;缺 logger 即降级为 no-op 日志)
|
|
291
|
+
* @param {{ stateDir?: string, leaseMs?: number, name?: string }} [config]
|
|
292
|
+
* @returns {object} roster
|
|
293
|
+
*/
|
|
294
|
+
export function createRoster(ctx, config = {}) {
|
|
295
|
+
const stateDir = resolveStateDir(config)
|
|
296
|
+
const leaseMsDefault = Number.isFinite(config.leaseMs) && config.leaseMs > 0
|
|
297
|
+
? config.leaseMs
|
|
298
|
+
: DEFAULT_LEASE_MS
|
|
299
|
+
|
|
300
|
+
const jsonlPath = statePath(stateDir, 'roster.jsonl')
|
|
301
|
+
const snapPath = statePath(stateDir, 'roster.snap.json')
|
|
302
|
+
|
|
303
|
+
const info = (...args) => { try { ctx?.logger?.info?.(...args) } catch { /* 日志失败不影响业务 */ } }
|
|
304
|
+
const warn = (...args) => { try { ctx?.logger?.warn?.(...args) } catch { /* 同上 */ } }
|
|
305
|
+
|
|
306
|
+
/** 状态目录是否可写(不可写只降级 warn,不阻塞装配——与 dispatcher.durable 同语义)。 */
|
|
307
|
+
const durable = ensureDir(stateDir)
|
|
308
|
+
if (!durable) warn(`[cs-bots/roster] 状态目录不可写(${stateDir})——注册表变更将无法落盘`)
|
|
309
|
+
|
|
310
|
+
/** @type {Map<string, object>} bot id → Bot(创建序 = Map 插入序) */
|
|
311
|
+
const bots = new Map()
|
|
312
|
+
/** @type {Map<string, string>} nameKey → botId(**只含未退役 Bot**,退役即释放名字) */
|
|
313
|
+
const nameIndex = new Map()
|
|
314
|
+
/** @type {object[]} 全部 BotEvent(审计流,按落盘顺序) */
|
|
315
|
+
const events = []
|
|
316
|
+
|
|
317
|
+
const diagnostics = {
|
|
318
|
+
stateDir,
|
|
319
|
+
jsonlPath,
|
|
320
|
+
snapPath,
|
|
321
|
+
durable,
|
|
322
|
+
kernelAvailable: typeof kernel?.createSession === 'function',
|
|
323
|
+
hydratedFrom: /** @type {null|'jsonl'|'snapshot'|'empty'} */ (null),
|
|
324
|
+
replayedEvents: 0,
|
|
325
|
+
badLines: 0,
|
|
326
|
+
skippedEvents: 0,
|
|
327
|
+
snapshotWriteFailures: 0,
|
|
328
|
+
lastReplayAt: /** @type {number|undefined} */ (undefined),
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ── 持久化 ────────────────────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
/** 快照内容(落盘形态)。 */
|
|
334
|
+
const snapshot = () => ({
|
|
335
|
+
version: 1,
|
|
336
|
+
savedAt: Date.now(),
|
|
337
|
+
bots: [...bots.values()],
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* 把某个 Bot 的**名字占用**同步进 `nameIndex`。
|
|
342
|
+
*
|
|
343
|
+
* ⚠️ 必须**先摘掉该 Bot 曾经占用的所有名字**再登记新名:`rename` 时旧名要**立刻**释放
|
|
344
|
+
* (官方「名字永不复用」的反面,是本模块的核心增量之一)。漏了这一步的后果是
|
|
345
|
+
* 「改名后旧名永久占用」,与 `retire` 释放名额的语义自相矛盾。
|
|
346
|
+
*
|
|
347
|
+
* @param {object} bot
|
|
348
|
+
*/
|
|
349
|
+
function reindexName(bot) {
|
|
350
|
+
for (const [k, holderId] of nameIndex) {
|
|
351
|
+
if (holderId === bot.id) nameIndex.delete(k)
|
|
352
|
+
}
|
|
353
|
+
if (bot.state === 'retired') return
|
|
354
|
+
nameIndex.set(nameKey(bot.name), bot.id)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* 提交一次变更:**先落事件,再改内存,最后写快照**。
|
|
359
|
+
*
|
|
360
|
+
* 顺序不可颠倒:append 失败时内存必须原封不动(否则内存与磁盘分叉,
|
|
361
|
+
* 重启回放后状态凭空回退,属于最恶劣的静默失败)。
|
|
362
|
+
*
|
|
363
|
+
* @param {string} botId
|
|
364
|
+
* @param {string} action
|
|
365
|
+
* @param {string|null} byId
|
|
366
|
+
* @param {number} at
|
|
367
|
+
* @param {object|null} before 变更前 Bot(create 为 null)
|
|
368
|
+
* @param {object} after 变更后 Bot
|
|
369
|
+
* @returns {object} 克隆后的 after
|
|
370
|
+
*/
|
|
371
|
+
function commit(botId, action, byId, at, before, after) {
|
|
372
|
+
const event = {
|
|
373
|
+
botId,
|
|
374
|
+
rev: after.rev,
|
|
375
|
+
action,
|
|
376
|
+
byId: byId ?? null,
|
|
377
|
+
at,
|
|
378
|
+
before: before ? clone(before) : null,
|
|
379
|
+
after: clone(after),
|
|
380
|
+
}
|
|
381
|
+
if (!appendJsonl(jsonlPath, event)) {
|
|
382
|
+
throw new RosterError(
|
|
383
|
+
`roster.${action}:事件追加失败(${jsonlPath})——拒绝「只改内存不落盘」`,
|
|
384
|
+
'ROSTER_APPEND_FAILED',
|
|
385
|
+
{ botId, action },
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
bots.set(botId, clone(after))
|
|
389
|
+
events.push(event)
|
|
390
|
+
reindexName(after)
|
|
391
|
+
if (!writeJsonAtomic(snapPath, snapshot())) {
|
|
392
|
+
diagnostics.snapshotWriteFailures += 1
|
|
393
|
+
warn(`[cs-bots/roster] 快照写入失败(${snapPath})——事件流已落盘,replay() 可完整恢复`)
|
|
394
|
+
}
|
|
395
|
+
return clone(after)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ── 回放 ──────────────────────────────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* 从 `roster.jsonl`(真相源)重建内存态;jsonl 为空才回落到 `roster.snap.json`。
|
|
402
|
+
* 坏行/skipped 事件**不静默丢弃**:计入 diagnostics 并 warn。
|
|
403
|
+
*/
|
|
404
|
+
function hydrate() {
|
|
405
|
+
bots.clear()
|
|
406
|
+
nameIndex.clear()
|
|
407
|
+
events.length = 0
|
|
408
|
+
|
|
409
|
+
const { rows, bad } = readJsonl(jsonlPath)
|
|
410
|
+
diagnostics.badLines = bad
|
|
411
|
+
diagnostics.replayedEvents = rows.length
|
|
412
|
+
diagnostics.skippedEvents = 0
|
|
413
|
+
if (bad > 0) {
|
|
414
|
+
warn(`[cs-bots/roster] ${jsonlPath} 有 ${bad} 行无法解析(撕裂写或文件损坏)——已跳过,请核对`)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (rows.length === 0) {
|
|
418
|
+
const snap = readJson(snapPath, null)
|
|
419
|
+
const snapBots = Array.isArray(snap?.bots) ? snap.bots : null
|
|
420
|
+
if (snapBots) {
|
|
421
|
+
for (const raw of snapBots) {
|
|
422
|
+
if (!raw || typeof raw.id !== 'string') continue
|
|
423
|
+
const b = clone(raw)
|
|
424
|
+
if (typeof b.rev !== 'number') b.rev = 1
|
|
425
|
+
bots.set(b.id, b)
|
|
426
|
+
reindexName(b)
|
|
427
|
+
}
|
|
428
|
+
diagnostics.hydratedFrom = 'snapshot'
|
|
429
|
+
info(`[cs-bots/roster] 回放完成:无事件流 → 由快照恢复 ${bots.size} 个 Bot`)
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
diagnostics.hydratedFrom = 'empty'
|
|
433
|
+
info('[cs-bots/roster] 回放完成:空注册表(无事件流、无快照)')
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// 事件流是真相源:只接受 rev 递增的事件(同 rev 重放幂等,容忍重复投递)
|
|
438
|
+
let skipped = 0
|
|
439
|
+
for (const ev of rows) {
|
|
440
|
+
if (!ev || typeof ev.botId !== 'string' || !ev.after || typeof ev.after.id !== 'string') {
|
|
441
|
+
skipped += 1
|
|
442
|
+
continue
|
|
443
|
+
}
|
|
444
|
+
const before = bots.get(ev.botId)
|
|
445
|
+
if (before && Number.isFinite(ev.rev) && ev.rev <= before.rev) {
|
|
446
|
+
skipped += 1
|
|
447
|
+
continue
|
|
448
|
+
}
|
|
449
|
+
bots.set(ev.botId, clone(ev.after))
|
|
450
|
+
events.push(clone(ev))
|
|
451
|
+
}
|
|
452
|
+
diagnostics.skippedEvents = skipped
|
|
453
|
+
if (skipped > 0) {
|
|
454
|
+
warn(`[cs-bots/roster] 回放跳过 ${skipped} 条非递增/残缺事件(已保留在文件中,未被静默丢弃)`)
|
|
455
|
+
}
|
|
456
|
+
// 名字索引在整轮回放后统一重建(改名/退役的顺序敏感,逐条增量容易残留脏映射)
|
|
457
|
+
for (const b of bots.values()) reindexName(b)
|
|
458
|
+
diagnostics.hydratedFrom = 'jsonl'
|
|
459
|
+
info(`[cs-bots/roster] 回放完成:${rows.length} 条事件 → ${bots.size} 个 Bot`)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ── CAS 公共前置 ──────────────────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* 取 Bot 并做 CAS 校验(rename/setCwd/claim/release/heartbeat 共用)。
|
|
466
|
+
* @param {object} input
|
|
467
|
+
* @param {string} action
|
|
468
|
+
* @returns {object} 内存中的当前 Bot(**未克隆**,仅供构造 next 用)
|
|
469
|
+
*/
|
|
470
|
+
function loadForCas(input, action) {
|
|
471
|
+
const { id, expectedRev } = input ?? {}
|
|
472
|
+
const botId = requireString(id, 'id', action)
|
|
473
|
+
const current = bots.get(botId)
|
|
474
|
+
if (!current) {
|
|
475
|
+
throw new RosterError(`roster.${action}:Bot 不存在 id=${botId}`, 'ROSTER_NOT_FOUND', { id: botId })
|
|
476
|
+
}
|
|
477
|
+
if (typeof expectedRev !== 'number' || !Number.isInteger(expectedRev)) {
|
|
478
|
+
throw new RosterError(
|
|
479
|
+
`roster.${action}:expectedRev 必须是整数(CAS 需要显式期望版本,Bot ${botId} 当前 rev=${current.rev},`
|
|
480
|
+
+ `收到 ${JSON.stringify(expectedRev)})`,
|
|
481
|
+
'ROSTER_MISSING_EXPECTED_REV',
|
|
482
|
+
{ id: botId, rev: current.rev },
|
|
483
|
+
)
|
|
484
|
+
}
|
|
485
|
+
if (current.rev !== expectedRev) {
|
|
486
|
+
throw new RosterError(
|
|
487
|
+
`roster.${action} CAS 冲突:Bot ${botId} 期望 rev=${expectedRev},实际 rev=${current.rev} ——`
|
|
488
|
+
+ '拒绝静默覆盖,请重新 get() 后按最新 rev 重试',
|
|
489
|
+
'ROSTER_REV_CONFLICT',
|
|
490
|
+
{ id: botId, expectedRev, rev: current.rev },
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
return current
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** 终态守卫:退役 Bot 不可再变更(除 `retire` 自身的重复调用判定)。 */
|
|
497
|
+
function assertNotRetired(current, action, verb) {
|
|
498
|
+
if (current.state === 'retired') {
|
|
499
|
+
throw new RosterError(
|
|
500
|
+
`roster.${action}:Bot ${current.id} 已退役(retiredAt=${current.retiredAt ?? '?'})——`
|
|
501
|
+
+ `${verb}是终态后的非法操作;如需复用请新建同名 Bot(名额已释放)`,
|
|
502
|
+
'ROSTER_RETIRED',
|
|
503
|
+
{ id: current.id, retiredAt: current.retiredAt },
|
|
504
|
+
)
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** 名字占用检查(**只对未退役 Bot 生效**)。 */
|
|
509
|
+
function assertNameFree(name, selfId, action) {
|
|
510
|
+
const key = nameKey(name)
|
|
511
|
+
const holder = nameIndex.get(key)
|
|
512
|
+
if (holder && holder !== selfId) {
|
|
513
|
+
throw new RosterError(
|
|
514
|
+
`roster.${action}:名字 ${JSON.stringify(String(name).trim())} 已被占用`
|
|
515
|
+
+ `(大小写不敏感归一后冲突)→ 现存 Bot ${holder}。注册表内名字必须唯一,拒绝静默重名`,
|
|
516
|
+
'ROSTER_NAME_TAKEN',
|
|
517
|
+
{ name: String(name).trim(), key, holderId: holder, selfId: selfId ?? null },
|
|
518
|
+
)
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ── API:读 ────────────────────────────────────────────────────────────────
|
|
523
|
+
|
|
524
|
+
/** @param {string} id @returns {object | undefined} */
|
|
525
|
+
function get(id) {
|
|
526
|
+
const b = bots.get(id)
|
|
527
|
+
return b ? clone(b) : undefined
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* 列出 Bot(**创建序**)。
|
|
532
|
+
* 缺省不含 `retired`;显式传 `state:'retired'` 才看得到退役名录。
|
|
533
|
+
* @param {{ state?: string }} [options]
|
|
534
|
+
* @returns {object[]}
|
|
535
|
+
*/
|
|
536
|
+
function list(options = {}) {
|
|
537
|
+
const state = normalizeStateFilter(options?.state)
|
|
538
|
+
const out = []
|
|
539
|
+
for (const b of bots.values()) {
|
|
540
|
+
if (state) {
|
|
541
|
+
if (b.state === state) out.push(clone(b))
|
|
542
|
+
} else if (b.state !== 'retired') {
|
|
543
|
+
out.push(clone(b))
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return out
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** 退役名录(终态,留档用;名字/槽位已释放)。 @returns {object[]} */
|
|
550
|
+
function listRetired() {
|
|
551
|
+
return list({ state: 'retired' })
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* 注册表统计。
|
|
556
|
+
* 口径说明:`total` = **含 retired 的全部 Bot**;`byState` 是全部状态的分布;
|
|
557
|
+
* `retired` 单独给一份便于「还活着几个」直接取用(= `total - retired`)。
|
|
558
|
+
* @returns {{ total: number, byState: Record<string, number>, retired: number }}
|
|
559
|
+
*/
|
|
560
|
+
function stats() {
|
|
561
|
+
/** @type {Record<string, number>} */
|
|
562
|
+
const byState = {}
|
|
563
|
+
for (const s of STATES) byState[s] = 0
|
|
564
|
+
for (const b of bots.values()) {
|
|
565
|
+
byState[b.state] = (byState[b.state] ?? 0) + 1
|
|
566
|
+
}
|
|
567
|
+
return { total: bots.size, byState, retired: byState.retired ?? 0 }
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* 审计流查询。
|
|
572
|
+
* @param {string} [botId] 省略则返回全部事件
|
|
573
|
+
* @returns {object[]}
|
|
574
|
+
*/
|
|
575
|
+
function history(botId) {
|
|
576
|
+
return clone(botId ? events.filter((e) => e.botId === botId) : events)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ── API:写 ────────────────────────────────────────────────────────────────
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* 创建一个 Bot:**先在独立 cwd 上建 root agent 会话,再落注册表**。
|
|
583
|
+
*
|
|
584
|
+
* 失败语义(**不能有半成品**):
|
|
585
|
+
* - 全部字段校验在**动会话之前**完成 ⇒ 校验失败不产生任何会话;
|
|
586
|
+
* - `kernel.createSession` 抛错即向上抛(`ROSTER_SESSION_CREATE_FAILED`),
|
|
587
|
+
* 且**不写事件、不动内存**(`id` 只在成功后占用)。
|
|
588
|
+
*
|
|
589
|
+
* 为什么必须传 `cwd`:官方 `spawn_teammate` 给不了独立目录(见文件头 §1),
|
|
590
|
+
* `agents.create({meta:{cwd}})` 是唯一路径 —— 这一条会被 `test/roster.test.mjs`
|
|
591
|
+
* 用「注入假 ctx 断言 `meta.cwd`」直接验证(§"测试要求"第 1 条)。
|
|
592
|
+
*
|
|
593
|
+
* @param {{ name: string, cwd: string, label?: string, model?: string, instructions?: string,
|
|
594
|
+
* permission?: string, byId: string, now?: number }} input
|
|
595
|
+
* @returns {Promise<object>} Bot
|
|
596
|
+
* @throws {RosterError} 缺字段 / 非法 cwd / 重名 / 会话创建失败 / 事件落盘失败
|
|
597
|
+
*/
|
|
598
|
+
async function create(input = {}) {
|
|
599
|
+
const { name, cwd, label, model, instructions, preset, permission, profile, byId, now } = input ?? {}
|
|
600
|
+
const by = requireString(byId, 'byId', 'create')
|
|
601
|
+
const displayName = requireString(name, 'name', 'create')
|
|
602
|
+
const dir = assertBotCwd(cwd, 'create')
|
|
603
|
+
const roleLabel = normalizeLabel(label, 'create')
|
|
604
|
+
const mdl = optionalString(model, 'model', 'create')
|
|
605
|
+
const inst = optionalString(instructions, 'instructions', 'create')
|
|
606
|
+
const perm = normalizePermission(permission, 'create')
|
|
607
|
+
const at = normalizeNow(now, 'create')
|
|
608
|
+
|
|
609
|
+
// ── 唯一性(校验通过后才动会话,避免「建了会话才发现重名」的资源泄漏)──
|
|
610
|
+
assertNameFree(displayName, undefined, 'create')
|
|
611
|
+
|
|
612
|
+
// ── 同 cwd 提示(非阻塞):两个活 Bot 共用一个目录会撞 fs 写入边界 ──
|
|
613
|
+
for (const other of bots.values()) {
|
|
614
|
+
if (other.state !== 'retired' && other.cwd === dir) {
|
|
615
|
+
warn(
|
|
616
|
+
`[cs-bots/roster] 新 Bot「${displayName}」与 ${other.id}(「${other.name}」)共用 cwd=${dir}`
|
|
617
|
+
+ '——cwd 同时是 fs 写入边界,共用会导致互相覆盖,请确认这是有意为之',
|
|
618
|
+
)
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// ── 会话创建:**唯一能指定 cwd 的路径**(kernel.js:249-287)──
|
|
623
|
+
const id = `bot-${randomUUID()}`
|
|
624
|
+
let handle
|
|
625
|
+
try {
|
|
626
|
+
handle = await kernel.createSession(ctx, {
|
|
627
|
+
sessionId: id,
|
|
628
|
+
cwd: dir,
|
|
629
|
+
...(preset !== undefined && preset !== null && preset !== '' ? { agentPreset: String(preset) } : {}),
|
|
630
|
+
})
|
|
631
|
+
} catch (error) {
|
|
632
|
+
throw new RosterError(
|
|
633
|
+
`roster.create:Bot「${displayName}」的会话创建失败(cwd=${dir})→ ${error?.message ?? error}`
|
|
634
|
+
+ '——已中止注册(不产生半成品 Bot)',
|
|
635
|
+
'ROSTER_SESSION_CREATE_FAILED',
|
|
636
|
+
{ name: displayName, cwd: dir, cause: String(error?.message ?? error) },
|
|
637
|
+
)
|
|
638
|
+
}
|
|
639
|
+
const sessionId = pickSessionId(handle, id)
|
|
640
|
+
if (sessionId !== id) {
|
|
641
|
+
warn(
|
|
642
|
+
`[cs-bots/roster] 宿主返回的会话 id(${sessionId})与请求的(${id})不同——以宿主返回为准并记入注册表`,
|
|
643
|
+
)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** @type {Record<string, unknown>} */
|
|
647
|
+
const bot = {
|
|
648
|
+
id,
|
|
649
|
+
rev: 1,
|
|
650
|
+
name: displayName,
|
|
651
|
+
sessionId,
|
|
652
|
+
cwd: dir,
|
|
653
|
+
permission: perm,
|
|
654
|
+
...(preset !== undefined && preset !== null && preset !== '' ? { preset: String(preset) } : {}),
|
|
655
|
+
state: 'idle',
|
|
656
|
+
createdAt: at,
|
|
657
|
+
createdBy: by,
|
|
658
|
+
}
|
|
659
|
+
if (roleLabel !== undefined) bot.label = roleLabel
|
|
660
|
+
if (mdl !== undefined) bot.model = mdl
|
|
661
|
+
if (inst !== undefined) bot.instructions = inst
|
|
662
|
+
// ── 档案(身份/经验/技能/预制记忆)→ 落到官方机制上 ──────────────────
|
|
663
|
+
if (profile !== undefined) {
|
|
664
|
+
try {
|
|
665
|
+
bot.profile = normalizeProfile(profile)
|
|
666
|
+
const written = writeAgentsFile(bot.cwd, { ...bot.profile, botName: bot.name, preset: bot.preset })
|
|
667
|
+
bot.profilePath = written.path
|
|
668
|
+
// 技能:每个名字建一个 SKILL.md 骨架(已存在则不覆盖正文)
|
|
669
|
+
for (const skillName of bot.profile.skills ?? []) {
|
|
670
|
+
try {
|
|
671
|
+
writeSkill(bot.cwd, skillName, { body: `# ${skillName}\n\n(在此写技能说明)` })
|
|
672
|
+
} catch (error) {
|
|
673
|
+
warn(`[cs-bots/roster] 技能 ${skillName} 落盘失败(不影响注册):${error?.message ?? error}`)
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
} catch (error) {
|
|
677
|
+
// 档案写失败不阻塞建 Bot(会话已建好),但要如实记在记录里
|
|
678
|
+
bot.profile = normalizeProfile(profile)
|
|
679
|
+
bot.profileError = String(error?.message ?? error)
|
|
680
|
+
warn(`[cs-bots/roster] 档案落盘失败:${error?.message ?? error}`)
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// 事件落盘失败 → 会话已建但注册表没有它:**不静默**,错误里带上 sessionId 供人工清理
|
|
685
|
+
try {
|
|
686
|
+
return commit(id, 'create', by, at, null, bot)
|
|
687
|
+
} catch (error) {
|
|
688
|
+
error.message += `(注意:会话 ${sessionId} 已创建但未登记,请人工清理或重试 create)`
|
|
689
|
+
throw error
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* 改名(**官方 roster 做不到的能力**,见文件头 §1.1)。
|
|
695
|
+
*
|
|
696
|
+
* 生效即释放旧名:旧名立刻可被新 Bot 复用(唯一性只算未退役 Bot)。
|
|
697
|
+
*
|
|
698
|
+
* @param {{ id: string, name: string, expectedRev: number, byId: string, now?: number }} input
|
|
699
|
+
* @returns {object} Bot
|
|
700
|
+
* @throws {RosterError} 同名占用 / 已退役 / CAS 冲突 / 缺字段
|
|
701
|
+
*/
|
|
702
|
+
function rename(input = {}) {
|
|
703
|
+
const { name, byId, now } = input ?? {}
|
|
704
|
+
const current = loadForCas(input, 'rename')
|
|
705
|
+
const by = requireString(byId, 'byId', 'rename')
|
|
706
|
+
const displayName = requireString(name, 'name', 'rename')
|
|
707
|
+
assertNotRetired(current, 'rename', '改名')
|
|
708
|
+
|
|
709
|
+
const key = nameKey(displayName)
|
|
710
|
+
assertNameFree(displayName, current.id, 'rename')
|
|
711
|
+
if (nameKey(current.name) === key && current.name === displayName) {
|
|
712
|
+
throw new RosterError(
|
|
713
|
+
`roster.rename:Bot ${current.id} 的新名字与当前名字完全相同(${JSON.stringify(displayName)})——无需变更`,
|
|
714
|
+
'ROSTER_NOTHING_TO_RENAME',
|
|
715
|
+
{ id: current.id, name: displayName },
|
|
716
|
+
)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const at = normalizeNow(now, 'rename')
|
|
720
|
+
const before = clone(current)
|
|
721
|
+
const next = clone(current)
|
|
722
|
+
next.rev = current.rev + 1
|
|
723
|
+
next.name = displayName
|
|
724
|
+
next.renamedAt = at
|
|
725
|
+
next.renamedBy = by
|
|
726
|
+
return commit(current.id, 'rename', by, at, before, next)
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* 退役 → **终态,但释放名额**(名字可复用、cwd 可回收;§3.1 "关键" 段)。
|
|
731
|
+
*
|
|
732
|
+
* 语义边界(写清楚,避免误用):
|
|
733
|
+
* - 退役**不删除**记录(append-only 审计流里留档,`listRetired()` 可查);
|
|
734
|
+
* - 退役**不杀会话**(会话生命周期由宿主管理;本模块只管注册表视图);
|
|
735
|
+
* - 退役**无 CAS**(签名 §"必须实现" 无 `expectedRev`),但**拒绝重复退役**——
|
|
736
|
+
* 重复调用抛 `ROSTER_ALREADY_RETIRED`,不会写第二条事件。
|
|
737
|
+
* - 名字唯一性只算未退役 Bot ⇒ 退役**立刻**释放名字与槽位。
|
|
738
|
+
*
|
|
739
|
+
* @param {{ id: string, reason?: string, byId: string, now?: number }} input
|
|
740
|
+
* @returns {object} Bot
|
|
741
|
+
* @throws {RosterError} 不存在 / 已退役 / 缺 byId
|
|
742
|
+
*/
|
|
743
|
+
function retire(input = {}) {
|
|
744
|
+
const { id, reason, byId, now } = input ?? {}
|
|
745
|
+
const botId = requireString(id, 'id', 'retire')
|
|
746
|
+
const by = requireString(byId, 'byId', 'retire')
|
|
747
|
+
const note = optionalString(reason, 'reason', 'retire')
|
|
748
|
+
const current = bots.get(botId)
|
|
749
|
+
if (!current) {
|
|
750
|
+
throw new RosterError(`roster.retire:Bot 不存在 id=${botId}`, 'ROSTER_NOT_FOUND', { id: botId })
|
|
751
|
+
}
|
|
752
|
+
if (current.state === 'retired') {
|
|
753
|
+
throw new RosterError(
|
|
754
|
+
`roster.retire:Bot ${botId} 已于 ${current.retiredAt ?? '?'} 退役(终态不重复写)`,
|
|
755
|
+
'ROSTER_ALREADY_RETIRED',
|
|
756
|
+
{ id: botId, retiredAt: current.retiredAt },
|
|
757
|
+
)
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const at = normalizeNow(now, 'retire')
|
|
761
|
+
const before = clone(current)
|
|
762
|
+
const next = clone(current)
|
|
763
|
+
next.rev = current.rev + 1
|
|
764
|
+
next.state = 'retired'
|
|
765
|
+
next.retiredAt = at
|
|
766
|
+
next.retiredBy = by
|
|
767
|
+
if (note !== undefined) next.retiredReason = note
|
|
768
|
+
delete next.lease // 退役即清租约,不留悬空持有者
|
|
769
|
+
const committed = commit(botId, 'retire', by, at, before, next)
|
|
770
|
+
info(`[cs-bots/roster] Bot ${botId}(「${current.name}」)已退役 —— 名字与槽位释放,可复用`)
|
|
771
|
+
return committed
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* 换工作目录(**官方完全没有的语义**:teammate 强制继承父 cwd)。
|
|
776
|
+
*
|
|
777
|
+
* ⚠️ 诚实边界(必须知道,否则会误判):本函数只改**注册表记录**。
|
|
778
|
+
* 已建会话的 cwd 是宿主的 **durable session data**(`kernel.js:257-259` 原文
|
|
779
|
+
* "This is durable session data"),**不会**随本变更迁移 —— 新 cwd 在
|
|
780
|
+
* 「按本记录重建会话」时生效。故此处记 warn,不假装已经搬完家。
|
|
781
|
+
*
|
|
782
|
+
* @param {{ id: string, cwd: string, expectedRev: number, byId: string, now?: number }} input
|
|
783
|
+
* @returns {object} Bot
|
|
784
|
+
* @throws {RosterError} 非法 cwd / 已退役 / CAS 冲突
|
|
785
|
+
*/
|
|
786
|
+
/**
|
|
787
|
+
* 读一个 Bot 的档案(含从 AGENTS.md 读回的托管段)。
|
|
788
|
+
* @param {string} id
|
|
789
|
+
* @returns {{ bot: object, profilePath?: string, managedText?: string, hasOutside?: boolean, skills?: string[] }}
|
|
790
|
+
*/
|
|
791
|
+
function getProfile(id) {
|
|
792
|
+
const bid = requireString(id, 'id', 'getProfile')
|
|
793
|
+
const bot = index.get(bid)
|
|
794
|
+
if (!bot) {
|
|
795
|
+
throw new RosterError(`roster.getProfile:Bot ${bid} 不存在`, 'ROSTER_BOT_NOT_FOUND', { id: bid })
|
|
796
|
+
}
|
|
797
|
+
const out = { bot, profile: bot.profile ?? {} }
|
|
798
|
+
try {
|
|
799
|
+
const md = readAgentsFile(bot.cwd)
|
|
800
|
+
out.profilePath = md.path
|
|
801
|
+
out.managedText = md.managed
|
|
802
|
+
out.hasOutside = Boolean(md.outside && md.outside.trim())
|
|
803
|
+
} catch (error) {
|
|
804
|
+
out.profileError = String(error?.message ?? error)
|
|
805
|
+
}
|
|
806
|
+
return out
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* 改一个 Bot 的档案(身份/经验/技能/预制记忆)→ 重写 AGENTS.md 托管段。
|
|
811
|
+
*
|
|
812
|
+
* **只动托管段**,用户在 AGENTS.md 段外写的内容原样保留。
|
|
813
|
+
* @param {{ id: string, profile: object, byId?: string, expectedRev?: number, now?: number }} input
|
|
814
|
+
*/
|
|
815
|
+
function setProfile(input = {}) {
|
|
816
|
+
const { id, profile, byId, now } = input ?? {}
|
|
817
|
+
const current = loadForCas(input, 'setProfile')
|
|
818
|
+
const by = requireString(byId, 'byId', 'setProfile')
|
|
819
|
+
if (current.state === 'retired') {
|
|
820
|
+
throw new RosterError(
|
|
821
|
+
`roster.setProfile:Bot ${current.id} 已退役(名字 ${current.name})——退役 Bot 不可改档案`,
|
|
822
|
+
'ROSTER_BOT_RETIRED',
|
|
823
|
+
{ id: current.id },
|
|
824
|
+
)
|
|
825
|
+
}
|
|
826
|
+
const nextProfile = { ...(current.profile ?? {}), ...normalizeProfile(profile) }
|
|
827
|
+
|
|
828
|
+
// 先落盘,成功再记事件(避免"记录说改了但文件没变")
|
|
829
|
+
let written
|
|
830
|
+
try {
|
|
831
|
+
written = writeAgentsFile(current.cwd, {
|
|
832
|
+
...nextProfile,
|
|
833
|
+
botName: current.name,
|
|
834
|
+
preset: current.preset,
|
|
835
|
+
})
|
|
836
|
+
for (const skillName of nextProfile.skills ?? []) {
|
|
837
|
+
try {
|
|
838
|
+
writeSkill(current.cwd, skillName, { body: `# ${skillName}\n\n(在此写技能说明)` })
|
|
839
|
+
} catch (error) {
|
|
840
|
+
warn(`[cs-bots/roster] 技能 ${skillName} 落盘失败:${error?.message ?? error}`)
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
} catch (error) {
|
|
844
|
+
throw new RosterError(
|
|
845
|
+
`roster.setProfile:写 AGENTS.md 失败(cwd=${current.cwd})→ ${error?.message ?? error}`,
|
|
846
|
+
'ROSTER_PROFILE_WRITE_FAILED',
|
|
847
|
+
{ id: current.id, cwd: current.cwd, cause: String(error?.message ?? error) },
|
|
848
|
+
)
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const at = normalizeNow(now, 'setProfile')
|
|
852
|
+
const before = clone(current)
|
|
853
|
+
const next = clone(current)
|
|
854
|
+
next.profile = nextProfile
|
|
855
|
+
next.profilePath = written.path
|
|
856
|
+
delete next.profileError
|
|
857
|
+
next.rev = current.rev + 1
|
|
858
|
+
next.updatedAt = at
|
|
859
|
+
next.updatedBy = by
|
|
860
|
+
return commit(current.id, 'profile_updated', by, at, before, next)
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function setCwd(input = {}) {
|
|
864
|
+
const { cwd, byId, now } = input ?? {}
|
|
865
|
+
const current = loadForCas(input, 'setCwd')
|
|
866
|
+
const by = requireString(byId, 'byId', 'setCwd')
|
|
867
|
+
const dir = assertBotCwd(cwd, 'setCwd')
|
|
868
|
+
assertNotRetired(current, 'setCwd', '换目录')
|
|
869
|
+
|
|
870
|
+
if (current.cwd === dir) {
|
|
871
|
+
throw new RosterError(
|
|
872
|
+
`roster.setCwd:Bot ${current.id} 的 cwd 已是 ${dir}——无需变更`,
|
|
873
|
+
'ROSTER_NOTHING_TO_SET_CWD',
|
|
874
|
+
{ id: current.id, cwd: dir },
|
|
875
|
+
)
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const at = normalizeNow(now, 'setCwd')
|
|
879
|
+
const before = clone(current)
|
|
880
|
+
const next = clone(current)
|
|
881
|
+
next.rev = current.rev + 1
|
|
882
|
+
next.cwd = dir
|
|
883
|
+
next.cwdChangedAt = at
|
|
884
|
+
next.cwdChangedBy = by
|
|
885
|
+
warn(
|
|
886
|
+
`[cs-bots/roster] Bot ${current.id} 的注册表 cwd 由 ${current.cwd} 改为 ${dir};`
|
|
887
|
+
+ `已建会话 ${current.sessionId} 的 cwd 是宿主 durable 数据,**不随之迁移**`,
|
|
888
|
+
)
|
|
889
|
+
return commit(current.id, 'setCwd', by, at, before, next)
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* 取租约(§3.1 草案未列,见文件头「必要增量」第 3 条)。
|
|
894
|
+
*
|
|
895
|
+
* 租约的作用与 board 的 owner lease 同构:**封死「持有者在 interrupt/崩溃后永不释放」**——
|
|
896
|
+
* 靠 `expireLeases()` 到期回收,而不是指望一方自觉。
|
|
897
|
+
*
|
|
898
|
+
* @param {{ id: string, owner: string, expectedRev: number, leaseMs?: number, now?: number }} input
|
|
899
|
+
* @returns {object} Bot
|
|
900
|
+
* @throws {RosterError} 活跃租约被他人持有 / 已退役 / CAS 冲突
|
|
901
|
+
*/
|
|
902
|
+
function claim(input = {}) {
|
|
903
|
+
const { owner, leaseMs, now } = input ?? {}
|
|
904
|
+
const current = loadForCas(input, 'claim')
|
|
905
|
+
const holder = requireString(owner, 'owner', 'claim')
|
|
906
|
+
assertNotRetired(current, 'claim', '取租约')
|
|
907
|
+
|
|
908
|
+
const ttl = optionalNumber(leaseMs, 'leaseMs', 'claim') ?? leaseMsDefault
|
|
909
|
+
if (!(ttl > 0)) {
|
|
910
|
+
throw new RosterError(
|
|
911
|
+
`roster.claim:leaseMs 必须 > 0,收到 ${JSON.stringify(leaseMs)}`,
|
|
912
|
+
'ROSTER_BAD_FIELD',
|
|
913
|
+
{ field: 'leaseMs' },
|
|
914
|
+
)
|
|
915
|
+
}
|
|
916
|
+
const at = normalizeNow(now, 'claim')
|
|
917
|
+
if (current.lease) {
|
|
918
|
+
const exp = Number(current.lease.expiresAt)
|
|
919
|
+
const live = Number.isFinite(exp) && exp > at
|
|
920
|
+
if (live && current.lease.owner !== holder) {
|
|
921
|
+
throw new RosterError(
|
|
922
|
+
`roster.claim:Bot ${current.id} 的租约仍被 ${current.lease.owner} 持有`
|
|
923
|
+
+ `(至 ${new Date(exp).toISOString()})——不允许抢占,调用者为 ${holder}`,
|
|
924
|
+
'ROSTER_LEASE_HELD',
|
|
925
|
+
{ id: current.id, owner: current.lease.owner, expiresAt: exp },
|
|
926
|
+
)
|
|
927
|
+
}
|
|
928
|
+
if (!live) {
|
|
929
|
+
warn(
|
|
930
|
+
`[cs-bots/roster] Bot ${current.id} 的租约已于 ${new Date(exp).toISOString()} 过期`
|
|
931
|
+
+ `(持有者 ${current.lease.owner})→ 允许抢占`,
|
|
932
|
+
)
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const before = clone(current)
|
|
937
|
+
const next = clone(current)
|
|
938
|
+
next.rev = current.rev + 1
|
|
939
|
+
next.state = 'running'
|
|
940
|
+
next.lease = { owner: holder, expiresAt: at + ttl, heartbeatAt: at }
|
|
941
|
+
return commit(current.id, 'claim', holder, at, before, next)
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* 主动放租约(回 `idle`)。
|
|
946
|
+
* @param {{ id: string, owner: string, expectedRev: number, now?: number }} input
|
|
947
|
+
* @returns {object} Bot
|
|
948
|
+
*/
|
|
949
|
+
function release(input = {}) {
|
|
950
|
+
const { owner, now } = input ?? {}
|
|
951
|
+
const current = loadForCas(input, 'release')
|
|
952
|
+
const holder = requireString(owner, 'owner', 'release')
|
|
953
|
+
if (!current.lease) {
|
|
954
|
+
throw new RosterError(
|
|
955
|
+
`roster.release:Bot ${current.id} 当前没有租约(state=${current.state}),无可释放`,
|
|
956
|
+
'ROSTER_NO_LEASE',
|
|
957
|
+
{ id: current.id },
|
|
958
|
+
)
|
|
959
|
+
}
|
|
960
|
+
if (current.lease.owner !== holder) {
|
|
961
|
+
throw new RosterError(
|
|
962
|
+
`roster.release:只允许租约持有者释放(Bot ${current.id} 的租约属于 ${current.lease.owner},调用者 ${holder})`,
|
|
963
|
+
'ROSTER_LEASE_NOT_OWNER',
|
|
964
|
+
{ id: current.id, owner: current.lease.owner },
|
|
965
|
+
)
|
|
966
|
+
}
|
|
967
|
+
const at = normalizeNow(now, 'release')
|
|
968
|
+
const before = clone(current)
|
|
969
|
+
const next = clone(current)
|
|
970
|
+
next.rev = current.rev + 1
|
|
971
|
+
next.state = 'idle'
|
|
972
|
+
delete next.lease
|
|
973
|
+
return commit(current.id, 'release', holder, at, before, next)
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* 心跳续租。
|
|
978
|
+
* @param {{ id: string, owner: string, expectedRev: number, leaseMs?: number, now?: number }} input
|
|
979
|
+
* @returns {object} Bot
|
|
980
|
+
* @throws {RosterError} 无租约 / 非持有者 / 租约已过期(**心跳不复活过期租约**)
|
|
981
|
+
*/
|
|
982
|
+
function heartbeat(input = {}) {
|
|
983
|
+
const { owner, leaseMs, now } = input ?? {}
|
|
984
|
+
const current = loadForCas(input, 'heartbeat')
|
|
985
|
+
const holder = requireString(owner, 'owner', 'heartbeat')
|
|
986
|
+
if (!current.lease) {
|
|
987
|
+
throw new RosterError(
|
|
988
|
+
`roster.heartbeat:Bot ${current.id} 没有活跃租约(state=${current.state})——请先 claim`,
|
|
989
|
+
'ROSTER_NO_LEASE',
|
|
990
|
+
{ id: current.id, state: current.state },
|
|
991
|
+
)
|
|
992
|
+
}
|
|
993
|
+
if (current.lease.owner !== holder) {
|
|
994
|
+
throw new RosterError(
|
|
995
|
+
`roster.heartbeat:只允许租约持有者续租(Bot ${current.id} 的租约属于 ${current.lease.owner},调用者 ${holder})`,
|
|
996
|
+
'ROSTER_LEASE_NOT_OWNER',
|
|
997
|
+
{ id: current.id, owner: current.lease.owner },
|
|
998
|
+
)
|
|
999
|
+
}
|
|
1000
|
+
const at = normalizeNow(now, 'heartbeat')
|
|
1001
|
+
const exp = Number(current.lease.expiresAt)
|
|
1002
|
+
if (!Number.isFinite(exp) || exp <= at) {
|
|
1003
|
+
throw new RosterError(
|
|
1004
|
+
`roster.heartbeat:Bot ${current.id} 的租约已过期`
|
|
1005
|
+
+ `(expiresAt=${Number.isFinite(exp) ? new Date(exp).toISOString() : exp})`
|
|
1006
|
+
+ '——心跳无法复活过期租约,请走 claim 或 expireLeases',
|
|
1007
|
+
'ROSTER_LEASE_EXPIRED',
|
|
1008
|
+
{ id: current.id, expiresAt: exp },
|
|
1009
|
+
)
|
|
1010
|
+
}
|
|
1011
|
+
const ttl = optionalNumber(leaseMs, 'leaseMs', 'heartbeat') ?? leaseMsDefault
|
|
1012
|
+
if (!(ttl > 0)) {
|
|
1013
|
+
throw new RosterError(
|
|
1014
|
+
`roster.heartbeat:leaseMs 必须 > 0,收到 ${JSON.stringify(leaseMs)}`,
|
|
1015
|
+
'ROSTER_BAD_FIELD',
|
|
1016
|
+
{ field: 'leaseMs' },
|
|
1017
|
+
)
|
|
1018
|
+
}
|
|
1019
|
+
const before = clone(current)
|
|
1020
|
+
const next = clone(current)
|
|
1021
|
+
next.rev = current.rev + 1
|
|
1022
|
+
next.lease = { owner: holder, expiresAt: at + ttl, heartbeatAt: at }
|
|
1023
|
+
return commit(current.id, 'heartbeat', holder, at, before, next)
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* 过期租约回收:`lease.expiresAt <= now` → 清租约、回 `idle`。
|
|
1028
|
+
*
|
|
1029
|
+
* **这是封死「持有者崩溃后永不释放」硬伤的关键动作**(同 board.expireLeases 的立意)。
|
|
1030
|
+
* 已退役 Bot 天然无租约(retire 时已清),不参与回收。
|
|
1031
|
+
*
|
|
1032
|
+
* @param {{ now?: number }} [options]
|
|
1033
|
+
* @returns {string[]} 被回收的 bot id(创建序)
|
|
1034
|
+
*/
|
|
1035
|
+
function expireLeases(options = {}) {
|
|
1036
|
+
const at = normalizeNow(options?.now, 'expireLeases')
|
|
1037
|
+
const recycled = []
|
|
1038
|
+
for (const current of [...bots.values()]) {
|
|
1039
|
+
if (!current.lease) continue
|
|
1040
|
+
const exp = Number(current.lease.expiresAt)
|
|
1041
|
+
if (!Number.isFinite(exp)) {
|
|
1042
|
+
warn(
|
|
1043
|
+
`[cs-bots/roster] Bot ${current.id} 的租约 expiresAt 非法`
|
|
1044
|
+
+ `(${JSON.stringify(current.lease.expiresAt)})——不静默放过,本次跳过回收`,
|
|
1045
|
+
)
|
|
1046
|
+
continue
|
|
1047
|
+
}
|
|
1048
|
+
if (exp > at) continue
|
|
1049
|
+
|
|
1050
|
+
const before = clone(current)
|
|
1051
|
+
const next = clone(current)
|
|
1052
|
+
next.rev = current.rev + 1
|
|
1053
|
+
// running 是「被租约驱动」的状态,租约没了就回 idle;parked/failed 是显式状态,不覆盖
|
|
1054
|
+
if (next.state === 'running') next.state = 'idle'
|
|
1055
|
+
delete next.lease
|
|
1056
|
+
commit(current.id, 'lease_expired', current.lease.owner ?? null, at, before, next)
|
|
1057
|
+
recycled.push(current.id)
|
|
1058
|
+
info(
|
|
1059
|
+
`[cs-bots/roster] 租约回收:${current.id} 由 ${current.lease.owner} 于 ${new Date(exp).toISOString()} 过期`
|
|
1060
|
+
+ ` → state=${next.state}`,
|
|
1061
|
+
)
|
|
1062
|
+
}
|
|
1063
|
+
return recycled
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* 从 `roster.jsonl` 重建内存态(重启回放一致性)。
|
|
1068
|
+
* @returns {{ from: string, bots: number, events: number, bad: number, skipped: number }}
|
|
1069
|
+
*/
|
|
1070
|
+
function replay() {
|
|
1071
|
+
hydrate()
|
|
1072
|
+
diagnostics.lastReplayAt = Date.now()
|
|
1073
|
+
return {
|
|
1074
|
+
from: diagnostics.hydratedFrom ?? 'empty',
|
|
1075
|
+
bots: bots.size,
|
|
1076
|
+
events: events.length,
|
|
1077
|
+
bad: diagnostics.badLines,
|
|
1078
|
+
skipped: diagnostics.skippedEvents,
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/** 首次构造即回放,保证 `list()` 在重启后马上可用。 */
|
|
1083
|
+
hydrate()
|
|
1084
|
+
|
|
1085
|
+
return {
|
|
1086
|
+
name: config.name ?? 'roster',
|
|
1087
|
+
paths: Object.freeze({ stateDir, jsonl: jsonlPath, snapshot: snapPath }),
|
|
1088
|
+
durable,
|
|
1089
|
+
create,
|
|
1090
|
+
get,
|
|
1091
|
+
list,
|
|
1092
|
+
listRetired,
|
|
1093
|
+
rename,
|
|
1094
|
+
retire,
|
|
1095
|
+
setCwd,
|
|
1096
|
+
getProfile,
|
|
1097
|
+
setProfile,
|
|
1098
|
+
claim,
|
|
1099
|
+
release,
|
|
1100
|
+
heartbeat,
|
|
1101
|
+
expireLeases,
|
|
1102
|
+
replay,
|
|
1103
|
+
stats,
|
|
1104
|
+
history,
|
|
1105
|
+
diagnostics: () => clone(diagnostics),
|
|
1106
|
+
/** 状态/权限的合法值(供上层校验与文档复用,避免各处硬编码)。 */
|
|
1107
|
+
constants: Object.freeze({
|
|
1108
|
+
STATES: Object.freeze([...STATES]),
|
|
1109
|
+
LIVE_STATES: Object.freeze([...LIVE_STATES]),
|
|
1110
|
+
PERMISSIONS: Object.freeze([...PERMISSIONS]),
|
|
1111
|
+
ACTIONS: Object.freeze([...ACTIONS]),
|
|
1112
|
+
MAX_LABEL,
|
|
1113
|
+
DEFAULT_PERMISSION,
|
|
1114
|
+
DEFAULT_LEASE_MS: leaseMsDefault,
|
|
1115
|
+
}),
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
export default createRoster
|