@shgroup/dsh-serenity-hooks 1.105.0 → 1.108.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.
@@ -0,0 +1,3603 @@
1
+ import { n as __exportAll } from "./rolldown-runtime-B4iAMlE-.js";
2
+ import { c as hostAgents, o as listCccs, u as hostService } from "./ccc-roots-DPMwBywc.js";
3
+ import { g as resolveInside } from "./ccc-BmpE3cQz.js";
4
+ import { F as isoLocal, L as localHuman, P as readTrajectoryFile, R as localIdStamp, T as sessionsRoot, b as parseTrajectoryCode, j as TRAJECTORY_FILENAME, k as trajectoryDescOf, p as findSession, v as namingTitleFor } from "./bind-file-B0TuOP89.js";
5
+ import { a as listBoundSessionIds, i as carrierState, n as PLUGIN_SOURCE, r as appendBound } from "./message-source-BTIe2cVO.js";
6
+ import { i as sessionsRootDir, n as findSessionLog, r as hasSessionLogById } from "./cleanup-CPl1SSaK.js";
7
+ import { n as readSimpleSettings } from "./settings-DY4_DYYY.js";
8
+ import { basename, dirname, join } from "node:path";
9
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
10
+ import { spawn } from "node:child_process";
11
+ import { hostname } from "node:os";
12
+ import { createUserMessage } from "@deepseek-ai/dsh-llm";
13
+ import * as nodeZlib from "node:zlib";
14
+ //#region src/wake/registry.ts
15
+ /**
16
+ * wake/registry.ts — 认知轨迹唤醒注册表(D58,S142 2026-09-13)
17
+ *
18
+ * 需求(用户原话):一个 CCC 内允许多个认知轨迹任意形态并行;某个认知轨迹可以
19
+ * **预定未来唤醒自己或别人**,本质上是一次「未来时刻 + 一条 message」的投递;「周期自唤醒」
20
+ * 曾是它的特例(原 ACC autopilot / D59,该机制已于 2026-09-15 整段退场)。协作边界:
21
+ * 互相可见、**无阻塞无等待**。
22
+ *
23
+ * 本模块只负责**注册表本身**(纯数据层,无宿主依赖,可单测):
24
+ * - 落点:CCC 内 `AGENT_SESSIONS/wake-registry.json`(随 CCC git,人可读可审计)
25
+ * - 状态机:`pending` →(投递成功)`delivered`|(超补跑窗口)`missed`|(`wake rm` 物理移除)
26
+ * - 投递即终结:一次性,不重投(无回执语义——发起方不 await,见设计文档 §2)
27
+ *
28
+ * 调度与投递在 `wake/scheduler.ts`(需要 ctx),本模块保持纯净。
29
+ */
30
+ /** 注册表相对 CCC 根的路径(与 `.bindings.json` 同居 AGENT_SESSIONS/) */
31
+ const WAKE_REGISTRY_REL_PATH = "AGENT_SESSIONS/wake-registry.json";
32
+ /** 注册表格式版本(结构变更时 +1;读取端只接受本版本) */
33
+ const WAKE_REGISTRY_VERSION = 1;
34
+ /**
35
+ * 补跑窗口(缺省 2h):dsh 停机期间到期的条目,若迟到不超过本窗口则补投,
36
+ * 超过则置 `missed` 留痕(不补投——避免"半夜该做的事早上突然连环跑")。
37
+ */
38
+ const WAKE_CATCH_UP_MS = 72e5;
39
+ /** 注册表绝对路径 */
40
+ function wakeRegistryPath(root) {
41
+ return join(root, WAKE_REGISTRY_REL_PATH);
42
+ }
43
+ /** 空注册表 */
44
+ function emptyRegistry() {
45
+ return {
46
+ version: WAKE_REGISTRY_VERSION,
47
+ entries: []
48
+ };
49
+ }
50
+ /** 条目形状校验(容忍手改:字段缺失/类型不符 → 丢弃该条,不阻断整表) */
51
+ function asEntry$1(value) {
52
+ const e = value;
53
+ if (!e || typeof e.id !== "string" || typeof e.target !== "string") return null;
54
+ if (typeof e.at !== "string" || typeof e.message !== "string") return null;
55
+ const state = e.state === "delivered" || e.state === "missed" || e.state === "cancelled" ? e.state : "pending";
56
+ return {
57
+ id: e.id,
58
+ target: e.target,
59
+ at: e.at,
60
+ message: e.message,
61
+ state,
62
+ createdBy: typeof e.createdBy === "string" ? e.createdBy : "",
63
+ createdAt: typeof e.createdAt === "string" ? e.createdAt : "",
64
+ attempts: typeof e.attempts === "number" && Number.isFinite(e.attempts) ? e.attempts : 0,
65
+ lastResult: typeof e.lastResult === "string" ? e.lastResult : null,
66
+ deliveredAt: typeof e.deliveredAt === "string" ? e.deliveredAt : null
67
+ };
68
+ }
69
+ /**
70
+ * 读注册表。文件缺失 → 空表;损坏 → 空表 + error(不抛错,调用方决定是否告警)。
71
+ * @param root CCC 根
72
+ * @returns 注册表与读取错误(无错为 null)
73
+ */
74
+ function loadWakeRegistry(root) {
75
+ const path = wakeRegistryPath(root);
76
+ if (!existsSync(path)) return {
77
+ registry: emptyRegistry(),
78
+ error: null
79
+ };
80
+ try {
81
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
82
+ const raw = Array.isArray(parsed.entries) ? parsed.entries : [];
83
+ const entries = [];
84
+ for (const item of raw) {
85
+ const e = asEntry$1(item);
86
+ if (e) entries.push(e);
87
+ }
88
+ return {
89
+ registry: {
90
+ version: WAKE_REGISTRY_VERSION,
91
+ entries
92
+ },
93
+ error: null
94
+ };
95
+ } catch (err) {
96
+ return {
97
+ registry: emptyRegistry(),
98
+ error: `唤醒注册表解析失败(${String(err?.message ?? err)})`
99
+ };
100
+ }
101
+ }
102
+ /** 原子写(tmp + rename;与 .bindings.json 同款形态) */
103
+ function saveWakeRegistry(root, registry) {
104
+ const path = wakeRegistryPath(root);
105
+ try {
106
+ mkdirSync(dirname(path), { recursive: true });
107
+ const tmp = `${path}.tmp`;
108
+ writeFileSync(tmp, `${JSON.stringify({
109
+ version: WAKE_REGISTRY_VERSION,
110
+ entries: registry.entries
111
+ }, null, 2)}\n`, "utf-8");
112
+ renameSync(tmp, path);
113
+ return {
114
+ ok: true,
115
+ error: null
116
+ };
117
+ } catch (err) {
118
+ return {
119
+ ok: false,
120
+ error: `唤醒注册表写入失败(${String(err?.message ?? err)})`
121
+ };
122
+ }
123
+ }
124
+ /** 条目 id:`w-<当地到分钟>-<4 hex>`(可读 + 低碰撞;不复用由"物理移除"保证) */
125
+ function newWakeId(nowMs, rand = Math.random) {
126
+ return `w-${localIdStamp(nowMs)}-${Math.floor(rand() * 65536).toString(16).padStart(4, "0")}`;
127
+ }
128
+ /**
129
+ * 解析唤醒时刻。接受两种写法(设计文档 §7):
130
+ * - 绝对:RFC3339(含时区偏移,如 `2026-09-14T09:00:00+08:00`)
131
+ * - 相对:`+30m` / `+2h`(便于 agent 直接登记"半小时后")
132
+ * @param input 用户/agent 传入的时刻串
133
+ * @param nowMs 当前时刻(相对写法基准)
134
+ * @returns `{ ok, ms }` 或 `{ ok: false, error }`
135
+ */
136
+ function parseWakeAt(input, nowMs) {
137
+ const raw = input.trim();
138
+ if (raw === "") return {
139
+ ok: false,
140
+ error: "时刻为空"
141
+ };
142
+ const rel = /^\+(\d+(?:\.\d+)?)([mhd])$/.exec(raw);
143
+ if (rel) {
144
+ const n = Number(rel[1]);
145
+ const unit = rel[2];
146
+ return {
147
+ ok: true,
148
+ ms: nowMs + n * (unit === "m" ? 6e4 : unit === "h" ? 36e5 : 864e5)
149
+ };
150
+ }
151
+ const ms = Date.parse(raw);
152
+ if (!Number.isFinite(ms)) return {
153
+ ok: false,
154
+ error: `时刻无法解析(须 RFC3339 或 +30m/+2h): ${raw}`
155
+ };
156
+ return {
157
+ ok: true,
158
+ ms
159
+ };
160
+ }
161
+ /**
162
+ * 登记一条唤醒。
163
+ * @param root CCC 根
164
+ * @param input 目标认知轨迹 / 时刻串 / message / 发起者 / 当前时刻
165
+ * @returns 成功返回条目,失败返回稳定错误文案(不抛错)
166
+ */
167
+ function addWake(root, input) {
168
+ const target = input.target.trim();
169
+ if (target === "") return {
170
+ ok: false,
171
+ error: "target 为空(须 S### 或 AGENT_SESSIONS 目录名)"
172
+ };
173
+ const message = input.message.trim();
174
+ if (message === "") return {
175
+ ok: false,
176
+ error: "message 为空(唤醒必须携带一条信息)"
177
+ };
178
+ const parsed = parseWakeAt(input.at, input.nowMs);
179
+ if (!parsed.ok) return {
180
+ ok: false,
181
+ error: parsed.error
182
+ };
183
+ if (parsed.ms <= input.nowMs) return {
184
+ ok: false,
185
+ error: "时刻必须在未来(唤醒只允许可预期的未来)"
186
+ };
187
+ const { registry, error } = loadWakeRegistry(root);
188
+ if (error) return {
189
+ ok: false,
190
+ error
191
+ };
192
+ const entry = {
193
+ id: newWakeId(input.nowMs),
194
+ target,
195
+ at: isoLocal(parsed.ms),
196
+ message,
197
+ state: "pending",
198
+ createdBy: input.createdBy,
199
+ createdAt: isoLocal(input.nowMs),
200
+ attempts: 0,
201
+ lastResult: null,
202
+ deliveredAt: null
203
+ };
204
+ registry.entries.push(entry);
205
+ const saved = saveWakeRegistry(root, registry);
206
+ if (!saved.ok) return {
207
+ ok: false,
208
+ error: saved.error ?? "写入失败"
209
+ };
210
+ return {
211
+ ok: true,
212
+ entry
213
+ };
214
+ }
215
+ /**
216
+ * 到期切分(纯函数,可单测):
217
+ * - `due`:`state === 'pending'` 且 `now >= at`(在补跑窗口内)→ 应投递
218
+ * - `expired`:`state === 'pending'` 且 `now - at > catchUpMs` → 应置 missed(不投递)
219
+ * @param registry 注册表
220
+ * @param nowMs 当前时刻
221
+ * @param catchUpMs 补跑窗口
222
+ */
223
+ function splitDueWakes(registry, nowMs, catchUpMs = WAKE_CATCH_UP_MS) {
224
+ const due = [];
225
+ const expired = [];
226
+ for (const e of registry.entries) {
227
+ if (e.state !== "pending") continue;
228
+ const at = Date.parse(e.at);
229
+ if (!Number.isFinite(at) || nowMs < at) continue;
230
+ if (nowMs - at > catchUpMs) expired.push(e);
231
+ else due.push(e);
232
+ }
233
+ return {
234
+ due,
235
+ expired
236
+ };
237
+ }
238
+ /**
239
+ * 🔴 结案并**移除**一条(owner 2026-09-19 裁定「**直接删**」,S142 §0Q 甲案)。
240
+ *
241
+ * ## 为什么删(R↓)
242
+ *
243
+ * 本模块原本是「`pending` → 终态」但**终态条目永不移除** ⇒ 文件**只增**(实测 308 KB / 143+ 条)。
244
+ * owner 明令「应当**唤醒后就清理**的」⇒ 终态即删。
245
+ *
246
+ * **审计不丢**:由 **SESSION.md 与工具输出**承担——这正是本文件 `removeWake` 的既有注释承诺
247
+ * (*"审计由 SESSION.md 与工具输出承担——注册表只保留在办项"*)。
248
+ * ⚠️ 此前该承诺**并未兑现**(`updateWake` 只改 state 从不删行)⇒ 本次是**把承诺做实**。
249
+ *
250
+ * ## 与 `updateWake` 的分工(**别混用**)
251
+ *
252
+ * - `updateWake`:**仍在办**的推进(失败重试 `state:'pending'` + `attempts+1`)⇒ **保留行**;
253
+ * - `finalizeWake`:**不再办**的归宿(投递成功 / 超窗 missed)⇒ 写终态后**同一笔事务里删掉**。
254
+ *
255
+ * 🔴 **为什么两个动作要同一次写盘**:若先 `updateWake(delivered)` 再 `removeWake`,
256
+ * 中间被打断会留下一条"已终结但没删"的条目 ⇒ 又回到只增。
257
+ * 故本函数**读一次、改一次、删一次、写一次**(单次原子落盘)。
258
+ *
259
+ * @param root CCC 根
260
+ * @param id 条目 id
261
+ * @param terminal 终态(`delivered` / `missed` / `cancelled`)——写入仅用于**日志文案**,
262
+ * 随即整行移除;保留该参数是为了让调用点语义自明("它归到哪一档了")。
263
+ * @param result 人读结果(进日志,不进表)
264
+ * @returns `removed` = 被移除的条目(供调用方打印"它是什么");不存在 → 稳定错误码
265
+ */
266
+ function finalizeWake(root, id, terminal, result) {
267
+ const { registry, error } = loadWakeRegistry(root);
268
+ if (error) return {
269
+ ok: false,
270
+ error,
271
+ removed: null
272
+ };
273
+ const idx = registry.entries.findIndex((e) => e.id === id);
274
+ if (idx < 0) return {
275
+ ok: false,
276
+ error: `wake_not_found: ${id}`,
277
+ removed: null
278
+ };
279
+ const [removed] = registry.entries.splice(idx, 1);
280
+ const saved = saveWakeRegistry(root, registry);
281
+ if (!saved.ok) return {
282
+ ok: false,
283
+ error: saved.error,
284
+ removed: null
285
+ };
286
+ return {
287
+ ok: true,
288
+ error: null,
289
+ removed: {
290
+ ...removed,
291
+ state: terminal,
292
+ lastResult: result
293
+ }
294
+ };
295
+ }
296
+ /**
297
+ * 一次性清除**存量终态条目**(`delivered` / `missed` / `cancelled`)。
298
+ *
299
+ * ## 为什么需要(不是可选)
300
+ *
301
+ * 本函数上线前,表里已积压 143+ 条终态记录(308 KB)⇒ **只改行为不改存量 = 文件不再涨但仍很大**。
302
+ * 故首次运行须清一次。**幂等**(无终态条目时空转,不写盘)。
303
+ *
304
+ * 审计不丢:与 `finalizeWake` 同理(终态本就属性"已结案",其史由 SESSION.md 承担)。
305
+ * @param root CCC 根
306
+ * @returns 清掉几条(0 = 本来就没有)
307
+ */
308
+ function purgeFinalizedWakes(root) {
309
+ const { registry, error } = loadWakeRegistry(root);
310
+ if (error) return {
311
+ ok: false,
312
+ error,
313
+ purged: 0
314
+ };
315
+ const kept = registry.entries.filter((e) => e.state === "pending");
316
+ const purged = registry.entries.length - kept.length;
317
+ if (purged === 0) return {
318
+ ok: true,
319
+ error: null,
320
+ purged: 0
321
+ };
322
+ const saved = saveWakeRegistry(root, {
323
+ ...registry,
324
+ entries: kept
325
+ });
326
+ if (!saved.ok) return {
327
+ ok: false,
328
+ error: saved.error,
329
+ purged: 0
330
+ };
331
+ return {
332
+ ok: true,
333
+ error: null,
334
+ purged
335
+ };
336
+ }
337
+ /**
338
+ * 更新一条(状态推进;调用方给最终状态与结果文案)。
339
+ *
340
+ * ⚠️ **只用于"仍在办"的推进**(主要是失败重试)。**终态请用 `finalizeWake`**——
341
+ * 否则该行会永远留下(这正是本表过去只增的根因,见 `finalizeWake` 注释)。
342
+ * @param root CCC 根
343
+ * @param id 条目 id
344
+ * @param patch 要写入的字段
345
+ * @returns 写入结果
346
+ */
347
+ function updateWake(root, id, patch) {
348
+ const { registry, error } = loadWakeRegistry(root);
349
+ if (error) return {
350
+ ok: false,
351
+ error
352
+ };
353
+ const entry = registry.entries.find((e) => e.id === id);
354
+ if (!entry) return {
355
+ ok: false,
356
+ error: `wake_not_found: ${id}`
357
+ };
358
+ Object.assign(entry, patch);
359
+ return saveWakeRegistry(root, registry);
360
+ }
361
+ /** 列条目(默认全量;`pendingOnly` 只列在办项——面板/工具用) */
362
+ function listWakes(root, opts = {}) {
363
+ const { registry, error } = loadWakeRegistry(root);
364
+ return {
365
+ entries: [...opts.pendingOnly ? registry.entries.filter((e) => e.state === "pending") : registry.entries].sort((a, b) => Date.parse(a.at) - Date.parse(b.at)),
366
+ error
367
+ };
368
+ }
369
+ //#endregion
370
+ //#region src/trajectory/user-carrier.ts
371
+ /**
372
+ * trajectory/user-carrier.ts — **造一条与"用户人工创建"基本一致的 dsh 会话载体**(判决 D171,owner 2026-10-20 裁 B)
373
+ *
374
+ * ## 这个文件解决什么(白话)
375
+ *
376
+ * `send-now` 在目标没有活跃会话时会**就地建一条**。它一直能建、也能挂工作区,
377
+ * 但建出来的东西与用户在 UI 里新建的那条**在宿主眼里不是同一种东西**:
378
+ *
379
+ * | 宿主投影 | 用户建 | 我们自建(修前实测) |
380
+ * |---|---|---|
381
+ * | `title` | 自动生成 | **永远 `null`** |
382
+ * | `titleInput.count` | ≥1 | **0** |
383
+ * | 假首轮(对齐声明 + EAP) | 注入 | **被自家递归守卫挡掉** |
384
+ *
385
+ * ## 🔴 根因(读宿主源码,不是推断)
386
+ *
387
+ * `dsh-session-title` 的 `sessionTitleUserMessageOf()` **第一行**就是:
388
+ *
389
+ * ```js
390
+ * if (event.type !== "user/message" || event.data.source.kind !== "user") return void 0
391
+ * ```
392
+ *
393
+ * 而 ACC 投递的消息一律是 `{ kind: 'plugin:dsh-serenity-hooks' }`(`kernel/message-source.ts`)
394
+ * ⇒ **宿主从头到尾不把它当"有真人说过话"的会话** ⇒ 自动命名不触发、`titleInput` 恒 0。
395
+ *
396
+ * ## owner 的裁决 = B:**模拟一个用户开场白**
397
+ *
398
+ * `MessageSourceMap` 里 `user: { kind: 'user' }`,而 `UserMessage.source` 是**任意**
399
+ * `MessageSource` ⇒ `createUserMessage({ content, source: { kind: 'user' } })` **类型合法、
400
+ * 零改 DSH**(D4 仍成立)。
401
+ *
402
+ * ## 🔴 这是一次**明知故犯的伪造**,代价写在这里,不许悄悄付
403
+ *
404
+ * 1. **伪造用户身份**:与 D160「不再造历史」方向相反。范围**仅限**"载体开场白"这一条消息,
405
+ * 且正文**逐字自证**是模拟的({@link CARRIER_OPENING_MARK})⇒ 将来翻日志的人一眼能看出。
406
+ * 2. **污染所有者语料**:`session-corpus` 的"所有者发言"判据**必须**排除这条
407
+ * (判据 = 正文含 {@link CARRIER_OPENING_MARK})⇒ 否则统计"owner 说过什么"会算进一句
408
+ * 机器写的开场白。**这一条是同批改动,不是待办。**
409
+ * 3. **与假首轮的先后**:开场白必须让假首轮先注入(同一次 `agent/inbox/inserted` 里
410
+ * prepend 天然排在真实消息之前),否则 `hasUserMessageHistory` 会把假首轮挡掉。
411
+ * ⇒ 单测钉死顺序,不靠"应该会"。
412
+ *
413
+ * ## 归属(D23)
414
+ * 机制在 ACC(怎么建、建什么形状);**内容归 CCC**(开场白正文由调用方给)。
415
+ *
416
+ * ## 与 `trajectory/carrier.ts` 的关系
417
+ * 🔴 **两个文件、两件事**,名字刻意区分:`carrier.ts` 回答"**这条载体跑完没有**"(等待空闲/销毁竞速);
418
+ * 本文件回答"**怎么造一条载体**"。合在一起会让两条互不相关的依赖链卷成一团。
419
+ */
420
+ /**
421
+ * 模拟开场白的**自证标记**(逐字)。
422
+ *
423
+ * 🔴 三个用途,缺一不可:
424
+ * ① 人类翻日志时能一眼看出"这不是真主说的";
425
+ * ② `session-corpus` 用它**排除**语料(不污染"所有者发言");
426
+ * ③ 单测反向钉住:真实用户发言里**不会**出现这串 ⇒ 断言"语料里 0 条含它"才有意义。
427
+ */
428
+ const CARRIER_OPENING_MARK = "[ACC 代用户开场]";
429
+ /**
430
+ * 构造模拟开场白的正文。
431
+ *
432
+ * ⚠️ **不含版本号/日期/模型名**(D159:任务书里不得写死版本号 ⇒ 写与版本无关的判据)。
433
+ * 正文只描述"这是谁、要接着做什么",不描述"哪一版干的"。
434
+ *
435
+ * @param code 轨迹编码(`S###` 或自定义)
436
+ * @param desc 轨迹目录名里那段可读描述(**人起的**,不另造第二真相源)
437
+ * @returns 一条自证是模拟的开场白正文
438
+ */
439
+ function buildCarrierOpening(code, desc) {
440
+ const label = desc.trim() === "" ? code : `${code}(${desc})`;
441
+ return [
442
+ `${CARRIER_OPENING_MARK} 激活认知轨迹 ${label},接着它 SESSION.md 里的工作往下做。`,
443
+ "",
444
+ "(这条开场白由 ACC 代为发出。如此做的目的只有一个:让程序性创建的会话与用户在界面里",
445
+ "人工创建的会话,在宿主侧具有相同的投影 —— 有标题、有首轮对齐声明。它不是任何人的发言。)"
446
+ ].join("\n");
447
+ }
448
+ /**
449
+ * 投一条"用户形态"的消息进目标 agent(**B 的核心动作**)。
450
+ *
451
+ * 🔴 为什么单独成函数而不是就地写:这是**全仓唯一**允许 `kind:'user'` 的地方。
452
+ * 集中在一处,将来若要收紧(或 owner 改主意回 A),只有一个点要改。
453
+ *
454
+ * @param agent 目标 agent(须有 `followup`)
455
+ * @param text 开场白正文(**必须**由 {@link buildCarrierOpening} 产出,以带上自证标记)
456
+ * @returns 投递是否成功(失败返回 false,由调用方决定降级 —— 不抛)
457
+ */
458
+ function deliverCarrierOpening(agent, text) {
459
+ if (typeof agent.followup !== "function") return false;
460
+ try {
461
+ agent.followup(createUserMessage({
462
+ content: [{
463
+ type: "text",
464
+ text
465
+ }],
466
+ source: { kind: "user" }
467
+ }));
468
+ return true;
469
+ } catch {
470
+ return false;
471
+ }
472
+ }
473
+ //#endregion
474
+ //#region src/trajectory/alive.ts
475
+ /**
476
+ * trajectory/alive.ts — **"这个 dsh 会话还活着吗"的唯一判据**(owner 2026-10-20 裁决)
477
+ *
478
+ * ## owner 的原话(本模块的全部设计依据)
479
+ *
480
+ * > 「死应该包含归档判断;只有 ① 或者 ②且不在归档集 才算活」
481
+ * > 「use 只要被调用了,可以无条件覆盖绑定到当前会话(这说明当前会话肯定活着啊)」
482
+ *
483
+ * ## 为什么必须收成一个判据(在此之前有三个互不一致的判据)
484
+ *
485
+ * | # | 判据 | 旧实现位置 | 单独使用时的漏洞 |
486
+ * |---|---|---|---|
487
+ * | ① | 进程内 loaded agent | `hostAgents(ctx).get(id)` | 冷会话全判死 |
488
+ * | ② | 磁盘上有会话日志 | `hasSessionLogById()` | **归档后日志逐字节不动** ⇒ 归档会话判活 |
489
+ * | ③ | 宿主归档集 | `workspaceRegistry.archivedSessionIds` | 旧 dsp **零引用**(整个仓库 0 处) |
490
+ *
491
+ * 宿主的归档语义是**只往集合里加一个 id、不动日志**(`dsh-workspace` 的
492
+ * `archiveSession` 注释:「Archiving never touches workspace accounting」)。
493
+ * ⇒ 只问 ② 的实现在归档上**必然判错**,而那个错误没有任何机械信号。
494
+ *
495
+ * ## 判据(owner 给定,逐字实现)
496
+ *
497
+ * ```text
498
+ * 活 ⟺ ① (进程内 loaded ⇒ 一定在)
499
+ * ∨ (② ∧ 不在归档集) (日志在、且没人把它归档掉)
500
+ * ```
501
+ *
502
+ * 🔴 **一处对原话的收紧(必须说出来,不许默默改)**:owner 的 ① 未附加"不在归档集",
503
+ * 而本模块把**归档判定排在最前** —— 即 `归档 ∧ live ⇒ 判死`。理由:归档是**人对这条会话
504
+ * 的处置声明**(宿主会把它从所有分组面隐藏),若仍被当成载体,唤醒调度器会去驱动一条
505
+ * **使用者以为已经收起来**的会话。⚠️ 与字面规则的差异仅此一格;`use` 不受影响
506
+ * (见下方 §use 逃生门),因此真需要复活一条已归档会话时**仍然有受支持的路**。
507
+ *
508
+ * ## 🔴 `use` 的逃生门(owner 第二条,写在调用点)
509
+ *
510
+ * **`use` 不查存活**:它被调用这件事本身证明**当前会话活着**(本模块的被测对象是
511
+ * "别人写的那个 id",而 `use` 手里拿的是**自己**)。⇒ `use` 走无条件覆盖
512
+ * (`claimBinding` 的 `takeover`),因此**判据再严也不会把一条轨迹锁死**。
513
+ * 这条逃生门是判据可以取严的前提,两者必须一起存在。
514
+ *
515
+ * ## 依赖纪律
516
+ *
517
+ * 纯判据 `isSessionAlive` 零依赖、可单测;宿主面只在 `hostAliveProbe` 里,
518
+ * 且**不抛错**(服务缺失 ⇒ 该维度作废,而不是让 ACC 工具整块失败)。
519
+ */
520
+ /** 宿主侧的归档集读取(不可用 ⇒ 空集 = 该维度作废,**不是**把所有会话判死) */
521
+ function archivedIdsOf(ctx) {
522
+ try {
523
+ const ids = hostService(ctx, "workspaceRegistry")?.archivedSessionIds;
524
+ return new Set(Array.isArray(ids) ? ids : []);
525
+ } catch {
526
+ return /* @__PURE__ */ new Set();
527
+ }
528
+ }
529
+ /**
530
+ * **宿主面的判活探针**(`(sessionId) => boolean`)——所有需要"这条会话还活着吗"的
531
+ * 生产代码都应当用这一个,不要自己拼 `agents.get` + `hasSessionLogById`。
532
+ *
533
+ * 语义 = {@link isSessionAlive},其中:
534
+ * · `liveIds` 因为宿主只给 `get(id)` 而**按 id 逐次问**(不是集合);
535
+ * · `archivedIds` 在**首次调用时**取一次快照并在闭包内复用(每 tick 一个探针,
536
+ * 归档集在一个 tick 内不变)—— 避免为每条轨迹重复读存储域。
537
+ *
538
+ * ⚠️ **快照的代价**:同一探针内若有人中途归档了某会话,本探针直到下次新建才看见。
539
+ * 调度器是每 tick 新建一个探针(见 `wake/scheduler.ts`),故实际窗口 ≤1 tick。
540
+ *
541
+ * @param ctx 插件上下文(取 `agents` 与 `workspaceRegistry`)
542
+ * @returns 探针;**永不抛**
543
+ */
544
+ function hostAliveProbe(ctx) {
545
+ let archived;
546
+ const archivedOnce = () => archived ??= archivedIdsOf(ctx);
547
+ return (sessionId) => {
548
+ if (typeof sessionId !== "string" || sessionId === "") return false;
549
+ if (archivedOnce().has(sessionId)) return false;
550
+ try {
551
+ if (hostAgents(ctx)?.get?.(sessionId)) return true;
552
+ } catch {}
553
+ try {
554
+ return hasSessionLogById(sessionsRootDir(), sessionId);
555
+ } catch {
556
+ return true;
557
+ }
558
+ };
559
+ }
560
+ /**
561
+ * 判据只有**两个**出口,别再开第三个:
562
+ * · {@link isSessionAlive} —— 纯判据(给定事实),单测的靶子;
563
+ * · {@link hostAliveProbe} —— 生产入口(自己去宿主与磁盘取事实)。
564
+ *
565
+ * ⚠️ 曾有一个 `aliveFactsFor`(把探针摊平成 `SessionAliveFacts`),**全仓零调用点**,
566
+ * 已删除 —— 留一个"看起来有用、实际无人调用"的导出,会让下一个人以为存在第二条判活路径
567
+ * (本仓同族教训:`cheapWorkerPresetInheritance` 全仓零调用点 ⇒ 接线断了却看不出来)。
568
+ */
569
+ //#endregion
570
+ //#region src/cro/core.ts
571
+ /**
572
+ * cro/core.ts — CRO(Continuous Re-Occurrence · 持续再发生)机制层(S142 §7.8,2026-09-19)
573
+ *
574
+ * ## 这东西用来干什么(白话)
575
+ *
576
+ * 今天叫醒一条轨迹的方式是「几点几分叫我」。但**该不该醒,往往不是时间说了算**:
577
+ * 比如 S185 应该「天亮 + 家里有人 + 非高峰」才醒;已经在干活就不该再叫;
578
+ * 日志太大了,下次叫它时该让它先整理。
579
+ *
580
+ * CRO 让**一条轨迹自己带一段程序**,由 ACC 在每次检查时跑它,
581
+ * **由这段程序决定「现在该不该叫我、叫我的时候说什么」**。
582
+ *
583
+ * ## 归属(所有者 2026-09-19 定:**机制属 ACC,CCC 是用户**)
584
+ *
585
+ * | 面 | 归谁 | 内容 |
586
+ * |---|---|---|
587
+ * | **机制** | 🔴 **ACC(本模块)** | 契约 / 调度 / 执行 / 状态暴露 / 容错 |
588
+ * | **程序** | 🔴 **CCC(用户)** | 那段判定的 TS 代码,放**轨迹自己的目录**里 |
589
+ *
590
+ * ⇒ 与 `send-later` 同构:**工具在 ACC,用它在 CCC**。
591
+ *
592
+ * ## 命名(防混淆,写死)
593
+ *
594
+ * **`CRO` = 那段程序**(实体名),**不是** ACC 标准 §0.1 三环节(发生/存储/**再发生**)的第三环本身。
595
+ * 命名来源 = §0.3「**Trajectory 在寻找 Agent**」——CRO 是「再发生」这一环的自动化。
596
+ *
597
+ * ## 位置与形态(设计 §2)
598
+ *
599
+ * `<CCC 根>/AGENT_SESSIONS/<轨迹目录>/continuous-re-occurrence.ts`
600
+ * · **文件名全写**(所有者令);· **程序只有一个**:**文件在 = 启用,不在 = 禁用**(无 enabled 字段、无注册表)。
601
+ *
602
+ * ## 🔴 运行契约 = B 案(**ACC 只 spawn,从不 import**)
603
+ *
604
+ * 所有者 2026-09-19:「**B 是设计,A 只是文档友好**」。
605
+ * 若 ACC **import** 那段 TS,会出现两件事:
606
+ * ① ACC 依赖 CCC 的**源码路径**(npm 装机版在别处 ⇒ 两条路径都要活 = 两个真相源);
607
+ * ② **用户程序语法错会让 ACC 启动失败** ⇒ **一个用户程序的错误放倒整个容器**。
608
+ * ⇒ 进程边界把这两件事同时挡掉:**路径只需一个(文件系统),错误被隔离在子进程里**。
609
+ *
610
+ * ## 🔴 铁律:CRO 的任何失败,绝不影响既有机制(设计 §5)
611
+ *
612
+ * `send-later` / `send-now` / 唤醒表的投递**照常工作**。
613
+ * 先例 = `im/hook.ts` 的**旁路容忍**("超时 kill / 非 0 退出 / spawn 失败 → 仅日志返回,绝不抛")。
614
+ * ⇒ 本模块的**每一个**导出函数**都不抛错**(返回结构化失败)。
615
+ */
616
+ /** 入口文件名(**全写**,所有者令 2026-09-19;不改缩写) */
617
+ const CRO_FILENAME = "continuous-re-occurrence.ts";
618
+ /**
619
+ * 机器绑定文件名(**全写**,与 `CRO_FILENAME` 同风格)。
620
+ *
621
+ * 🔴 **它随 CCC git 走是设计意图,不是缺陷**:
622
+ * 绑定声明必须跟着**轨迹**走,才能在第二台机器上被读到、并被判为"不是我的"。
623
+ * (这正是机器绑定的整个目的 —— 见 `resolveCroHostBinding` 的头注。)
624
+ */
625
+ const CRO_HOST_FILENAME = "cro-host-binding";
626
+ /** 硬超时(沿用 `biasProvider` 先例的 60s;设计 §5 情形 2) */
627
+ const CRO_TIMEOUT_MS = 6e4;
628
+ /** stdout 上限(防一个坏程序把日志撑爆;沿用 hook 截断思路) */
629
+ const CRO_STDOUT_MAX = 65536;
630
+ /** 绑定文件体积上限(它只是个机器名 ⇒ 超过这个数一定是写错了,不读) */
631
+ const CRO_HOST_FILE_MAX = 1024;
632
+ /** SESSION.md 文件名(与 `trajectory/core.ts` 同值;此处本地常量避免反向依赖) */
633
+ const SESSION_MD = "SESSION.md";
634
+ /**
635
+ * 展示码派生(**只用于展示**,不作识别依据 —— §0I U4:锚永远是 `dirName`)。
636
+ *
637
+ * 判据(设计 §1):**编号不是固定格式,不同 CCC 格式不同**(本容器 `S###`,别处可能是 issue 号
638
+ * 或自定义前缀)⇒ 这里只做**尽力而为的展示性提取**,取不到就返回空串。
639
+ * 🔴 **绝不**因为取不到就拒绝服务——`dirName` 才是硬锚。
640
+ * @param dirName 轨迹目录名
641
+ * @returns `S###` 形态的展示码;取不到 ⇒ `''`
642
+ */
643
+ function deriveCode(dirName) {
644
+ const m = /(?:^|--)S(\d{2,})(?:--|$)/.exec(dirName);
645
+ return m ? `S${m[1]}` : "";
646
+ }
647
+ /** epoch ms → 当地 ISO(不可解析 ⇒ null) */
648
+ function isoOrNull(ms) {
649
+ return typeof ms === "number" && Number.isFinite(ms) ? isoLocal(ms) : null;
650
+ }
651
+ /**
652
+ * 装配快照(**纯函数**:只做数据整形,不读 fs、不看 ctx)⇒ 可穷举测试。
653
+ * @param input 已读好的原始数据
654
+ * @returns 喂给 CRO 程序的快照
655
+ */
656
+ function buildCroSnapshot(input) {
657
+ return {
658
+ version: 1,
659
+ identity: {
660
+ dirName: input.dirName,
661
+ code: deriveCode(input.dirName),
662
+ cccRoot: input.cccRoot
663
+ },
664
+ time: {
665
+ now: isoLocal(input.nowMs),
666
+ nowMs: input.nowMs,
667
+ nowLocal: new Date(input.nowMs).toString()
668
+ },
669
+ body: {
670
+ sessionMdPath: input.sessionMdPath,
671
+ sessionMdBytes: input.sessionMdBytes,
672
+ sessionMdMtime: isoOrNull(input.sessionMdMtimeMs),
673
+ references: input.references.map((r) => ({
674
+ name: r.name,
675
+ bytes: r.bytes,
676
+ mtime: isoLocal(r.mtimeMs)
677
+ }))
678
+ },
679
+ binding: {
680
+ boundSessionIds: input.boundSessionIds,
681
+ liveSessionIds: input.liveSessionIds,
682
+ runningSessionIds: input.runningSessionIds
683
+ },
684
+ scheduling: {
685
+ pendingWakes: input.pendingWakes,
686
+ scheduler: input.scheduler
687
+ }
688
+ };
689
+ }
690
+ /**
691
+ * 解析 CRO 程序 stdout(**纯函数** ⇒ 可穷举测试)。
692
+ *
693
+ * ## 边界(设计 §4.1)
694
+ * · `wake` 缺省 / `false` ⇒ **不唤起**(**这是常态**)——`prompt` 可省;
695
+ * · `wake: true` 但 `prompt` 空 / 非串 ⇒ 🔴 **非法** ⇒ 返回错误(调用方**跳过本轮**,不投递空消息);
696
+ * · 非 JSON / 非对象 ⇒ 错误。
697
+ *
698
+ * ## 🔴 为什么 `reason` 重要(CCE:重建 > 保存)
699
+ * 改成程序判定后,**「当时为什么叫了」不再能从时间表重建**(原因在程序肚子里:
700
+ * 可能有随机、可能看了外部数据)⇒ 要求程序自报理由,把「决策依据」重新变成**可重建的**。
701
+ * ⇒ **强烈建议但不强制**(强制会让简单程序难写;设计 §9-6 待裁,本版取"建议")。
702
+ *
703
+ * @param stdout 程序标准输出(可含前后空白;允许多行 JSON 文本)
704
+ * @returns 决策 或 结构化错误
705
+ */
706
+ function parseCroOutput(stdout) {
707
+ const text = stdout.trim();
708
+ if (text === "") return {
709
+ ok: false,
710
+ error: "cro-empty-output",
711
+ detail: "程序没有输出(期望 stdout 一行 JSON)"
712
+ };
713
+ let raw;
714
+ try {
715
+ raw = JSON.parse(text);
716
+ } catch (err) {
717
+ return {
718
+ ok: false,
719
+ error: "cro-bad-json",
720
+ detail: `输出不是合法 JSON: ${String(err?.message ?? err)}`
721
+ };
722
+ }
723
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
724
+ ok: false,
725
+ error: "cro-bad-json",
726
+ detail: "输出必须是 JSON 对象"
727
+ };
728
+ const obj = raw;
729
+ const wake = obj.wake === true;
730
+ const reason = typeof obj.reason === "string" && obj.reason.trim() !== "" ? obj.reason.trim() : null;
731
+ if (!wake) return {
732
+ ok: true,
733
+ decision: {
734
+ wake: false,
735
+ prompt: null,
736
+ reason
737
+ }
738
+ };
739
+ if (typeof obj.prompt !== "string" || obj.prompt.trim() === "") return {
740
+ ok: false,
741
+ error: "cro-missing-prompt",
742
+ detail: "wake=true 但 prompt 缺失或为空"
743
+ };
744
+ return {
745
+ ok: true,
746
+ decision: {
747
+ wake: true,
748
+ prompt: obj.prompt.trim(),
749
+ reason
750
+ }
751
+ };
752
+ }
753
+ /**
754
+ * CRO 程序路径:`<CCC 根>/AGENT_SESSIONS/<dirName>/continuous-re-occurrence.ts`。
755
+ *
756
+ * ⚠️ 路径逃逸校验(`resolveInside`):`dirName` 来自配置/工具入参,
757
+ * 必须确保解析后仍在 CCC 根内(同 `im/hook.ts:160` 手法)。**抛错由调用方吞**。
758
+ * @param root CCC 根
759
+ * @param dirName 轨迹目录名
760
+ * @returns 绝对路径
761
+ */
762
+ function croScriptPath(root, dirName) {
763
+ return resolveInside(root, join("AGENT_SESSIONS", dirName, CRO_FILENAME));
764
+ }
765
+ /**
766
+ * 解析机器绑定 —— 「**这条轨迹由哪台机器负责自唤醒**」(所有者令 2026-10-15)。
767
+ *
768
+ * ## 所有者裁定的语义(逐字,D135)
769
+ *
770
+ * 「**CRO 必须指定绑定机器,否则不运行,唤醒器 检查这个**」⇒ 三条同时成立:
771
+ * ① **只许这一台**(白名单,不是"排除这一台");
772
+ * ② **fail-closed** —— **没绑 = 不跑**(不是"没绑 = 随便跑");
773
+ * ③ **检查者在唤醒器**(本函数由 `runCroPhase` 调用,不是程序自己判)。
774
+ *
775
+ * ## 绑定键 = 机器名(所有者 2026-10-15 裁「用 hostname 吧,比较合理」)
776
+ *
777
+ * ⚠️ **已接受的取舍**:本机机器名是 QEMU 机型派生的默认形
778
+ * (`yh-Standard-PC-Q35-ICH9-2009`)⇒ 同法新建的同名用户 VM 会取到同一个名字。
779
+ * 所有者 2026-10-15 明确「**不改名,家里就这几台机器没必要**」⇒ **风险由所有者接受**。
780
+ *
781
+ * ## 🔴 为什么必须是三态,「没绑」不能并进「绑给别人」
782
+ *
783
+ * 2026-10-15 已发生过**同族事故**:`send-now` 失败时报的是「无绑定会话记录」,
784
+ * 而真因是"那条轨迹不可寻址"—— **文案相同、原因不同**,害得排查方向错了一次
785
+ * (S142 §日志 续 129 / D134)。
786
+ * ⇒ 绑定这一层**不许再造同样的坑**:「你没绑」与「你绑给了别人」是**两件不同的事**,
787
+ * 日志必须分开写 —— 否则将来"为什么这条轨迹不跑了"会又一次指错方向。
788
+ *
789
+ * ## 文件形态(刻意的极简)
790
+ *
791
+ * · 位置 = `<轨迹目录>/cro-host-binding`(与 CRO 程序**同级**)
792
+ * · 内容 = **一行机器名**(首尾空白裁掉;只取第一行有效内容)
793
+ * · 不用 JSON —— 它只有一个值,JSON 只会多一种写错的方式
794
+ * · 支持 `#` 注释行与空行(方便人写、方便留痕)
795
+ *
796
+ * 🔴 **它必须随 git 入库**(不是"运行态"):绑定声明要**跟着轨迹走到第二台机器**,
797
+ * 在那里被判成「绑给了别人」而**正确地不跑** —— 那就是本机制在工作。
798
+ * ⇒ ⛔ **不要把它加进 `.gitignore`**(那会让第二台机器读到"没绑"而同样不跑,
799
+ * 表面上结果一样,但**判据从"明确绑给别人"退化成"什么都没说"**,失去可诊断性)。
800
+ *
801
+ * ## 为什么读文件而不是读快照字段
802
+ *
803
+ * 快照(`CroSnapshot`)是喂给**程序**的;而本判定发生在**程序被 spawn 之前**
804
+ * —— 程序还没跑,没有快照可读。⇒ 只能读文件。(这也是"机制属 ACC"的体现:
805
+ * ACC 在调用用户程序前先判定自己该不该调用。)
806
+ *
807
+ * ## 🆕 2026-10-19 owner 裁 Q2:**声明文件里的 `cro.host` 优先,独立文件回落**
808
+ *
809
+ * 收拢后机器名住在 `<轨迹目录>/trajectory.json` 的 `cro.host` 字段里。
810
+ * 判据(**两代并读,不硬切**):**声明面里有合法 `cro.host` ⇒ 用它;否则回落 `cro-host-binding` 文件**。
811
+ *
812
+ * 🔴 **为什么这里绝不能硬切**(比别处更硬的一条,逐字保留原裁定):
813
+ * 硬切之后,**第二台机器**读到的是「没绑」而不是「绑给了别人」——
814
+ * 两者都导致"不跑",**表面结果相同,但判据退化**(失去可诊断性)。
815
+ * ⇒ 只要还有一台机器可能装着旧版本,两条路都必须能读。
816
+ *
817
+ * @param root CCC 根
818
+ * @param dirName 轨迹目录名
819
+ * @param selfHost 本机机器名(**注入** ⇒ 可测;生产调用方传 `hostname()`)
820
+ * @returns 判定结果(**永不抛** —— 旁路容忍铁律)
821
+ */
822
+ function resolveCroHostBinding(root, dirName, selfHost) {
823
+ const self = String(selfHost ?? "").trim();
824
+ let fileAbs;
825
+ try {
826
+ fileAbs = resolveInside(root, join("AGENT_SESSIONS", dirName, CRO_HOST_FILENAME));
827
+ } catch (err) {
828
+ return {
829
+ allowed: false,
830
+ bound: null,
831
+ self,
832
+ code: "read-error",
833
+ detail: `绑定文件路径非法(已忽略):${String(err?.message ?? err)}`
834
+ };
835
+ }
836
+ const fromJson = readCroHostFromDeclaration(root, dirName);
837
+ if (fromJson !== null) return verdictFor(fromJson, self);
838
+ let raw;
839
+ try {
840
+ if (!existsSync(fileAbs)) return {
841
+ allowed: false,
842
+ bound: null,
843
+ self,
844
+ code: "not-bound",
845
+ detail: `未绑定机器(缺 ${CRO_HOST_FILENAME},且 ${TRAJECTORY_FILENAME} 里也没有 cro.host)⇒ 按"必须指定"不运行`
846
+ };
847
+ const st = statSync(fileAbs);
848
+ if (st.size > CRO_HOST_FILE_MAX) return {
849
+ allowed: false,
850
+ bound: null,
851
+ self,
852
+ code: "read-error",
853
+ detail: `绑定文件过大(${st.size} B > ${CRO_HOST_FILE_MAX} B)⇒ 判为写错,不运行`
854
+ };
855
+ raw = readFileSync(fileAbs, "utf-8");
856
+ } catch (err) {
857
+ return {
858
+ allowed: false,
859
+ bound: null,
860
+ self,
861
+ code: "read-error",
862
+ detail: `绑定文件读取失败 ⇒ 不运行:${String(err?.message ?? err)}`
863
+ };
864
+ }
865
+ const bound = parseCroHostBinding(raw);
866
+ if (bound === null) return {
867
+ allowed: false,
868
+ bound: null,
869
+ self,
870
+ code: "not-bound",
871
+ detail: `绑定文件为空(只有注释/空行)⇒ 按"必须指定"不运行`
872
+ };
873
+ return verdictFor(bound, self);
874
+ }
875
+ /**
876
+ * 从**声明文件**(`trajectory.json` 族)里取 `cro.host`(取不到 ⇒ `null`)。
877
+ *
878
+ * 🔴 **永不抛**:本函数跑在 tick 内(旁路容忍铁律);坏 JSON / 结构不符一律按"没给"处理,
879
+ * 于是**自然回落到独立文件那条路** —— 这正是两代并读想要的降级方向。
880
+ *
881
+ * @param root CCC 根
882
+ * @param dirName 轨迹目录名
883
+ * @returns 机器名;没给 ⇒ `null`
884
+ */
885
+ function readCroHostFromDeclaration(root, dirName) {
886
+ try {
887
+ const cro = readTrajectoryFile(join(sessionsRoot(root), dirName))?.data?.["cro"];
888
+ if (!cro || typeof cro !== "object" || Array.isArray(cro)) return null;
889
+ const host = cro["host"];
890
+ if (typeof host !== "string") return null;
891
+ const trimmed = host.trim();
892
+ return trimmed === "" ? null : trimmed;
893
+ } catch {
894
+ return null;
895
+ }
896
+ }
897
+ /**
898
+ * 已解析出的机器名 → 判定结果(三种态的唯一收敛点)。
899
+ *
900
+ * 🔴 **抽出来的理由**:两条读取路(声明文件 / 独立文件)必须给出**同一种**判定语义;
901
+ * 各写一份迟早会漂("声明面绑给别人"与"文件面绑给别人"在日志里长得不一样就糟了)。
902
+ */
903
+ function verdictFor(bound, self) {
904
+ if (bound === self) return {
905
+ allowed: true,
906
+ bound,
907
+ self,
908
+ code: "bound-to-self",
909
+ detail: `已绑定本机(${bound})`
910
+ };
911
+ return {
912
+ allowed: false,
913
+ bound,
914
+ self,
915
+ code: "bound-to-other",
916
+ detail: `绑定给别的机器(绑定=${bound},本机=${self})⇒ 本机不运行`
917
+ };
918
+ }
919
+ /**
920
+ * 解析绑定文件正文 ⇒ 机器名(取不到 ⇒ `null`)。
921
+ *
922
+ * 规则(刻意宽松:**只认一行,其余都当注释**):
923
+ * · 逐行扫;跳过空行与 `#` 开头的行;
924
+ * · **第一条有效行** = 机器名(裁首尾空白);
925
+ * · 无有效行 ⇒ `null`。
926
+ * @param raw 文件正文
927
+ * @returns 机器名;无有效内容 ⇒ `null`
928
+ */
929
+ function parseCroHostBinding(raw) {
930
+ for (const line of String(raw ?? "").split(/\r?\n/)) {
931
+ const t = line.trim();
932
+ if (t === "" || t.startsWith("#")) continue;
933
+ return t;
934
+ }
935
+ return null;
936
+ }
937
+ /**
938
+ * 列出**启用了 CRO 的轨迹目录名**(供调度器每 tick 扫描,设计 §6.1)。
939
+ *
940
+ * ## 判据与形态(R↓)
941
+ * · 判据 = **文件在不在**(同 §2.1)——**没有注册表**,所以"谁启用了"只能靠**扫目录**:
942
+ * `AGENT_SESSIONS/` 下每个目录查一次 `<目录>/continuous-re-occurrence.ts`。
943
+ * · **一次 readdir + 每个目录一次 existsSync**:N 条轨迹的代价是 O(N) 次 `stat`,
944
+ * 每 5min 一次 —— 与既有 `listSessions` 同量级,可接受。
945
+ * · 跳过 **`_` 前缀**(`_archived` / `_轨迹约束-logs` / `_weixin-logs` = 系统与日志目录)
946
+ * 与 **`.` 前缀**(隐藏)——它们**不是轨迹**,且扫它们纯属浪费。
947
+ * · 🔴 **本函数绝不抛错**(返回 `[]`)——它跑在调度器 tick 内,任何异常都可能影响既有链路
948
+ * (设计 §5 铁律)。目录读不到(不存在 / 权限)⇒ `[]` = "没有轨迹启用 CRO",语义正确。
949
+ *
950
+ * ⚠️ **为什么不做缓存**:缓存会引入"文件删了但缓存还在"的失效模式(本容器栽过的"第二真相源"),
951
+ * 而这里省下的只是一次 `readdir`——**不值当**。
952
+ *
953
+ * @param root CCC 根
954
+ * @returns 启用了 CRO 的轨迹目录名(**排序后**,保证同 tick 顺序稳定、便于日志比对)
955
+ */
956
+ function listCroCognitiveTrajectories(root) {
957
+ const rootDir = sessionsRoot(root);
958
+ let entries;
959
+ try {
960
+ entries = readdirSync(rootDir, { withFileTypes: true });
961
+ } catch {
962
+ return [];
963
+ }
964
+ const out = [];
965
+ for (const e of entries) {
966
+ if (!e.isDirectory()) continue;
967
+ if (e.name.startsWith("_") || e.name.startsWith(".")) continue;
968
+ try {
969
+ if (existsSync(join(rootDir, e.name, "continuous-re-occurrence.ts"))) out.push(e.name);
970
+ } catch {}
971
+ }
972
+ return out.sort();
973
+ }
974
+ /**
975
+ * 执行一次 CRO 程序(**永不抛** —— 旁路容忍铁律)。
976
+ *
977
+ * runner 顺序(照 `im/hook.ts:171-174` 的 bun 优先 / node 兜底):
978
+ * `bun` → `process.execPath`(同运行时)。
979
+ * 判据:`ENOENT`(二进制不存在)⇒ 试下一个;**其余失败视为最终结果**
980
+ * (程序自己报错就是报错,不该用另一个 runner 掩盖——与 hook 一致)。
981
+ * @param scriptAbs 程序绝对路径
982
+ * @param stdinJson 喂给程序的快照 JSON
983
+ * @param timeoutMs 硬超时
984
+ * @returns 执行结果(含输出的解析交由 `parseCroOutput`)
985
+ */
986
+ async function runCroProcess(scriptAbs, stdinJson, timeoutMs = CRO_TIMEOUT_MS) {
987
+ const runners = [["bun", [scriptAbs]], [process.execPath, [scriptAbs]]];
988
+ for (const [cmd, args] of runners) {
989
+ const res = await runCroOnce(cmd, args, stdinJson, timeoutMs);
990
+ if (!res.ok && res.code === "ENOENT") continue;
991
+ return res;
992
+ }
993
+ return {
994
+ ok: false,
995
+ stdout: "",
996
+ error: "CRO 执行失败(bun 与 node 均不可用)",
997
+ code: "cro-no-runner"
998
+ };
999
+ }
1000
+ /** 单次 spawn(Promise 化 + 超时 kill + 输出截断 + **绝不抛**) */
1001
+ function runCroOnce(cmd, args, stdinJson, timeoutMs) {
1002
+ return new Promise((resolvePromise) => {
1003
+ let settled = false;
1004
+ let stdout = "";
1005
+ let stderr = "";
1006
+ let child = null;
1007
+ try {
1008
+ child = spawn(cmd, args, {
1009
+ stdio: [
1010
+ "pipe",
1011
+ "pipe",
1012
+ "pipe"
1013
+ ],
1014
+ env: { ...process.env }
1015
+ });
1016
+ } catch (err) {
1017
+ return resolvePromise({
1018
+ ok: false,
1019
+ stdout: "",
1020
+ error: String(err),
1021
+ code: "SPAWN_ERR"
1022
+ });
1023
+ }
1024
+ const timer = setTimeout(() => {
1025
+ if (settled) return;
1026
+ settled = true;
1027
+ child?.kill("SIGKILL");
1028
+ resolvePromise({
1029
+ ok: false,
1030
+ stdout,
1031
+ error: `CRO 执行超时(>${Math.round(timeoutMs / 1e3)}s,已 kill)`,
1032
+ code: "cro-timeout"
1033
+ });
1034
+ }, timeoutMs);
1035
+ child.stdin?.on("error", () => {});
1036
+ try {
1037
+ child.stdin?.write(stdinJson);
1038
+ child.stdin?.end();
1039
+ } catch {}
1040
+ child.stdout?.on("data", (d) => {
1041
+ stdout = (stdout + d.toString()).slice(0, CRO_STDOUT_MAX);
1042
+ });
1043
+ child.stderr?.on("data", (d) => {
1044
+ stderr = (stderr + d.toString()).slice(0, CRO_STDOUT_MAX);
1045
+ });
1046
+ child.on("error", (err) => {
1047
+ if (settled) return;
1048
+ settled = true;
1049
+ clearTimeout(timer);
1050
+ resolvePromise({
1051
+ ok: false,
1052
+ stdout,
1053
+ error: String(err.message ?? err),
1054
+ code: err.code
1055
+ });
1056
+ });
1057
+ child.on("close", (code) => {
1058
+ if (settled) return;
1059
+ settled = true;
1060
+ clearTimeout(timer);
1061
+ if (code === 0) resolvePromise({
1062
+ ok: true,
1063
+ stdout,
1064
+ error: null
1065
+ });
1066
+ else {
1067
+ const detail = stderr.trim().split("\n")[0] ?? "";
1068
+ resolvePromise({
1069
+ ok: false,
1070
+ stdout,
1071
+ error: `CRO 程序退出码 ${code}${detail ? `:${detail}` : ""}`,
1072
+ code: "cro-exit-nonzero"
1073
+ });
1074
+ }
1075
+ });
1076
+ });
1077
+ }
1078
+ /** 读目录清单(不存在/读失败 ⇒ 空数组;**不回退抛错**) */
1079
+ function readDirEntries(dir) {
1080
+ try {
1081
+ return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile()).map((e) => {
1082
+ const p = join(dir, e.name);
1083
+ try {
1084
+ const st = statSync(p);
1085
+ return {
1086
+ name: e.name,
1087
+ bytes: st.size,
1088
+ mtimeMs: st.mtimeMs
1089
+ };
1090
+ } catch {
1091
+ return null;
1092
+ }
1093
+ }).filter((x) => x !== null);
1094
+ } catch {
1095
+ return [];
1096
+ }
1097
+ }
1098
+ /** 读 SESSION.md 体积与 mtime(不存在/读失败 ⇒ null) */
1099
+ function readMdStat(mdPath) {
1100
+ try {
1101
+ const st = statSync(mdPath);
1102
+ return {
1103
+ bytes: st.size,
1104
+ mtimeMs: st.mtimeMs
1105
+ };
1106
+ } catch {
1107
+ return {
1108
+ bytes: null,
1109
+ mtimeMs: null
1110
+ };
1111
+ }
1112
+ }
1113
+ /** 读取快照所需的 fs 侧数据(**纯读取,不抛**) */
1114
+ function readCroSnapshotInput(root, dirName, nowMs, extras) {
1115
+ const dir = join(sessionsRoot(root), dirName);
1116
+ const mdPath = join(dir, SESSION_MD);
1117
+ const md = readMdStat(mdPath);
1118
+ return {
1119
+ dirName,
1120
+ cccRoot: root,
1121
+ nowMs,
1122
+ sessionMdPath: mdPath,
1123
+ sessionMdBytes: md.bytes,
1124
+ sessionMdMtimeMs: md.mtimeMs,
1125
+ references: readDirEntries(join(dir, "references")),
1126
+ boundSessionIds: extras.boundSessionIds,
1127
+ liveSessionIds: extras.liveSessionIds,
1128
+ runningSessionIds: extras.runningSessionIds,
1129
+ pendingWakes: extras.pendingWakes,
1130
+ scheduler: extras.scheduler
1131
+ };
1132
+ }
1133
+ /**
1134
+ * 评估一条轨迹的 CRO 程序:**文件在 ⇒ 跑它;不在 ⇒ 未启用**(设计 §2.1)。
1135
+ *
1136
+ * 🔴 **本函数永不抛错**(旁路容忍铁律):任何失败都返回 `{status:'skipped'}`,
1137
+ * 调用方据此**跳过本轮**、**不影响既有投递链路**。
1138
+ *
1139
+ * @param root CCC 根
1140
+ * @param dirName 轨迹目录名
1141
+ * @param snapshotInput 已读好的快照输入
1142
+ * @param deps 可注入依赖(测试用)
1143
+ * @returns 四态结果
1144
+ */
1145
+ async function evaluateCro(root, dirName, snapshotInput, deps = {}) {
1146
+ let scriptAbs;
1147
+ try {
1148
+ scriptAbs = croScriptPath(root, dirName);
1149
+ } catch (err) {
1150
+ return {
1151
+ status: "disabled",
1152
+ detail: `CRO 路径非法(须在 CCC 根内):${String(err?.message ?? err)}`
1153
+ };
1154
+ }
1155
+ if (!existsSync(scriptAbs)) return {
1156
+ status: "disabled",
1157
+ detail: `未启用(无 ${CRO_FILENAME})`
1158
+ };
1159
+ const snapshot = buildCroSnapshot(snapshotInput);
1160
+ let stdinJson;
1161
+ try {
1162
+ stdinJson = JSON.stringify(snapshot);
1163
+ } catch (err) {
1164
+ return {
1165
+ status: "skipped",
1166
+ detail: `快照序列化失败:${String(err?.message ?? err)}`,
1167
+ code: "cro-snapshot"
1168
+ };
1169
+ }
1170
+ const run = await (deps.runner ?? runCroProcess)(scriptAbs, stdinJson, deps.timeoutMs ?? 6e4);
1171
+ if (!run.ok) return {
1172
+ status: "skipped",
1173
+ detail: run.error ?? "CRO 执行失败",
1174
+ ...run.code ? { code: run.code } : {}
1175
+ };
1176
+ const parsed = parseCroOutput(run.stdout);
1177
+ if (!parsed.ok) return {
1178
+ status: "skipped",
1179
+ detail: parsed.detail ?? parsed.error,
1180
+ code: parsed.error
1181
+ };
1182
+ const decision = parsed.decision;
1183
+ if (!decision.wake) return {
1184
+ status: "no-wake",
1185
+ decision,
1186
+ detail: decision.reason ?? "程序判定:不唤起"
1187
+ };
1188
+ return {
1189
+ status: "wake",
1190
+ decision,
1191
+ prompt: decision.prompt,
1192
+ detail: decision.reason ?? "程序判定:唤起"
1193
+ };
1194
+ }
1195
+ /** 人读一行(日志用;`evaluateCro` 结果的稳定摘要) */
1196
+ function renderCroOutcome(dirName, outcome) {
1197
+ switch (outcome.status) {
1198
+ case "disabled": return `${dirName}: CRO ${outcome.detail}`;
1199
+ case "skipped": return `${dirName}: CRO 跳过本轮(${outcome.detail})`;
1200
+ case "no-wake": return `${dirName}: CRO 不唤起(${outcome.detail})`;
1201
+ case "wake": return `${dirName}: CRO 唤起(${outcome.detail})`;
1202
+ }
1203
+ }
1204
+ //#endregion
1205
+ //#region src/host/effect.ts
1206
+ /** 已告警的标签(进程级去重:重复装配/多测试替身不刷屏,但绝不静默) */
1207
+ const warnedLabels = /* @__PURE__ */ new Set();
1208
+ /**
1209
+ * 登记一个卸载时执行的清理。
1210
+ * @param ctx 宿主插件上下文
1211
+ * @param label 日志标签(定位是哪个资源未装配)
1212
+ * @param cleanup 卸载时执行;抛错被吞掉(不阻断其它清理)
1213
+ * @returns 是否成功装配(false = 宿主无 `ctx.effect`,已告警)
1214
+ */
1215
+ function registerDisposer(ctx, label, cleanup) {
1216
+ const effect = ctx?.effect;
1217
+ if (typeof effect !== "function") {
1218
+ if (!warnedLabels.has(label)) {
1219
+ warnedLabels.add(label);
1220
+ console.warn(`[serenity-hooks] ✗ 资源拆卸未装配(${label}):ctx.effect 不可用(宿主契约缺失)`);
1221
+ }
1222
+ return false;
1223
+ }
1224
+ try {
1225
+ effect.call(ctx, () => () => {
1226
+ try {
1227
+ cleanup();
1228
+ } catch (err) {
1229
+ console.warn(`[serenity-hooks] ✗ 资源清理失败(${label}): ${String(err?.message ?? err)}`);
1230
+ }
1231
+ }, `dsh-serenity-hooks: ${label}`);
1232
+ return true;
1233
+ } catch (err) {
1234
+ console.warn(`[serenity-hooks] ✗ 资源拆卸注册失败(${label}): ${String(err?.message ?? err)}`);
1235
+ return false;
1236
+ }
1237
+ }
1238
+ //#endregion
1239
+ //#region src/cro/turns.ts
1240
+ /**
1241
+ * cro/turns.ts — 「这条轨迹是否正在跑轮次」的追踪(设计 §3.2,2026-09-19 S142)
1242
+ *
1243
+ * ## 为什么需要这个模块(CRO 唯一需要 ACC 新增的状态)
1244
+ *
1245
+ * CRO 程序要能回答「**已经在工作则停止**」这类问题 ⇒ 输入快照里必须有
1246
+ * 「**哪些载体正在跑轮次**」。而这件事**今天判不出来**:
1247
+ * 调度器只掌握**会话 live 与否**,而 **`live ≠ 在跑`**——
1248
+ * 一条会话可以 live 而空闲(本会话此刻就是活的反例)。
1249
+ *
1250
+ * ## 怎么夹出来(用**既有的**契约事件,不需要新宿主能力)
1251
+ *
1252
+ * | 事件 | 动作 | 出处 |
1253
+ * |---|---|---|
1254
+ * | `agent/status` = `running` | **置位**「在跑」 | 宿主 `AgentLoop.setPhase`:`emit("agent/status", { status })`,**只在状态真的变化时发** |
1255
+ * | `agent/status` = `idle` | **清位** | 同上;`kick()` 的 `finally` 一定 `setPhase({kind:'idle'})` |
1256
+ * | `agent/disposed` | **清该载体的项** | `host/contract.ts` |
1257
+ * | `session/disposed` | **清该载体的项** | `host/contract.ts` |
1258
+ *
1259
+ * 🔴 **`agent/turn-stopping` 刻意不订阅** —— 它是「轮末暂停点」不是「轮结束」,见下方 D244 段。
1260
+ *
1261
+ * ## 🔴🔴 D243(2026-10-24):**⛔ 不许用 `agent/created` 标记「在跑」**
1262
+ *
1263
+ * **修前**:`agent/created` 也 `markTurnRunning(...)`。**为什么这是错的** —— 该事件是
1264
+ * `AgentRegistry.announce(agent, source)` 发的,而 `source` 明确包含 **`resume`**:
1265
+ * 宿主**每次重启**都会把 persisted 会话重新载入并 announce ⇒ 每个被**载入**的会话都被打成"在跑",
1266
+ * 而**它那一轮早随旧进程死掉了** ⇒ `turn-stopping` 永不到达 ⇒ 该假标记活满 `TURN_TTL_MS`(30 min)。
1267
+ *
1268
+ * **代价(两个面,都已实测)**:
1269
+ * - **面一**:CRO 守卫拿它否决唤起 ⇒ **重启后聋 30 分钟**(guard 已在自己域绕开)。
1270
+ * - **面二**:`cognitive_trajectory interrupted` 拿它当排除集 ⇒ **把最该被接走的会话藏起来**
1271
+ * (实测:判据命中 5 条,生产入口报「检查 5 · 命中 0」)。
1272
+ *
1273
+ * **判据**:`agent/created` 证明的是「**载体被发布了**」,**不是**「有轮次在跑」。
1274
+ * 「载入」与「在跑」互不蕴含(与 D225 同族:*字段名/字面形态不是语义*)。
1275
+ * ⇒ **唯一权威 = `agent/status`**(宿主 `status` getter:`phase.kind === 'idle' | 'maintenance'`
1276
+ * 才是 idle,其余皆 running),且它在**每次真实状态跃迁**时发 —— 正是我们要的粒度。
1277
+ *
1278
+ * **代价与兜底**:删掉 `agent/created` 后,本表在"宿主重启后、某会话真正开跑之前"为空
1279
+ * ⇒ 落到文件尾那条**便宜**的误判方向(报"没跑"),不会被拿来藏会话。
1280
+ *
1281
+ * ## 🔴🔴 D244(2026-10-24):**⛔ `agent/turn-stopping` 不得清表**
1282
+ *
1283
+ * **它看起来最像"收轮",其实不是。** 宿主 `AgentLoop.turn()` 原文:
1284
+ *
1285
+ * ```js
1286
+ * if (turnEnds && this.inbox.nextStep.length === 0) {
1287
+ * await this.dispatch.serial("agent/turn-stopping", { turn, signal }) // 暂停点
1288
+ * signal.throwIfAborted()
1289
+ * }
1290
+ * if (turnEnds && this.inbox.nextStep.length === 0) break // 只有 nextStep 空才真结束
1291
+ * target = "next-step" // 否则同轮继续
1292
+ * ```
1293
+ *
1294
+ * ⇒ 任何监听者在 `turn-stopping` 回调里 `steer`(往 `next-step` 塞消息)⇒ **同一轮继续跑**,
1295
+ * 而 `turn/start` **不重发**、`agent/status` 也**不重发**(状态没变,还是 running)。
1296
+ *
1297
+ * 🔴 **实测代价**(真机 2026-10-24 18:1x):`trajectory/rebuild.ts` 的 auto-continue 正是
1298
+ * 在 `turn-stopping` 里 `agent.steer(...)` ⇒ 一条轮**连跑 9 分钟**
1299
+ * (`turn/start(32)` 18:12:00 → `turn/end(32)` 18:21:27),而本表**整段时间都是空的**
1300
+ * ⇒ `interrupted` 的排除失效 ⇒ `steer` 被塞进**正在跑的那一轮**(对它是误导)。
1301
+ * ⚠️ D243 之前这一格被 `agent/created` 的假置位**掩盖**;D243 删掉它 ⇒ 洞才暴露。
1302
+ * ⇒ **D243 的方向是对的,它只是揭开了一个更老的语义误解**。
1303
+ *
1304
+ * ⇒ **清理只认 `agent/status = idle`**(`kick()` 的 `finally` 一定 `setPhase({kind:'idle'})`)。
1305
+ * ⚠️ **已知窄代价**:`runMaintenance` 期间 `status` 也是 `idle` ⇒ 压缩等内部工作时不置位。
1306
+ * 判可接受:那时没有 CRO 该救的轮;方向落到那条**便宜**的误判(报"没跑")。
1307
+ * ⛔ **不要把 `clearTurnRunning` 加回 `turn-stopping`** —— 等于重新打开那个 9 分钟的洞。
1308
+ *
1309
+ * ## 🔴 清理设计(**这是本模块最容易被做坏的地方**)
1310
+ *
1311
+ * ⚠️ 本容器栽过 **5 次**「只增不清」的病(wake-registry / keeper 三表 / pendingRebuilds …),
1312
+ * 而 `agent/disposed` 在 `HOST_EVENTS` 里的 impact 原文**恰好就是**
1313
+ * 「*per-会话内存态不清理(**长跑泄漏**)*」⇒ **本模块属于该已登记病族,必须在设计期就带清理**。
1314
+ *
1315
+ * **三层清理**(D244 起第 1 层已从 `turn-stopping` 换成 `status=idle`):
1316
+ * 1. **正常路径**:`agent/status = idle` ⇒ 清除该项;
1317
+ * 2. **载体销毁**:`agent/disposed` / `session/disposed` ⇒ 清除该项;
1318
+ * 3. 🔴 **异常路径(前两层都漏掉的兜底)**:读取时按 **TTL** 过滤——
1319
+ * 超过 `TURN_TTL_MS` 未刷新的项**视为不在跑**(并顺手清除)。
1320
+ * 触发场景:进程被 kill / 卡死 / 事件丢失(这些情况下 `idle` 永远不会来)。
1321
+ *
1322
+ * ## TTL 取值的**方向性判据**(R↓,别用"感觉合适")
1323
+ *
1324
+ * 两种误判的代价**不对称**:
1325
+ * · **误报"在跑"**(实际没跑)⇒ CRO 可能**不唤起** ⇒ **漏排 = 硬故障**(链停滞);
1326
+ * · **误报"没跑"**(实际在跑)⇒ CRO 可能**多唤起**一次 ⇒ **多排 = 常态成本**(既有纪律:
1327
+ * 「漏排是硬故障、多排是常态成本」)。
1328
+ *
1329
+ * ⇒ **不确定时宁可报"没跑"**(选便宜的那种错)⇒ **TTL 不宜过长**。
1330
+ * 取 **30 分钟**:单轮静默超过 30 分钟仍无 `idle` 的情形罕见;
1331
+ * 而真发生了,代价只是一次可能多余的唤起(5min tick 限制频率,且 CRO 程序自己还能看
1332
+ * `pendingWakes` / `lastWakeAt` 二次判)。
1333
+ *
1334
+ * ## 键的选择
1335
+ *
1336
+ * **以载体(dsh 会话 id)为键**——因为事件本身就以 agent/session 为主语,直接映射最省。
1337
+ * **查询时按轨迹聚合**(`listBoundSessionIds` → 「任一载体在跑」⇒ 该轨迹在跑)。
1338
+ * ⚠️ 这也天然处理了「同一轨迹多载体」(S142 曾挂 4 条)的情形。
1339
+ */
1340
+ /** 一次「在跑」记录的 TTL(见文件头的方向性判据) */
1341
+ const TURN_TTL_MS = 18e5;
1342
+ /**
1343
+ * 进程内的「正在跑」表(键 = 载体 dsh 会话 id)。
1344
+ *
1345
+ * ⚠️ **仅内存**:进程重启即空 —— 这是**可接受的,且是刻意选的**:
1346
+ * 空表 = "没有在跑" ⇒ 落到上面那条**便宜**的误判方向(漏排是硬故障、多排是常态成本)。
1347
+ * 🔴 **D243**:刻意**不**在重启后用 `agent/created` 去"补"这张表 —— 那会把「载入」当「在跑」。
1348
+ */
1349
+ const runningBySession = /* @__PURE__ */ new Map();
1350
+ /** 取载体 id(事件 payload 的 `agent` 面;取不到 ⇒ null) */
1351
+ function sessionIdOf(agent) {
1352
+ const id = agent?.session?.id;
1353
+ return typeof id === "string" && id !== "" ? id : null;
1354
+ }
1355
+ /** 标记「在跑」(**唯一**生产调用点 = `agent/status=running`;另供测试与 CRO 自检置位) */
1356
+ function markTurnRunning(sessionId, nowMs, turn = null) {
1357
+ if (!sessionId) return;
1358
+ runningBySession.set(sessionId, {
1359
+ at: nowMs,
1360
+ turn
1361
+ });
1362
+ }
1363
+ /** 清除「在跑」(`turn-stopping` / disposed 调用) */
1364
+ function clearTurnRunning(sessionId) {
1365
+ if (!sessionId) return;
1366
+ runningBySession.delete(sessionId);
1367
+ }
1368
+ /**
1369
+ * 列出**当前在跑**的载体 id(按 TTL 过滤 + **顺手清除过期项**)。
1370
+ *
1371
+ * 🔴 **本函数带副作用(清理)是刻意的**:它是"异常路径兜底"的唯一执行点——
1372
+ * 只过滤不清除会让表留下永不过期的僵尸项(正是"只增不清"病)。
1373
+ * @param nowMs 当前时刻
1374
+ * @param ttlMs TTL(缺省 {@link TURN_TTL_MS})
1375
+ * @returns 在跑的载体 id 列表(顺序不保证)
1376
+ */
1377
+ function listRunningSessionIds(nowMs, ttlMs = TURN_TTL_MS) {
1378
+ const out = [];
1379
+ for (const [id, rec] of runningBySession) {
1380
+ if (nowMs - rec.at > ttlMs) {
1381
+ runningBySession.delete(id);
1382
+ continue;
1383
+ }
1384
+ out.push(id);
1385
+ }
1386
+ return out;
1387
+ }
1388
+ /**
1389
+ * 按轨迹聚合:该轨迹**是否有载体在跑**(`listBoundSessionIds` 给候选,再与在跑集求交)。
1390
+ * @param boundSessionIds 绑定该轨迹的载体 id
1391
+ * @param nowMs 当前时刻
1392
+ * @param ttlMs TTL
1393
+ * @returns 在跑的载体 id(**可能多条**;空数组 = 没有在跑)
1394
+ */
1395
+ function runningCarriersOf(boundSessionIds, nowMs, ttlMs = TURN_TTL_MS) {
1396
+ const running = new Set(listRunningSessionIds(nowMs, ttlMs));
1397
+ return boundSessionIds.filter((id) => running.has(id));
1398
+ }
1399
+ /**
1400
+ * 装配追踪(`index.ts` apply 调用):订阅四个既有事件 + 拆卸。
1401
+ *
1402
+ * **为什么订阅四个而不是两个**(R↓):
1403
+ * · 前两个(`agent/status` / `turn-stopping`)负责**语义**——"在不在跑";
1404
+ * · 后两个(`agent/disposed` / `session/disposed`)负责**生命周期**——
1405
+ * 载体没了必须清项,否则表按载体只增(本模块的头号风险)。
1406
+ *
1407
+ * 🔴 **D243:`agent/created` 已于 2026-10-24 从订阅清单中移除**(原先共 5 个)。
1408
+ * 理由与代价见**文件头 D243 段**;一句话:它是「载体被发布」的信,不是「轮次在跑」的信。
1409
+ *
1410
+ * ⚠️ **失败不抛**:任一事件通道缺失 ⇒ 响亮日志 + 该订阅跳过(**apply 不可成为启动单点**,
1411
+ * 与 `registerDeepseekVisionPatch` 等既有装配同款纪律)。
1412
+ *
1413
+ * ── 🔴 2026-09-25 I2 收敛(约束文档 §2.7 违反清单,L1 违反之一)────────────────
1414
+ * **两个宿主 import 全部清除**(`Context`(cordis)+ `Agent`(`@deepseek-ai/dsh-agent`)),
1415
+ * `ctx` 参数类型 → `unknown`。
1416
+ *
1417
+ * 🔴 **本处顺带更正 §2.7 的一处分档**:本文件原被列在"剩余 3 个属**真依赖**档"里,
1418
+ * 但**逐符号查证后与那个判断不符** ——
1419
+ * · `Context`:`ctx` 只被**结构化访问**(原码本就写着 `ctx as unknown as { on: … }`)
1420
+ * ⇒ 该注解未提供任何类型保证(与 `trajectory/carrier.ts` 同档)。
1421
+ * · `Agent`:**导入后从未使用** —— 事件 payload 一律按 `{ agent?: unknown }` 解构,
1422
+ * 再交给 `sessionIdOf(...)`(它收 `unknown`)。⇒ **纯粹是死 import**。
1423
+ * ⇒ **判据(本件再证一次,且比上一件更硬)**:分档**必须逐符号看"它有没有参与类型检查"**,
1424
+ * **不能按文件整档划分** —— 同一个文件里可以并存"真依赖符号"与"纯名义符号"。
1425
+ * 🔵 **剩余 2 个文件**才是真依赖档:`trajectory/rebuild.ts` 用 `Session`/`SessionEvent`/
1426
+ * `deriveEventMessage`/`Agent` 等**真调用**的类型;`wake/scheduler.ts` 用 `Agent` 的
1427
+ * `followup`/`steer`(**真调用**)⇒ 它们走 §2.7-③ **选项 (b)** 或把中立接口下沉到 L0,
1428
+ * **另立一件**(D85 一次只改一件事)。
1429
+ * @param ctx 插件上下文(**结构化访问** `on`,故类型为 `unknown`,见下方 I2 说明)
1430
+ */
1431
+ function registerCroTurnTracking(ctx) {
1432
+ const on = (event, handler) => {
1433
+ try {
1434
+ ctx.on(event, handler);
1435
+ } catch (err) {
1436
+ console.log(`[serenity-hooks] ✗ CRO turn 追踪未装配(${event} 不可用): ${String(err?.message ?? err)}`);
1437
+ }
1438
+ };
1439
+ on("agent/status", (payload) => {
1440
+ const p = payload;
1441
+ const id = sessionIdOf(p?.agent);
1442
+ if (!id) return;
1443
+ if (p.status === "running") markTurnRunning(id, Date.now());
1444
+ else if (p.status === "idle") clearTurnRunning(id);
1445
+ });
1446
+ on("agent/disposed", (payload) => {
1447
+ const id = sessionIdOf(payload?.agent);
1448
+ if (id) clearTurnRunning(id);
1449
+ });
1450
+ on("session/disposed", (payload) => {
1451
+ const id = payload?.id;
1452
+ if (typeof id === "string" && id !== "") clearTurnRunning(id);
1453
+ });
1454
+ registerDisposer(ctx, "cro turn tracking", () => {
1455
+ runningBySession.clear();
1456
+ });
1457
+ }
1458
+ //#endregion
1459
+ //#region src/cro/log.ts
1460
+ /**
1461
+ * cro/log.ts — CRO 唤起日志(**ACC 侧**,每轨迹一份固定名文件,2026-09-20 S142 §7.16)
1462
+ *
1463
+ * ## 所有者令(本模块的存在理由)
1464
+ *
1465
+ * 「S151 和 S185 的验证都说明,认知轨迹倾向于自己做个结构化记录来记录自身 CRO 的执行情况,
1466
+ * 这是个结构化流水账;**我们 ACC 有必要进行 CRO 唤醒的日志记录**,当 CRO 存在时,每次**成功唤醒**
1467
+ * 写相关信息,但是保留数量要少,**只保留仅两日**的记录即可,**这个时间是程序化的**」
1468
+ * +「**这个日志写结构化文件到认知轨迹目录,文件名固定即可**」
1469
+ * +「**失败成功都记录**」。
1470
+ *
1471
+ * ## 它解决什么麻烦(白话)
1472
+ *
1473
+ * 在此之前「ACC 到底叫过谁、叫了几次、哪次**没叫成**」**ACC 自己一个字都不留** ——
1474
+ * 要查只能去读**被叫的那个程序**写的记录;程序没写 / 写坏 / 被换掉 ⇒ **这段历史就是空白**。
1475
+ * ⇒ 本模块让 ACC **自己那一侧**留一份只记两天的流水,使「唤起历史」**不再依赖被观测者是否老实**。
1476
+ *
1477
+ * ## 🔴 为什么**不是**第二真相源(本容器铁律,勿删此论证)
1478
+ *
1479
+ * 轨迹目录里因此**并排两个文件、主语各一**:
1480
+ *
1481
+ * | 文件 | 谁写 | 主语 | 回答 |
1482
+ * |---|---|---|---|
1483
+ * | `<轨迹目录>/cro-state.json`(CCC 侧程序自建,名字自定) | **CCC 的 CRO 程序** | 程序 | 「**我判了什么**」(含"没叫"的判定、心跳阈值、环境谓词) |
1484
+ * | `<轨迹目录>/cro-wake-log.log`(**本模块**) | **ACC** | ACC | 「**我把什么送出去了、送成没有**」 |
1485
+ *
1486
+ * **内容不重叠**:本模块**不复制**判定细节(不存 `lastDecision` / 阈值 / 谓词);
1487
+ * 而程序**不可能**知道投递结果(CRO 投递是 fire-and-forget)。
1488
+ * ⇒ 两者**互补**:完整审计 = 两者合看(本模块 = 投递面|程序状态 = 判定面)。
1489
+ *
1490
+ * ## 🔴 形态 = 纯文本行,不是 JSON(owner 2026-10-19 裁 Q1)
1491
+ *
1492
+ * owner 原话:「**日志不要写 json,不合适,日志直接写纯文本 `.log` 就行**」。
1493
+ * ⇒ 一行一条,**tab 分隔**(终端里按制表位对齐,`grep` / `cut` / `awk` 直接可用):
1494
+ *
1495
+ * ```text
1496
+ * # ACC CRO 唤起流水(纯文本 · tab 分隔 · 时间 / 结果 / tick / 方式 / 理由 / 提示词摘要)
1497
+ * 2026-10-19T22:15:03+08:00 ok tick=82 已投递(live) reason=心跳:距上次 44m prompt=17:请先整理你的 SESSION.md
1498
+ * ```
1499
+ *
1500
+ * 🔴 **`#` 开头是注释 / 表头行,解析时跳过** —— 纯文本档案缺了表头,下一个人(或半年后的我)
1501
+ * 打开只看到一堆制表符,那是把 R 推给读者。
1502
+ * 🔴 **坏行跳过而非报错**(与 JSON 版的 `asEntry()` 同口径):手改 / 半行 / 截断都不得让整档失效。
1503
+ *
1504
+ * ## 🔴 两代并读(**不硬切**,本容器反复栽过的形态)
1505
+ *
1506
+ * 旧名 `cro-wake-log.json` **仍然读**(存量轨迹里可能正有那份);
1507
+ * **只写新 `.log`**。读侧判据 = **「新档在就只读新档,否则回落旧档」**。
1508
+ *
1509
+ * 🔴 **首次写入 = 迁移,不是覆盖**:那一刻 `loadCroWakeLog` 读到的仍是旧档条目,
1510
+ * 它们与新条目一起过裁剪、一起落进新档 ⇒ **窗内的旧记录不会凭空消失**;
1511
+ * 而旧档此后不再被读(新档已在)⇒ **不会重复计入**,也不需要删除动作。
1512
+ * ⚠️ 这条**只能靠读源码知道**(写出来的档看不出它继承过谁)⇒ 故在此明写。
1513
+ *
1514
+ * ## 🔴 轮转(所有者明示「程序化的」)
1515
+ *
1516
+ * **写入时按窗裁剪**:每次落盘只保留 `at >= now - CRO_LOG_RETENTION_MS` 的条目,
1517
+ * 随后**原子写**(`tmp + rename`,与 `wake/registry.ts` 同款形态)。
1518
+ * ⇒ 清理与写入**是同一笔操作** ⇒ **结构上不存在"忘了清"的可能**(比"另设一个每 tick 的清理步"更硬:
1519
+ * 那多一个"可能忘了跑"的面)。窗宽是**常量**,**无配置键、无人工清理动作**。
1520
+ *
1521
+ * ## 🔴 铁律(与 `cro/core.ts` 同族)
1522
+ *
1523
+ * **本模块的每个导出函数都不抛错**:写盘 / 裁剪 / 解析失败一律返回结构化失败,
1524
+ * 由调用方只记一行 tick 日志 ⇒ **CRO 流水的问题绝不影响投递与既有链路**(设计 §5)。
1525
+ *
1526
+ * ## 保留量级(实测,非估计)
1527
+ *
1528
+ * 实测最高 = S185 的 40min 心跳(≈36 条/天);两天窗 ⇒ **≤ ~80 条、< 20 KB**。
1529
+ * 最坏(某程序每 5min 都叫)⇒ 288 条/天 ⇒ 两天 **≤ ~600 条、< 100 KB** —— 轮转**自带上限**。
1530
+ */
1531
+ /** 固定文件名(所有者令:「文件名固定即可」)——与程序自建的 `cro-state.json` 并排、主语分明 */
1532
+ const CRO_LOG_FILENAME = "cro-wake-log.log";
1533
+ /**
1534
+ * 🔴 **读取族 = 新名在前、旧名在后**(认族,不认"当前叫什么")。
1535
+ *
1536
+ * 本容器已两次栽在"上游改名、下游写死旧名":`skiff.json` → `trajectory-constraint.json` 那次,
1537
+ * 症状是「写不进去」,而真因是「文件名对不上」,两者在读数上完全不像。
1538
+ * ⇒ 上游再改名只往族里加一项。
1539
+ */
1540
+ const CRO_LOG_LEGACY_FILENAMES = ["cro-wake-log.json"];
1541
+ /** 保留窗 = 2 日(所有者令「只保留仅两日」);**常量** ⇒ 「这个时间是程序化的」 */
1542
+ const CRO_LOG_RETENTION_MS = 1728e5;
1543
+ /** 纯文本档案的表头行(解析时跳过;人打开时先看到它) */
1544
+ const CRO_LOG_HEADER = "# ACC CRO 唤起流水(纯文本 · tab 分隔 · 时间 / 结果 / tick / 方式 / 理由 / 提示词摘要)";
1545
+ /** 日志绝对路径(**写入目标** = 新名) */
1546
+ function croWakeLogPath(root, dirName) {
1547
+ return join(sessionsRoot(root), dirName, CRO_LOG_FILENAME);
1548
+ }
1549
+ /** 旧档绝对路径(**只读**,用于两代并读) */
1550
+ function croWakeLogLegacyPath(root, dirName, filename) {
1551
+ return join(sessionsRoot(root), dirName, filename);
1552
+ }
1553
+ function emptyLog() {
1554
+ return {
1555
+ version: 1,
1556
+ entries: []
1557
+ };
1558
+ }
1559
+ /**
1560
+ * 抹掉会破坏**行结构**的字符(tab / CR / LF)。
1561
+ *
1562
+ * 🔴 只抹这三个:它们是**分隔符**,混进来会让一行变成两行或错位;
1563
+ * 其余字符(`|`、`]`、中文标点)**原样保留** —— 那些是内容,抹了就是把 E 降了。
1564
+ */
1565
+ function oneline(text) {
1566
+ return text.replace(/[\t\r\n]+/g, " ");
1567
+ }
1568
+ /** 一条记录 → 纯文本行(tab 分隔、字段带前缀便于人读) */
1569
+ function formatCroLogLine(entry) {
1570
+ const reason = entry.reason === null ? "" : oneline(entry.reason);
1571
+ const head = oneline(entry.promptDigest.head);
1572
+ return [
1573
+ oneline(entry.at),
1574
+ entry.ok ? "ok" : entry.notReady === true ? "not-ready" : entry.noWake === true ? "no-wake" : "fail",
1575
+ `tick=${entry.tick}`,
1576
+ oneline(entry.detail),
1577
+ `reason=${reason}`,
1578
+ `prompt=${entry.promptDigest.length}:${head}`
1579
+ ].join(" ");
1580
+ }
1581
+ /**
1582
+ * 纯文本行 → 记录(**坏行返回 null 而不是抛**)。
1583
+ *
1584
+ * 判据:字段数不足 / 时间不可解析 / `ok` 标记不认识 ⇒ `null`(跳过该行,其余照读)。
1585
+ * 🔴 `at` 必须**可被 `Date.parse` 解析**才收 —— 那是裁剪的前提,
1586
+ * 收一条解析不了的时间等于让它在此档里永久驻留(轮转对它失效)。
1587
+ */
1588
+ function parseCroLogLine(line) {
1589
+ if (line === "" || line.startsWith("#")) return null;
1590
+ const parts = line.split(" ");
1591
+ if (parts.length < 6) return null;
1592
+ const at = parts[0];
1593
+ if (Number.isNaN(Date.parse(at))) return null;
1594
+ const mark = parts[1];
1595
+ if (mark !== "ok" && mark !== "fail" && mark !== "not-ready" && mark !== "no-wake") return null;
1596
+ const tickM = /^tick=(\d+)$/.exec(parts[2]);
1597
+ if (!tickM) return null;
1598
+ const reasonRaw = parts[4].startsWith("reason=") ? parts[4].slice(7) : "";
1599
+ const promptM = /^prompt=(\d+):([\s\S]*)$/.exec(parts[5]);
1600
+ return {
1601
+ at,
1602
+ ok: mark === "ok",
1603
+ ...mark === "not-ready" ? { notReady: true } : {},
1604
+ ...mark === "no-wake" ? { noWake: true } : {},
1605
+ detail: parts[3],
1606
+ tick: Number(tickM[1]),
1607
+ reason: reasonRaw === "" ? null : reasonRaw,
1608
+ promptDigest: {
1609
+ length: promptM ? Number(promptM[1]) : 0,
1610
+ head: promptM ? promptM[2] : ""
1611
+ }
1612
+ };
1613
+ }
1614
+ /** 全文 → 记录(跳过表头与坏行;**永不抛**) */
1615
+ function parseCroLogText(text) {
1616
+ const entries = [];
1617
+ for (const line of text.split("\n")) {
1618
+ const e = parseCroLogLine(line);
1619
+ if (e) entries.push(e);
1620
+ }
1621
+ return entries;
1622
+ }
1623
+ /** JSON 旧档 → 记录(字段缺失/类型不符 ⇒ 丢弃该条,不阻断整档) */
1624
+ function parseCroLogJson(text) {
1625
+ const parsed = JSON.parse(text);
1626
+ const raw = Array.isArray(parsed.entries) ? parsed.entries : [];
1627
+ const entries = [];
1628
+ for (const item of raw) {
1629
+ const e = asEntry(item);
1630
+ if (e) entries.push(e);
1631
+ }
1632
+ return entries;
1633
+ }
1634
+ /** 旧档条目形状校验(容忍手改) */
1635
+ function asEntry(value) {
1636
+ const e = value;
1637
+ if (!e || typeof e !== "object") return null;
1638
+ if (typeof e.at !== "string" || e.at === "") return null;
1639
+ if (typeof e.ok !== "boolean") return null;
1640
+ if (typeof e.detail !== "string") return null;
1641
+ const digest = e.promptDigest ?? {};
1642
+ return {
1643
+ at: e.at,
1644
+ ok: e.ok,
1645
+ detail: e.detail,
1646
+ tick: typeof e.tick === "number" ? e.tick : 0,
1647
+ reason: typeof e.reason === "string" ? e.reason : null,
1648
+ promptDigest: {
1649
+ length: typeof digest.length === "number" ? digest.length : 0,
1650
+ head: typeof digest.head === "string" ? digest.head : ""
1651
+ }
1652
+ };
1653
+ }
1654
+ /** 提示词摘要(长度 + 前 N 字;超长不截断标记——长度字段本身就是信号) */
1655
+ function summarizeCroPrompt(prompt) {
1656
+ const text = typeof prompt === "string" ? prompt : "";
1657
+ return {
1658
+ length: [...text].length,
1659
+ head: [...text].slice(0, 80).join("")
1660
+ };
1661
+ }
1662
+ /**
1663
+ * 读取日志(**永不抛**;读坏 ⇒ 当作空档 + 返回 error 文本)。
1664
+ *
1665
+ * 🔴 **两代并读的判据**:新档在 ⇒ **只读新档**;否则按族回落旧档。
1666
+ * 这样首次写入之后旧档自然退场,**不需要删除动作、也不会重复计入**。
1667
+ *
1668
+ * @param root CCC 根
1669
+ * @param dirName 轨迹目录名
1670
+ */
1671
+ function loadCroWakeLog(root, dirName) {
1672
+ const path = croWakeLogPath(root, dirName);
1673
+ if (existsSync(path)) try {
1674
+ return {
1675
+ log: {
1676
+ version: 1,
1677
+ entries: parseCroLogText(readFileSync(path, "utf-8"))
1678
+ },
1679
+ error: null
1680
+ };
1681
+ } catch (err) {
1682
+ return {
1683
+ log: emptyLog(),
1684
+ error: `CRO 流水解析失败(${String(err?.message ?? err)})`
1685
+ };
1686
+ }
1687
+ for (const legacy of CRO_LOG_LEGACY_FILENAMES) {
1688
+ const legacyPath = croWakeLogLegacyPath(root, dirName, legacy);
1689
+ if (!existsSync(legacyPath)) continue;
1690
+ try {
1691
+ return {
1692
+ log: {
1693
+ version: 1,
1694
+ entries: parseCroLogJson(readFileSync(legacyPath, "utf-8"))
1695
+ },
1696
+ error: null
1697
+ };
1698
+ } catch (err) {
1699
+ return {
1700
+ log: emptyLog(),
1701
+ error: `CRO 流水解析失败(${String(err?.message ?? err)})`
1702
+ };
1703
+ }
1704
+ }
1705
+ return {
1706
+ log: emptyLog(),
1707
+ error: null
1708
+ };
1709
+ }
1710
+ /**
1711
+ * 按窗裁剪(**纯函数** ⇒ 可穷举测试)。
1712
+ *
1713
+ * 判据:`Date.parse(at) >= nowMs - windowMs` 保留;反之丢弃。
1714
+ * 🔴 **`at` 不可解析者一律丢弃**(本模块只写 `isoLocal()` 产物 ⇒ 不可解析 = 被手改或损坏),
1715
+ * **并在返回值里报数**(`dropped`)——**不静默吞掉**(本容器反复栽的"安静失败"纪律)。
1716
+ */
1717
+ function pruneCroLogEntries(entries, nowMs, windowMs = CRO_LOG_RETENTION_MS) {
1718
+ const floor = nowMs - windowMs;
1719
+ const kept = [];
1720
+ let pruned = 0;
1721
+ let dropped = 0;
1722
+ for (const e of entries) {
1723
+ const t = Date.parse(e.at);
1724
+ if (Number.isNaN(t)) {
1725
+ dropped += 1;
1726
+ continue;
1727
+ }
1728
+ if (t >= floor) kept.push(e);
1729
+ else pruned += 1;
1730
+ }
1731
+ return {
1732
+ kept,
1733
+ pruned,
1734
+ dropped
1735
+ };
1736
+ }
1737
+ /** 记录集 → 落盘全文(表头 + 逐行 + 末尾换行) */
1738
+ function renderCroLogText(entries) {
1739
+ return `${[CRO_LOG_HEADER, ...entries.map(formatCroLogLine)].join("\n")}\n`;
1740
+ }
1741
+ /**
1742
+ * 追加一条唤起记录并按窗裁剪(**本模块主入口;永不抛**)。
1743
+ *
1744
+ * 顺序 = 读 → 追加 → 裁剪 → **原子写**。任一步失败 ⇒ `{ok:false, error}`,
1745
+ * **不抛**(调用方只记一行 tick 日志;**绝不影响投递**)。
1746
+ *
1747
+ * 无 CRO 的轨迹**不会**因本函数产生文件:只有真的发生过一次唤起尝试才会建/写该档
1748
+ * (所有者令「**当 CRO 存在时**」)。
1749
+ *
1750
+ * @param root CCC 根
1751
+ * @param dirName 轨迹目录名
1752
+ * @param input 本次唤起尝试(成败都记)
1753
+ * @param nowMs 当前毫秒(可注入 ⇒ 便于测裁剪边界)
1754
+ * @returns 写入结果与裁剪计数
1755
+ */
1756
+ function appendCroWakeLog(root, dirName, input, nowMs = Date.now()) {
1757
+ const path = croWakeLogPath(root, dirName);
1758
+ try {
1759
+ const loaded = loadCroWakeLog(root, dirName);
1760
+ const entry = {
1761
+ at: isoLocal(nowMs),
1762
+ ok: input.ok,
1763
+ ...input.notReady === true ? { notReady: true } : {},
1764
+ ...input.noWake === true ? { noWake: true } : {},
1765
+ detail: input.detail,
1766
+ tick: input.tick,
1767
+ reason: input.reason,
1768
+ promptDigest: summarizeCroPrompt(input.prompt)
1769
+ };
1770
+ const { kept, pruned, dropped } = pruneCroLogEntries([...loaded.log.entries, entry], nowMs);
1771
+ mkdirSync(dirname(path), { recursive: true });
1772
+ const tmp = `${path}.tmp`;
1773
+ writeFileSync(tmp, renderCroLogText(kept), "utf-8");
1774
+ renameSync(tmp, path);
1775
+ return {
1776
+ ok: true,
1777
+ error: null,
1778
+ kept: kept.length,
1779
+ pruned,
1780
+ dropped
1781
+ };
1782
+ } catch (err) {
1783
+ return {
1784
+ ok: false,
1785
+ error: `CRO 流水写入失败(${String(err?.message ?? err)})`,
1786
+ kept: 0,
1787
+ pruned: 0,
1788
+ dropped: 0
1789
+ };
1790
+ }
1791
+ }
1792
+ //#endregion
1793
+ //#region src/trajectory/interrupted.ts
1794
+ /**
1795
+ * 中断轮次发现(`interrupted` 会话扫描)
1796
+ *
1797
+ * ## 这个模块解决什么
1798
+ *
1799
+ * DSH 的会话日志里,**进程在某一轮完成前退出**(被 kill/崩溃/关机超时)时,
1800
+ * 宿主重放时会为该轮补上收尾事件:
1801
+ *
1802
+ * ```jsonl
1803
+ * turn/end {"turn":66,"reason":{"kind":"interrupted"}}
1804
+ * step/end {"turn":66,"step":85}
1805
+ * session/end-seed {}
1806
+ * ```
1807
+ *
1808
+ * 宿主**只补收尾,不续跑** —— 那一轮的工作就此停在那里。本模块负责把这些会话找出来,
1809
+ * 交给上层(认知轨迹)决定要不要续。
1810
+ *
1811
+ * ## 判据(三条同时成立,缺一不可)
1812
+ *
1813
+ * 1. 日志里**最后一条 `turn/end`** 的 `reason.kind` ∈ {@link UNFINISHED_KINDS}
1814
+ * (= `interrupted` 崩溃补收尾 / `aborted` 正常终止,两者都是"这一轮没收完");
1815
+ * 2. 该 `turn/end` **之后没有新的 `turn/start`**(否则已被后续轮次接续);
1816
+ * 3. ⛔ **不要求**末条是 `session/end-seed`(2026-10-23 放宽,原因见 {@link detectInterruptedEnd})。
1817
+ *
1818
+ * 🔴 **2026-10-24 补入 `aborted`**:修前只认 `interrupted` ⇒「被正常终止」整类漏扫
1819
+ * (发布重启/换宿主最常见的形态)。判据问的是「这一轮收完了吗」,**不是"进程死得惨不惨"**。
1820
+ *
1821
+ * ⚠️ **为什么不能只看"存在过 interrupted"**:一个长会话历史上可能中断几十次
1822
+ * (本容器实测单会话 33 次)。只看"曾经中断"会把早已恢复的会话全部当成待续 ⇒ 灾难。
1823
+ *
1824
+ * 🔴 **为什么原判据要求"末条 = end-seed"是错的**(实测 26 条里漏 2 条):
1825
+ * 重启后宿主会在 end-seed **之后**追加 `agent/inbox/spliced`(把待办消息重新塞进收件箱)
1826
+ * ⇒ "末条必须 end-seed"**在恢复动作发生之后就恒不成立**,
1827
+ * 而"被恢复过又再次被打断"恰恰是守护最该覆盖的形态。
1828
+ *
1829
+ * ## 为什么不整文件解压
1830
+ *
1831
+ * DSH 日志是 **zstd 多帧追加**(实测单会话 24705 帧 / 47 MB)。原生
1832
+ * `zlib.zstdDecompressSync` 只解**第一帧**,对多帧直接报 `Unknown frame descriptor`。
1833
+ * ⇒ 采用**尾部窗口 + 倒序逐帧解压**:只读文件末尾 N 字节,从后往前找帧魔数,
1834
+ * 解到凑够所需事件数即停。实测 8h 窗口 6 个会话 **20 ms**。
1835
+ *
1836
+ * ## 世代识别复用 `cleanup.ts`
1837
+ *
1838
+ * 日志文件名有多个世代(`session.jsonl` / `session.v3.jsonl.zstd` / `session.v4.jsonl.zstd`…),
1839
+ * 宿主按世代写且**迁移保留源文件**。⇒ 本模块**不硬编码文件名**,走
1840
+ * {@link findSessionLog}(与清理/体积统计同一判据),避免"新世代对本模块不可见"。
1841
+ *
1842
+ * ## 边界
1843
+ *
1844
+ * - **只读**:不改任何会话日志。
1845
+ * - **不判周期**:何时扫、扫完做什么归调用方(CCC 的认知轨迹)。本模块只回答
1846
+ * 「此刻哪些会话以 interrupted 结尾」。
1847
+ */
1848
+ /**
1849
+ * zstd 解压(**运行时有、类型声明没有**)。
1850
+ *
1851
+ * 🔴 为什么不能用 `import { zstdDecompressSync } from 'node:zlib'`:
1852
+ * 本仓 `@types/node@^20` 的声明里**没有** zstd 系列(Node 22.15+ 才进声明)。
1853
+ * 而运行时本机 v22.22.1 **确实有**(实测 `typeof require('zlib').zstdDecompressSync === 'function'`)。
1854
+ * ⇒ 用命名空间导入 + 局部断言,并**在运行时校验**;缺失时走明确降级(返回空,不静默瞎猜)。
1855
+ */
1856
+ const zstdDecompressSync = nodeZlib.zstdDecompressSync;
1857
+ /** zstd 帧魔数(小端 `28 B5 2F FD`) */
1858
+ const ZSTD_MAGIC = Buffer.from([
1859
+ 40,
1860
+ 181,
1861
+ 47,
1862
+ 253
1863
+ ]);
1864
+ /** 默认回看窗口:8 小时(owner 2026-10-22 定) */
1865
+ const DEFAULT_LOOKBACK_MS = 288e5;
1866
+ /**
1867
+ * 尾部读取的**渐进块**(从小到大,够用就停)。
1868
+ *
1869
+ * 🔴🔴 **2026-10-24 A108:从"一次读满 8 MiB"改成"渐进扩块、见到证据就停"。**
1870
+ *
1871
+ * 修前:不管需要多少,**先 `readSync` 读满 8 MiB**,再线性扫一遍帧魔数(O(8 MiB)),
1872
+ * 然后解压到**攒够 400 条事件**为止 —— 而 400 只是一个**猜的数**:
1873
+ * 实测最长单轮 482 行、某会话 `turn/start` 落在 400 条之外 **83 行** ⇒ **证据被截断**,
1874
+ * 于是「这一轮没跑完」被读成「这一轮早就干净收尾了」⇒ **漏唤醒**。这就是 A108。
1875
+ *
1876
+ * ⇒ 现在的判据换成**「要的是证据,不是条数」**:从尾部往前,**一旦同时见到
1877
+ * `turn/start` 与 `turn/end` 就停**(那两条就是判定所需的全部证据)。
1878
+ *
1879
+ * 💰 实测代价(本机 13 个窗口内会话,中位 3 次):
1880
+ * · 单会话 **0.2 ~ 54 ms**(多数 1~9 ms)|13 个合计 **132 ms**;
1881
+ * · 实际读取量多数 **64 KiB**(最多的一个才 4 MiB)—— **远小于修前的恒定 8 MiB**。
1882
+ * ⇒ 所以这既是**修正确性**,也是**省开销**(不是"用性能换正确")。
1883
+ */
1884
+ const TAIL_CHUNKS = [
1885
+ 65536,
1886
+ 262144,
1887
+ 1 << 20,
1888
+ 4 << 20,
1889
+ 8 << 20
1890
+ ];
1891
+ /**
1892
+ * 尾部读取的**硬上限**(渐进块的最大一档)。
1893
+ *
1894
+ * ⚠️ 它仍然存在,因为"扫到边界为止"在病态日志上可能无界(一整轮几十万条事件)。
1895
+ * 与修前的区别:修前它是**起点**(无条件读满),现在它是**兜底**(扩到这里还没有证据才算不可判定)。
1896
+ */
1897
+ const TAIL_WINDOW_BYTES = 8 << 20;
1898
+ /**
1899
+ * 从文件尾部**渐进扩块**倒序解压,**见到判定所需的证据就停**。
1900
+ *
1901
+ * ## 与修前的两点不同(都对 A108 负责)
1902
+ *
1903
+ * 1. **不再一次读满 8 MiB**:按 {@link TAIL_CHUNKS} 从 64 KiB 起扩,够用就停。
1904
+ * 实测多数会话 64 KiB 就够(见 `TAIL_CHUNKS` 的代价段落)。
1905
+ * 2. **停止条件从"攒够 N 条"改成"见到证据"**。⛔ 不要再退回"攒够 400 条" ——
1906
+ * 那个数**必然**在长轮次上截断(实测被挤出 83 行)。
1907
+ *
1908
+ * ## 🔴 什么是「证据」(这里踩过一次,写清楚免得后人重犯)
1909
+ *
1910
+ * **必需证据 = 见到一条 `turn/start`**。理由:
1911
+ * - 判定形态 B(轮内死掉)只需"最后一条 `turn/start` 之后没有 `turn/end`"——
1912
+ * 而**进程死在轮内时,宿主根本没机会写 `turn/end`** ⇒ 若要求"必须见到 turn/end",
1913
+ * **这一整类会被判成"不可判定"**(我第一版就是这么写的,被 `形态 B` 用例当场打红)。
1914
+ * - `turn/end` 是**可选证据**:见到它能让形态 A(收尾原因)也成立,但它**不是判定的前提**。
1915
+ *
1916
+ * ⇒ 停止条件:**见到 `turn/start` 即可停**(`turn/end` 顺手见到更好)。
1917
+ * ⇒ 只有**连一条 `turn/start` 都见不到**才报 `exhausted`(那才是真的"不知道")——
1918
+ * 典型成因:一整轮长到 8 MiB 都装不下它的起点。
1919
+ *
1920
+ * ⚠️ 另一种"见不到 turn/start"的合法情况:**日志里从来没有过轮**(空会话/只有 header)。
1921
+ * ⇒ 那种情况扩到**全文**(`winBytes >= st.size`)仍见不到 ⇒ 判 `exhausted: false`
1922
+ * (**"没有轮"是确定的,不是不知道**),由判据函数返回 null ⇒ 进 `skipped`。
1923
+ */
1924
+ function readTailEvents(logPath) {
1925
+ if (zstdDecompressSync === void 0) return {
1926
+ events: [],
1927
+ exhausted: false
1928
+ };
1929
+ const st = statSync(logPath);
1930
+ if (st.size < 8) return {
1931
+ events: [],
1932
+ exhausted: false
1933
+ };
1934
+ const fd = openSync(logPath, "r");
1935
+ try {
1936
+ for (const chunk of TAIL_CHUNKS) {
1937
+ const winBytes = Math.min(chunk, st.size);
1938
+ const buf = Buffer.alloc(winBytes);
1939
+ readSync(fd, buf, 0, winBytes, st.size - winBytes);
1940
+ const offsets = [];
1941
+ for (let i = 0; i <= winBytes - ZSTD_MAGIC.length; i++) if (buf[i] === ZSTD_MAGIC[0] && buf[i + 1] === ZSTD_MAGIC[1] && buf[i + 2] === ZSTD_MAGIC[2] && buf[i + 3] === ZSTD_MAGIC[3]) offsets.push(i);
1942
+ const events = [];
1943
+ let sawStart = false;
1944
+ for (let k = offsets.length - 1; k >= 0; k--) {
1945
+ const start = offsets[k];
1946
+ const end = k + 1 < offsets.length ? offsets[k + 1] : winBytes;
1947
+ let text;
1948
+ try {
1949
+ text = zstdDecompressSync(buf.subarray(start, end)).toString("utf8");
1950
+ } catch {
1951
+ break;
1952
+ }
1953
+ const batch = [];
1954
+ for (const line of text.split("\n")) {
1955
+ if (!line.trim()) continue;
1956
+ try {
1957
+ const ev = JSON.parse(line);
1958
+ batch.push(ev);
1959
+ if (ev.type === "turn/start") sawStart = true;
1960
+ } catch {}
1961
+ }
1962
+ events.unshift(...batch);
1963
+ if (sawStart) return {
1964
+ events,
1965
+ exhausted: false
1966
+ };
1967
+ }
1968
+ if (winBytes >= st.size) return {
1969
+ events,
1970
+ exhausted: false
1971
+ };
1972
+ if (winBytes >= TAIL_WINDOW_BYTES) return {
1973
+ events,
1974
+ exhausted: true
1975
+ };
1976
+ }
1977
+ return {
1978
+ events: [],
1979
+ exhausted: true
1980
+ };
1981
+ } finally {
1982
+ closeSync(fd);
1983
+ }
1984
+ }
1985
+ /**
1986
+ * 判定一组尾部事件是否「以未完成的轮次结尾」。
1987
+ * 不命中 → null。
1988
+ *
1989
+ * ## 🔴🔴 2026-10-24 判据放宽(owner 报障:「有的会话在重启时不是崩溃的就扫不到,还是没修好」)
1990
+ *
1991
+ * ### 漏的是什么(宿主源码实读,非推测)
1992
+ *
1993
+ * 宿主只有**两条**会写出「轮次没收完」的 `turn/end`,而它们的 `kind` **不是同一个词**:
1994
+ *
1995
+ * | 形态 | 写谁 | `reason.kind` | 修前是否命中 |
1996
+ * |---|---|---|---|
1997
+ * | 进程**崩溃**/被强杀,下一轮 resume 时补收尾 | `interruptedTurnClosers()` → `openTurnClosers(events,{kind:'interrupted'})` | **`interrupted`** | ✅ |
1998
+ * | 进程**正常退出**(`restart-web` 的 SIGTERM、`agent/disposed`、用户中断) | 驱动 catch 里 `abortedCancelCause(signal)` | **`aborted`** + 内层 `reason:{kind:'user'\|'parent'\|'disposed'\|'hook'}` | ❌ **漏** |
1999
+ *
2000
+ * ⇒ 修前只认 `interrupted` ⇒ **「被正常终止」这一大类全部漏掉**,而它恰恰是
2001
+ * **发布重启/换宿主** 最常见的形态(比崩溃常见得多)。
2002
+ *
2003
+ * 🔴 **这两条是同一个状态的两种成因,不是"一个对一个不对"**:
2004
+ * 判据问的是「**这一轮有没有收完**」,不是「进程死得惨不惨」。
2005
+ * 拿死法当判据 ⇒ 换一种死法就漏一批(这正是 owner 反复看到的"还是没修好")。
2006
+ *
2007
+ * ### 为什么不能只加 `aborted` 就完事
2008
+ * `aborted.reason.kind` 里还可能有 `hook`(宿主主动 hook 取消)——它同属"没跑完"。
2009
+ * ⇒ 本判据**只看外层 `kind`**,理由:外层已经回答了"这一轮收完了吗",
2010
+ * 内层回答的是"谁让它停的",**那是另一个问题**,混进来会让判据随"谁取消的"漂移。
2011
+ *
2012
+ * ### ⛔ 刻意**不**纳入的形态(保守边界,写明理由)
2013
+ * - `error`:本轮**收完了**(宿主记了错误收尾),它不是"停在这里等人续";
2014
+ * - 完全没有 `turn/end` 的开轮(例如日志末尾是 `turn/start` 后再无事件):
2015
+ * 那**也可能是"进程正活着、这一轮正在跑"**,与真中断在读数上不可分
2016
+ * ⇒ 纳入会误唤醒在跑的会话。⚠️ 这是**已知边界**,不是遗漏。
2017
+ *
2018
+ * ### 为什么不会误报"曾经中断但已恢复"
2019
+ * 恢复一定伴随 `turn/start`(新一轮)或新的 `turn/end`(`completed` 等)⇒ 判据 1/2 各自挡住。
2020
+ */
2021
+ /**
2022
+ * 「这一轮没跑完」的 `turn/end.reason.kind` 取值域。
2023
+ *
2024
+ * 🔴🔴 **2026-10-24(owner 报障)**:原判据只认 `'interrupted'` 一个词 ⇒
2025
+ * **「被正常终止」整类漏扫**。两个值对应的形态见 {@link detectInterruptedEnd} 的头注。
2026
+ *
2027
+ * ⚠️ **这是判据的唯一取值点** —— 别在别处再写一份字面量(两份会漂)。
2028
+ */
2029
+ const UNFINISHED_KINDS = /* @__PURE__ */ new Set(["interrupted", "aborted"]);
2030
+ /**
2031
+ * 尾部窗口里**最后一条 `turn/start` 的轮号**(D244 输出面,给调用方做 A108 的现场判断)。
2032
+ *
2033
+ * ⚠️ 与 {@link detectUnclosedTurn} 不同:**它不判"是否未收尾"**,只**如实报告窗口里有什么**。
2034
+ * 分开的理由:前者是判据(会 deciding 命中),后者是读数(零判据风险)——
2035
+ * 把读数塞进判据函数,调用方就没法在"判据因为窗口截断而沉默"时看出**为什么**沉默。
2036
+ *
2037
+ * @returns 轮号;窗口内没有 `turn/start` 或轮号缺失 ⇒ `null`
2038
+ */
2039
+ function lastTurnStartTurnOf(events) {
2040
+ for (let i = events.length - 1; i >= 0; i--) if (events[i]?.type === "turn/start") {
2041
+ const t = events[i]?.data?.turn;
2042
+ return typeof t === "number" ? t : null;
2043
+ }
2044
+ return null;
2045
+ }
2046
+ /**
2047
+ * 尾部窗口里**最后一条 `turn/end` 的 `reason.kind`**(同上:读数,不是判据)。
2048
+ * @returns kind;窗口内没有 `turn/end`(形态 B 的典型)或 kind 缺失 ⇒ `null`
2049
+ */
2050
+ function lastTurnEndKindOf(events) {
2051
+ for (let i = events.length - 1; i >= 0; i--) if (events[i]?.type === "turn/end") {
2052
+ const k = events[i]?.data?.reason?.kind;
2053
+ return typeof k === "string" ? k : null;
2054
+ }
2055
+ return null;
2056
+ }
2057
+ /**
2058
+ * 命中判定(**形态 A:靠 `turn/end.reason.kind`**)。
2059
+ *
2060
+ * ⚠️ **本函数刻意不判「开了没收」** —— 那种形态与「此刻正在跑」在日志读数上**不可分**,
2061
+ * 只有扫描层手里有 `liveIds`(正在跑的会话 id)才判得准。
2062
+ * ⇒ 分工:**本函数判"收尾原因",`scanInterruptedSessions` 判"开了没收"**(见 `detectUnclosedTurn`)。
2063
+ * ⛔ 不要把这些情形塞进本函数 —— 会让它同时依赖"日志形态"与"进程状态",那种判据无法单独证伪。
2064
+ *
2065
+ * @returns 命中时给出被中断的轮次与"可能有副作用"的工具名;不命中 null
2066
+ */
2067
+ function detectInterruptedEnd(events) {
2068
+ if (events.length === 0) return null;
2069
+ let idx = -1;
2070
+ for (let i = events.length - 1; i >= 0; i--) if (events[i]?.type === "turn/end") {
2071
+ idx = i;
2072
+ break;
2073
+ }
2074
+ if (idx < 0) return null;
2075
+ const turnEnd = events[idx];
2076
+ const kind = turnEnd?.data?.reason?.kind;
2077
+ if (typeof kind !== "string" || !UNFINISHED_KINDS.has(kind)) return null;
2078
+ for (let i = idx + 1; i < events.length; i++) if (events[i]?.type === "turn/start") return null;
2079
+ const turn = typeof turnEnd?.data?.turn === "number" ? turnEnd.data.turn : null;
2080
+ return {
2081
+ turn,
2082
+ pendingTools: findPendingTools(events, turn)
2083
+ };
2084
+ }
2085
+ /**
2086
+ * 命中判定(**形态 B:一轮开了却没收**)—— 🔴🔴 **2026-10-24 owner 报障的正解**。
2087
+ *
2088
+ * ## 为什么这一格以前整类漏掉
2089
+ *
2090
+ * 进程在**轮内任意位置**死掉时,宿主**永远没机会写 `turn/end`**
2091
+ * ⇒ 日志就停在 `tool/call` / `step/start` / `assistant/message` 上。
2092
+ * 修前 `detectInterruptedEnd` 首行是 `if (idx < 0) return null`(找不到 turn/end 就放弃)
2093
+ * ⇒ **这一整类被判成"没中断"**。
2094
+ *
2095
+ * ## 判据
2096
+ * **最后一条 `turn/start` 之后没有 `turn/end`** ⇒ 那一轮开了没收。
2097
+ *
2098
+ * ## 为什么必须由**扫描层**调用(而不是并进 `detectInterruptedEnd`)
2099
+ * 「开了没收」与「此刻正在跑」在**日志读数上完全一样**。
2100
+ * 唯一能分开它们的东西是 `liveIds`(宿主报的"此刻在跑的会话")——
2101
+ * 而那是**扫描层**的参数,不是日志的属性。
2102
+ * ⇒ 本函数只回答"日志形态是不是开了没收",**由调用方保证已排除在跑的会话**。
2103
+ * ⚠️ 判据纪律:两个不同的问题(日志形态 / 进程状态)**不许塞进同一个函数**,
2104
+ * 否则那个函数无法被单独证伪(改任一侧都可能让测试红,而说不清是哪侧错了)。
2105
+ *
2106
+ * @param events 尾部事件
2107
+ * @returns 命中时给出未收尾的轮次与"可能有副作用"的工具名;没有未收尾的轮次 ⇒ null
2108
+ */
2109
+ function detectUnclosedTurn(events) {
2110
+ if (events.length === 0) return null;
2111
+ let lastEnd = -1;
2112
+ for (let i = events.length - 1; i >= 0; i--) if (events[i]?.type === "turn/end") {
2113
+ lastEnd = i;
2114
+ break;
2115
+ }
2116
+ let startIdx = -1;
2117
+ for (let i = events.length - 1; i >= 0; i--) if (events[i]?.type === "turn/start") {
2118
+ startIdx = i;
2119
+ break;
2120
+ }
2121
+ if (startIdx < 0 || startIdx < lastEnd) return null;
2122
+ /**
2123
+ * ⚠️ **不要拿 `session/end-seed` 当"已封口"判据**(我第一版这样写,当场被真机用例打红)。
2124
+ *
2125
+ * 真实日志里最常见的恢复形态就是
2126
+ * `turn/end[interrupted] → session/end-seed → turn/start(新轮)`
2127
+ * —— `end-seed` 是**上一轮**的收尾标记,**不是新一轮的**。
2128
+ * 拿它判"新一轮已封口" ⇒ 会把「新一轮开了就死」误判成"已接续" ⇒ **又回到"啥都扫不到"**。
2129
+ *
2130
+ * ⇒ 判据保持最简:**最后一条 `turn/start` 是否晚于最后一条 `turn/end`**。
2131
+ * "会不会重复拉起正在跑的会话"由 `liveIds` 负责(扫描层),不是本函数的事。
2132
+ */
2133
+ const t = events[startIdx]?.data?.turn;
2134
+ const turn = typeof t === "number" ? t : null;
2135
+ return {
2136
+ turn,
2137
+ pendingTools: findPendingTools(events, turn)
2138
+ };
2139
+ }
2140
+ /**
2141
+ * 找出末轮里发起了但没有结果的工具调用名。
2142
+ *
2143
+ * ⚠️ **尽力而为**,非权威:尾部窗口可能截断更早的调用。上层应把它当
2144
+ * "**可能**未完成的副作用"清单,而不是"确定未完成"。
2145
+ */
2146
+ function findPendingTools(events, turn) {
2147
+ if (turn === null) return [];
2148
+ const called = /* @__PURE__ */ new Map();
2149
+ const resolved = /* @__PURE__ */ new Set();
2150
+ for (const e of events) {
2151
+ const data = e.data;
2152
+ if (!data || data.turn !== turn) continue;
2153
+ if (e.type === "tool/call" && typeof data.callId === "string" && typeof data.name === "string") called.set(data.callId, data.name);
2154
+ else if (e.type === "tool/result" && typeof data.callId === "string") resolved.add(data.callId);
2155
+ }
2156
+ return [...called].filter(([callId]) => !resolved.has(callId)).map(([, name]) => name);
2157
+ }
2158
+ /**
2159
+ * 扫描一个 **project 目录**(形如 `<sessions-root>/--home-yh-home-home-serenity--/`)。
2160
+ *
2161
+ * @param projectDir DSH 项目目录(其下每个子目录是一个会话)
2162
+ * @param options.lookbackMs 回看窗口(缺省 8h)
2163
+ * @param options.now 注入"当前时刻"(测试用)
2164
+ * @param options.liveIds 需要跳过的会话 id(正在跑的会话不该被判为待续)
2165
+ */
2166
+ function scanInterruptedSessions(projectDir, options = {}) {
2167
+ const t0 = Date.now();
2168
+ const lookbackMs = options.lookbackMs ?? 288e5;
2169
+ const cutoff = (options.now ?? Date.now()) - lookbackMs;
2170
+ const liveIds = options.liveIds ?? /* @__PURE__ */ new Set();
2171
+ const result = {
2172
+ sessions: [],
2173
+ scanned: 0,
2174
+ skipped: 0,
2175
+ errors: [],
2176
+ elapsedMs: 0,
2177
+ excluded: [],
2178
+ undecidable: []
2179
+ };
2180
+ if (!existsSync(projectDir)) {
2181
+ result.elapsedMs = Date.now() - t0;
2182
+ return result;
2183
+ }
2184
+ let entries;
2185
+ try {
2186
+ entries = readdirSync(projectDir);
2187
+ } catch (err) {
2188
+ result.errors.push({
2189
+ sessionId: "*",
2190
+ message: err instanceof Error ? err.message : String(err)
2191
+ });
2192
+ result.elapsedMs = Date.now() - t0;
2193
+ return result;
2194
+ }
2195
+ for (const sessionId of entries) {
2196
+ if (liveIds.has(sessionId)) {
2197
+ const lp = findSessionLog(join(projectDir, sessionId));
2198
+ if (lp !== null) try {
2199
+ if (statSync(lp).mtimeMs >= cutoff) result.excluded.push(sessionId);
2200
+ } catch {}
2201
+ continue;
2202
+ }
2203
+ const dirPath = join(projectDir, sessionId);
2204
+ const logPath = findSessionLog(dirPath);
2205
+ if (logPath === null) continue;
2206
+ let mtimeMs;
2207
+ try {
2208
+ mtimeMs = statSync(logPath).mtimeMs;
2209
+ } catch (err) {
2210
+ result.errors.push({
2211
+ sessionId,
2212
+ message: err instanceof Error ? err.message : String(err)
2213
+ });
2214
+ continue;
2215
+ }
2216
+ if (mtimeMs < cutoff) continue;
2217
+ result.scanned++;
2218
+ try {
2219
+ const tail = readTailEvents(logPath);
2220
+ const events = tail.events;
2221
+ /**
2222
+ * 🔴🔴 **A108:拿不到判定所需证据时,报「不可判定」,⛔ 不许塌成「没中断」。**
2223
+ *
2224
+ * `tail.exhausted` 的判据 = 扩到 8 MiB 硬上限仍**没能同时见到** `turn/start` 与 `turn/end`。
2225
+ * 那时两个判据函数都会返回 null(它们只看「见到的那些事件」)——
2226
+ * 而「没证据」与「证据说没事」在返回值上长得一样,**这正是 A108 的漏**。
2227
+ * ⇒ 所以这一格必须**在调用它们之前**分流。
2228
+ */
2229
+ if (tail.exhausted) {
2230
+ result.undecidable.push({
2231
+ sessionId,
2232
+ logPath,
2233
+ mtimeMs,
2234
+ lastEventAt: typeof events[events.length - 1]?.time === "number" ? events[events.length - 1].time : null,
2235
+ reason: `扩到 ${Math.round(TAIL_WINDOW_BYTES / 1048576)} MiB 硬上限仍未同时见到 turn/start 与 turn/end`
2236
+ });
2237
+ continue;
2238
+ }
2239
+ /**
2240
+ * 🔴🔴 **两种形态都要接**(2026-10-24 owner 报障:「我要求你抓到非工具调用的中断,
2241
+ * 现在什么都抓不到了」)。
2242
+ *
2243
+ * - 形态 A `detectInterruptedEnd`:日志里有 `turn/end`,且它的 `reason.kind` 是
2244
+ * `interrupted`(崩溃后被 resume 补收尾)/`aborted`(被正常终止)。
2245
+ * - 形态 B `detectUnclosedTurn`:**日志里压根没有 `turn/end`** —— 进程在轮内任意位置死掉,
2246
+ * 宿主没机会写收尾 ⇒ 日志停在 `tool/call` / `step/start` / `assistant/message`。
2247
+ * ⚠️ **这一格必须靠"已排除在跑的会话"才成立**,而 `liveIds` 恰好在本函数手里
2248
+ * (循环开头 `if (liveIds.has(sessionId)) continue`)⇒ 这就是它该待在这里、而不是
2249
+ * 并进 `detectInterruptedEnd` 的原因。
2250
+ *
2251
+ * 实测(本机 10 个窗口内会话):**4 条**只能由形态 B 抓到(末条 `tool/call` / `step/start`),
2252
+ * 修前全落到 `skipped`。
2253
+ */
2254
+ const formA = detectInterruptedEnd(events);
2255
+ const formB = formA === null ? detectUnclosedTurn(events) : null;
2256
+ const hit = formA ?? formB;
2257
+ if (!hit) {
2258
+ result.skipped++;
2259
+ continue;
2260
+ }
2261
+ const tailTime = events[events.length - 1]?.time;
2262
+ result.sessions.push({
2263
+ sessionId,
2264
+ logPath,
2265
+ turn: hit.turn,
2266
+ lastEventAt: typeof tailTime === "number" ? tailTime : null,
2267
+ mtimeMs,
2268
+ pendingTools: hit.pendingTools,
2269
+ form: formA !== null ? "interrupted-end" : "unclosed",
2270
+ lastTurnEndKind: lastTurnEndKindOf(events),
2271
+ lastTurnStartTurn: lastTurnStartTurnOf(events)
2272
+ });
2273
+ } catch (err) {
2274
+ result.errors.push({
2275
+ sessionId,
2276
+ message: err instanceof Error ? err.message : String(err)
2277
+ });
2278
+ }
2279
+ }
2280
+ result.sessions.sort((a, b) => (b.lastEventAt ?? b.mtimeMs) - (a.lastEventAt ?? a.mtimeMs));
2281
+ result.elapsedMs = Date.now() - t0;
2282
+ return result;
2283
+ }
2284
+ /**
2285
+ * 由 CCC 根推导它的 DSH 项目目录名(`--home-yh-home-home-serenity--`)。
2286
+ *
2287
+ * 规则取自宿主:把绝对路径的 `/` 换成 `-`,并在前后各加 `--`。
2288
+ * ⚠️ 这是**推导**,不是真相源 —— 实际目录名以磁盘为准;调用方应在不存在时
2289
+ * 回退到"扫整个 sessions root"或直接报未找到。
2290
+ */
2291
+ function projectDirNameForCwd(cwd) {
2292
+ return `--${cwd.replace(/\/+$/, "").split("/").filter(Boolean).join("-")}--`;
2293
+ }
2294
+ /**
2295
+ * 续跑正文(**简单、稳定、不含版本号**)。
2296
+ *
2297
+ * 判据(为什么这么短):本动作的用途是**把被打断的会话推回工作状态**,
2298
+ * 不是替它重述任务 —— 被打断那一轮的上下文就在它自己的日志里。
2299
+ * ⇒ 正文只做三件事:① 说明发生了什么;② 提示**先核对副作用**;③ 让它接着做。
2300
+ *
2301
+ * 🔴 **为什么要写"先核对副作用"**:中断时可能有「有 call 无 result」的工具调用,
2302
+ * 它们的副作用**未知**(可能已写文件 / 已发消息)。若模型把那一轮整段重放,
2303
+ * 会**重复执行**。这句话是给模型的护栏,不是客套。
2304
+ */
2305
+ function buildContinueText(sessionId, turn, pendingTools) {
2306
+ const lines = [];
2307
+ lines.push("[中断续跑] 上一条进程在本轮完成前退出,该轮已被中止。现在继续。");
2308
+ lines.push("");
2309
+ lines.push(`会话: ${sessionId}${turn === null ? "" : ` / 被中断的轮次: turn ${turn}`}`);
2310
+ if (pendingTools.length > 0) {
2311
+ lines.push("");
2312
+ lines.push(`⚠️ 中断时这些工具「有调用、无结果」(副作用未知,**可能已经执行**): ${pendingTools.join(", ")}`);
2313
+ lines.push(" ⇒ 先核对它们是否已生效(文件是否已写、消息是否已发),再决定要不要重做。");
2314
+ lines.push(" ⛔ 不要把被中断的那一轮整段重放。");
2315
+ }
2316
+ lines.push("");
2317
+ lines.push("请接着完成该轮未完成的工作;完成后把进展写入本轨迹的 SESSION.md。");
2318
+ return lines.join("\n");
2319
+ }
2320
+ /**
2321
+ * 把一个 agent 唤醒(注入一条续跑消息)。
2322
+ *
2323
+ * 两条路径(与 `wake/scheduler.ts` 的 `sendToCognitiveTrajectory` 同源判据):
2324
+ * - **live**:`agents.get(id)` 拿得到 ⇒ 目标在跑(或被加载)⇒ `steer` 当场注入当前轮;
2325
+ * `steer` 不可用则退回 `followup`(宁可排队,不可静默丢)。
2326
+ * - **cold-resume**:`sessionController.resolveAgent(id)` 冷载入后同样注入。
2327
+ *
2328
+ * ⚠️ 冷会话载入**会真起一个 agent**(消耗真实算力)⇒ 调用方必须自己决定要不要,
2329
+ * 本函数不做数量限制(那是策略,归调用者)。
2330
+ */
2331
+ function createResumeOne(deps) {
2332
+ return async (hit) => {
2333
+ const { sessionId } = hit;
2334
+ const text = buildContinueText(sessionId, hit.turn, hit.pendingTools);
2335
+ let agent;
2336
+ let how;
2337
+ const live = deps.agents?.get?.(sessionId);
2338
+ if (live) {
2339
+ agent = live;
2340
+ how = "live";
2341
+ } else {
2342
+ const sc = deps.sessionController;
2343
+ const resolve = sc?.resolveAgent;
2344
+ if (typeof resolve !== "function" || sc === void 0) return {
2345
+ sessionId,
2346
+ ok: false,
2347
+ error: "宿主 sessionController 不可用(重启启动窗口?)—— 稍后重试"
2348
+ };
2349
+ try {
2350
+ const out = await resolve.call(sc, sessionId);
2351
+ if (!out?.agent) return {
2352
+ sessionId,
2353
+ ok: false,
2354
+ error: `冷载入未返回 agent: ${String(out?.error ?? "未知原因")}`
2355
+ };
2356
+ agent = out.agent;
2357
+ how = "cold-resume";
2358
+ } catch (err) {
2359
+ return {
2360
+ sessionId,
2361
+ ok: false,
2362
+ error: `冷载入失败: ${err instanceof Error ? err.message : String(err)}`
2363
+ };
2364
+ }
2365
+ }
2366
+ const a = agent;
2367
+ try {
2368
+ if (typeof a.steer === "function" && a.status === "running") {
2369
+ a.steer(deps.makeMessage(text));
2370
+ return {
2371
+ sessionId,
2372
+ ok: true,
2373
+ how: `${how}+steer`
2374
+ };
2375
+ }
2376
+ if (typeof a.followup === "function") {
2377
+ a.followup(deps.makeMessage(text));
2378
+ return {
2379
+ sessionId,
2380
+ ok: true,
2381
+ how: `${how}+followup`
2382
+ };
2383
+ }
2384
+ if (typeof a.steer === "function") {
2385
+ a.steer(deps.makeMessage(text));
2386
+ return {
2387
+ sessionId,
2388
+ ok: true,
2389
+ how: `${how}+steer`
2390
+ };
2391
+ }
2392
+ return {
2393
+ sessionId,
2394
+ ok: false,
2395
+ how,
2396
+ error: "agent 既无 steer 也无 followup(宿主契约漂移)"
2397
+ };
2398
+ } catch (err) {
2399
+ if (typeof a.followup === "function") try {
2400
+ a.followup(deps.makeMessage(text));
2401
+ return {
2402
+ sessionId,
2403
+ ok: true,
2404
+ how: `${how}+followup(steer 失败后退回)`
2405
+ };
2406
+ } catch (err2) {
2407
+ return {
2408
+ sessionId,
2409
+ ok: false,
2410
+ error: `steer 与 followup 均失败: ${String(err2?.message ?? err2)}`
2411
+ };
2412
+ }
2413
+ return {
2414
+ sessionId,
2415
+ ok: false,
2416
+ error: `注入失败: ${err instanceof Error ? err.message : String(err)}`
2417
+ };
2418
+ }
2419
+ };
2420
+ }
2421
+ //#endregion
2422
+ //#region src/wake/clock.ts
2423
+ /**
2424
+ * wake/clock.ts — **时钟运行时工厂**(S142 §38 复审候选 C6b,路径 **P2**)
2425
+ *
2426
+ * 🔴 2026-09-25(S142 分批清死代码·第 1 批)**更正**:本文件当初是为**两条**独立 5min 时钟抽出的公共引擎:
2427
+ * · 唤醒调度器(`wake/scheduler.ts`,一次性唤醒,D58)—— **现存**
2428
+ * · autopilot 时钟(`autopilot-trajectory.ts`,周期自唤醒,D59)—— 🔴 **已随 v1.35.0 整段退场**
2429
+ * 二者的**业务判据完全不同**(谁来扫 / 扫什么 / 投递给谁 / 按什么周期),但**引擎骨架逐字同形**
2430
+ * ——进程态 + 快照 + 复位 + `startTimer` + tick 外壳 + 事件接线 + disposer + `timer.unref()`。
2431
+ * 本条把**引擎**抽到本工厂,**各钟仍各持自己的定时器与自己的串行链**(现存的那一钟 = 唤醒调度器)。
2432
+ *
2433
+ * ───────────────────────────────────────────────────────────────────────────
2434
+ * 🔴 硬约束一:**两条时钟绝不可共用"排队执行"的链**(这是 P2 与 P1 的分界判据)
2435
+ *
2436
+ * autopilot 每轮要跑该 CCC 的**偏见脚本**(`fetchBiasContent`,超时 60s),可能阻塞数十秒;
2437
+ * 若与唤醒调度器共用一条串行链,会把另一条一起**卡住**(一次性唤醒被无关的偏见脚本拖住)。
2438
+ * ⇒ 本工厂把 `chain` 作为**每实例私有状态**:`createClock()` 调用一次 = 一条独立的串行域。
2439
+ * 实例之间**零共享**(无模块级单例、无共享 Promise、无共享 map)。
2440
+ * ⚠️ 若日后有人把 `chain` 提到模块级"省一次分配",**两条时钟的隔离即刻失效**——不许。
2441
+ *
2442
+ * 🔴 硬约束二:**武装门只判全局闸**(`gate()`),**不判"本次有无目标"**
2443
+ *
2444
+ * 2026-09-14 F 段实测缺陷:旧实现把"有 live CCC"当**武装前置条件**,而宿主刚重启时 live 会话
2445
+ * 必为空,且**"恢复旧会话"不触发 `session/created`**(只有新建会)⇒ 启动瞬间判定落空 =
2446
+ * **时钟永久不武装**(实测 6.6h 零 tick)。⇒ 现在「**全局闸开即武装**」,"有无目标"降级为
2447
+ * tick 内的廉价判定。**武装与"本次有无目标"是两件事,本工厂不得把二者耦合。**
2448
+ *
2449
+ * 🔴 硬约束三:**闸是可选能力,工厂不得替调用方假设"必须有闸"**(v1.34 S-1 解耦 → 2026-09-21 收窄)
2450
+ *
2451
+ * 🔴 2026-09-25(S142 分批清死代码·第 1 批)**更正:当前零条闸在用** —— 现存唯一那一钟
2452
+ * (唤醒调度器)本已无闸,而"曾只余的那一条" `autopilotWakeEnabled` 已随 **v1.35.0 autopilot
2453
+ * 整段退场**一并消失 ⇒ `gate` **缺省即常态,不是特例**。**唤醒调度器已无闸**
2454
+ * (原 `wakeSchedulerEnabled` 于 2026-09-21 所有者令砍掉 ⇒ 调用方**不传** `gate`
2455
+ * = 恒武装、永不因闸跳过、`enabled` 恒 `true`)。工厂取 `gate` **回调**(每次现读),
2456
+ * **不做**任何合并或缓存;**缺省无闸是合法形态,不是漏配**。
2457
+ *
2458
+ * ───────────────────────────────────────────────────────────────────────────
2459
+ * 两条时钟**逐项语义差**(`ClockOptions` 的每个可配项都对应下表一行,不是随意的灵活性):
2460
+ *
2461
+ * | 项 | 唤醒调度器 | autopilot | 工厂表达 |
2462
+ * |----|-----------|-----------|---------|
2463
+ * | `chain` | 有(全局串行) | 有(全局串行) | **总是私有**(硬约束一) |
2464
+ * | `lastTickLog` | 有(逐行摘要) | **无** | `logFrom` 不给 = 不记 |
2465
+ * | `ticks` 记账 | 工厂在 body **完成后**记 | **本钟**在同步段记 | `bodyCountsTick` + `begin` |
2466
+ * | body 返回类型 | `string[]`(人读摘要) | `void` | 泛型 `T` |
2467
+ * | 启动日志 | 固定串 | 含启用 CCC 计数 | `startLog: () => string` |
2468
+ * | body 异常兜底 | 留痕 + `console.warn` | 同 | 工厂统一(**吞异常不改语义**) |
2469
+ *
2470
+ * ───────────────────────────────────────────────────────────────────────────
2471
+ * **本工厂是"加法式"重构**(P2 的性质):不新增任何判定逻辑,只把同形骨架收成一份代码;
2472
+ * 两个模块仍各自导出 `wakeSchedulerState()` / `autopilotClockState()` 与各自的
2473
+ * `__reset…ForTest()`,**对外可观测面(字段名与语义)零变化**——`acc-diag` ①b 段、
2474
+ * `surface/observation.ts:containerClocks()` 与面板都在读它们(见该文件 :225–250 的设计红线)。
2475
+ *
2476
+ * 为什么**不**取 P1(真合并成一个 `setInterval` + 两条策略)——2026-09-15 所有者裁决:
2477
+ * P1 只多省 ~20 行,却要把 `armed` 拆成"宿主 + 每策略"两层、并强行分开串行域,
2478
+ * 即**动两条正在跑的时钟的骨架**;**不值当**(裁决原文见 SESSION §12.8②)。
2479
+ */
2480
+ /**
2481
+ * tick 周期(**5 分钟**)。
2482
+ *
2483
+ * 2026-09-15 S142「ACC 侧 autopilot 退场」:本值原从 `autopilot-core.ts` 导入(`TICK_MS`),
2484
+ * 但该模块随 autopilot 退场,而**本模块的唯一消费者是唤醒调度器**(`wake-scheduler`)——
2485
+ * 值本身与 autopilot 无关 ⇒ 随消费者归位到本模块(不再依赖 autopilot 判据层)。
2486
+ *
2487
+ * ⚠️ 语义不得变:该值同时是**报告文案的来源**("等下个 Nmin tick"),历史上曾因文案写死
2488
+ * 10min 与实现 5min 不符而误导排查(§12.7 文案纠错)。
2489
+ */
2490
+ const TICK_MS = 3e5;
2491
+ /**
2492
+ * 挂事件 → 触发 `start()`(**每个事件一次 try/catch 吞异常**:事件通道缺失不阻断时钟)。
2493
+ *
2494
+ * 为什么吞异常:宿主(headless profile 等)可能没有事件通道;**启动时已 `start()` 过一次**,
2495
+ * 事件只是"跟上面板开关 / 新会话"的热启动补丁,缺它不该让整个装配抛错。
2496
+ * @param on 宿主的 `on`(可能不存在)
2497
+ * @param ctx `on` 的 `this`(宿主 API 常要求绑定)
2498
+ * @param clock 目标时钟
2499
+ * @param events 事件名列表(回调恒为 `() => clock.start()`)
2500
+ */
2501
+ function attachClockEvents(ctx, clock, events) {
2502
+ const on = ctx?.on;
2503
+ if (typeof on !== "function") return;
2504
+ for (const name of events) try {
2505
+ on.call(ctx, name, () => clock.start());
2506
+ } catch {}
2507
+ }
2508
+ /**
2509
+ * 建一条时钟(**每钟各调一次**——本工厂**不是**单例,调用次数 = 时钟条数)。
2510
+ *
2511
+ * 顺序忠实于既有实现:`start()` 先武装并立即跑一次 tick → 再挂事件。
2512
+ * 拆卸由调用方经 `registerDisposer(ctx, label, clock.dispose)` 落到宿主的 `ctx.effect`。
2513
+ * @param opts 见 {@link ClockOptions}(每项对应一条既有的语义差)
2514
+ * @returns 该钟的 `{start, dispose, snapshot, reset}`
2515
+ */
2516
+ function createClock(opts) {
2517
+ const runtime = {
2518
+ armed: false,
2519
+ armedAt: null,
2520
+ lastTickAt: null,
2521
+ ticks: 0,
2522
+ lastSkipReason: null
2523
+ };
2524
+ if (opts.logFrom) runtime.lastTickLog = [];
2525
+ const st = {
2526
+ timer: null,
2527
+ chain: Promise.resolve(),
2528
+ runtime,
2529
+ wired: false,
2530
+ opts
2531
+ };
2532
+ /** 记一次 tick(工厂内部与 body 共用同一实现——只有一处会计逻辑) */
2533
+ const countTick = () => {
2534
+ runtime.lastTickAt = Date.now();
2535
+ runtime.ticks += 1;
2536
+ };
2537
+ /** 逐行打印(`logFrom` 缺省 ⇒ 本钟无 tick 日志,整段为死代码路径) */
2538
+ const emit = (lines) => {
2539
+ if (!opts.logFrom) return;
2540
+ for (const line of lines) console.log(`${opts.logFrom.prefix}${line}`);
2541
+ };
2542
+ const tick = () => {
2543
+ if (opts.gate !== void 0 && !opts.gate()) {
2544
+ runtime.lastSkipReason = opts.gateOffReason ?? null;
2545
+ return;
2546
+ }
2547
+ if (opts.begin) try {
2548
+ opts.begin();
2549
+ } catch (err) {
2550
+ console.warn(`[serenity-hooks] ✗ tick 前置异常: ${String(err?.message ?? err)}(${opts.label})`);
2551
+ return;
2552
+ }
2553
+ st.chain = st.chain.then(async () => {
2554
+ try {
2555
+ const value = await opts.body();
2556
+ if (!opts.bodyCountsTick) countTick();
2557
+ if (opts.logFrom) {
2558
+ runtime.lastTickLog = opts.logFrom.lines(value);
2559
+ emit(runtime.lastTickLog);
2560
+ }
2561
+ } catch (err) {
2562
+ const msg = `✗ tick 异常: ${String(err?.message ?? err)}`;
2563
+ if (opts.logFrom) runtime.lastTickLog = [msg];
2564
+ console.warn(`[serenity-hooks] ${msg}(${opts.label})`);
2565
+ }
2566
+ });
2567
+ };
2568
+ /**
2569
+ * 幂等启动 + 武装门:**已武装 → 原样返回**(防重复起定时器);
2570
+ * **闸关 → 不武装**(零资源占用);闸开则**无条件**武装(硬约束二:不判"有无目标")。
2571
+ */
2572
+ const start = () => {
2573
+ if (st.timer) return;
2574
+ if (opts.events && !st.wired) {
2575
+ st.wired = true;
2576
+ attachClockEvents(opts.ctx, api, opts.events);
2577
+ }
2578
+ if (opts.gate !== void 0 && !opts.gate()) return;
2579
+ st.timer = setInterval(tick, TICK_MS);
2580
+ st.timer.unref();
2581
+ runtime.armed = true;
2582
+ runtime.armedAt = Date.now();
2583
+ console.log(opts.startLog());
2584
+ if (opts.immediate !== false) tick();
2585
+ };
2586
+ const dispose = () => {
2587
+ if (st.timer) {
2588
+ clearInterval(st.timer);
2589
+ st.timer = null;
2590
+ }
2591
+ runtime.armed = false;
2592
+ runtime.armedAt = null;
2593
+ opts.onDispose?.();
2594
+ };
2595
+ const api = {
2596
+ start,
2597
+ dispose,
2598
+ snapshot: () => ({
2599
+ enabled: opts.gate === void 0 ? true : opts.gate(),
2600
+ ...runtime,
2601
+ ...runtime.lastTickLog ? { lastTickLog: [...runtime.lastTickLog] } : {}
2602
+ }),
2603
+ noteSkipReason: (reason) => {
2604
+ runtime.lastSkipReason = reason;
2605
+ },
2606
+ countTick,
2607
+ enqueue: (work) => {
2608
+ st.chain = st.chain.then(work, () => void 0).then(() => void 0, (err) => {
2609
+ console.warn(`[serenity-hooks] ✗ 串行任务异常(${opts.label}): ${String(err?.message ?? err)}`);
2610
+ });
2611
+ },
2612
+ reset: () => {
2613
+ if (st.timer) {
2614
+ clearInterval(st.timer);
2615
+ st.timer = null;
2616
+ }
2617
+ runtime.armed = false;
2618
+ runtime.armedAt = null;
2619
+ runtime.lastTickAt = null;
2620
+ runtime.ticks = 0;
2621
+ runtime.lastSkipReason = null;
2622
+ if (opts.logFrom) runtime.lastTickLog = [];
2623
+ st.wired = false;
2624
+ opts.onReset?.();
2625
+ }
2626
+ };
2627
+ return api;
2628
+ }
2629
+ //#endregion
2630
+ //#region src/wake/scheduler.ts
2631
+ /**
2632
+ * wake/scheduler.ts — 认知轨迹唤醒调度器(D58/D59/D60;S142 2026-09-13)
2633
+ *
2634
+ * 一个**中心调度器**(5min tick,复用 autopilot 的节律常量),处理 CCC 唤醒注册表里
2635
+ * 到期的条目:把「未来时刻 + 一条 message」投递给目标认知轨迹,**fire-and-forget**
2636
+ * (无阻塞、无等待、无回执——发起方不 await,调度器不替模型结果负责)。
2637
+ *
2638
+ * 与 autopilot 的关系(D59):**两个 tick 各自独立**。autopilot 是每 CCC 单例的
2639
+ * 周期自唤醒,逻辑一行不碰;本模块只处理注册表的一次性唤醒条目。
2640
+ *
2641
+ * 投递路径(P-1 证据链见 SESSION.md §30.6):
2642
+ * 1. target(S### / 目录名)→ 目录名 + SESSION.md
2643
+ * 2. 目标 dsh 会话 id:`.bindings.json` 里绑定该目录名的会话(按绑定时间倒序,权威)
2644
+ * → 回退:live 会话标题匹配
2645
+ * 3. agent:**live 优先**(`ctx.agents.get`)→ 冷会话走 `ctx.sessionController.resolveAgent`
2646
+ * (宿主实现:live 优先 + resume 去重 + 由会话元数据恢复 preset)
2647
+ * → `sessionController` 缺席 ⇒ 仅投递 live 目标,**不静默**(v1.39.3:**归为"环境未就绪"**
2648
+ * 而非"投递失败",见 `WakeDeliveryResult.notReady` —— 启动那一拍早于懒服务就绪是常态)
2649
+ * 4. 投递:`agent.followup(...)`("Queue an ordinary follow-up turn and wake the driver")
2650
+ *
2651
+ * 守卫(2026-09-21 起**本钟无闸**):🔴 **恒武装**——原 `wakeSchedulerEnabled` 已砍,
2652
+ * 本钟不再有「可被误关的开关」;旧配置残留该键**静默忽略**,且**不**回退 `autopilotEnabled`|
2653
+ * CRO 阶段另有**独立**总闸 `croEnabled`(只停 CRO,**不影响**本注册表投递)|
2654
+ * 补跑窗口 2h(超窗置 missed 留痕)|同 tick 串行|唤起后维持 live(人类可介入)。
2655
+ */
2656
+ var scheduler_exports = /* @__PURE__ */ __exportAll({
2657
+ buildCroWakeText: () => buildCroWakeText,
2658
+ buildSendText: () => buildSendText,
2659
+ buildWakeText: () => buildWakeText,
2660
+ deliverWake: () => deliverWake,
2661
+ registerWakeScheduler: () => registerWakeScheduler,
2662
+ resolveWakeTarget: () => resolveWakeTarget,
2663
+ sendToCognitiveTrajectory: () => sendToCognitiveTrajectory,
2664
+ wakeSchedulerState: () => wakeSchedulerState
2665
+ });
2666
+ /**
2667
+ * 解析条目的 target(`S###` 或完整目录名)→ 目录名 + SESSION.md 路径。
2668
+ * @param root CCC 根
2669
+ * @param target 条目里的目标标识
2670
+ * @returns 命中返回目录信息,未命中 null
2671
+ */
2672
+ function resolveWakeTarget(root, target) {
2673
+ const found = findSession(sessionsRoot(root), target);
2674
+ if (!found) return null;
2675
+ const mdPath = join(found.path, "SESSION.md");
2676
+ if (!existsSync(mdPath)) return null;
2677
+ return {
2678
+ dirName: basename(found.path),
2679
+ mdPath
2680
+ };
2681
+ }
2682
+ /**
2683
+ * 🔴 **本钟的全局闸已于 2026-09-21 砍掉**(所有者令逐字:「**send-later 的开关不再重要了,砍掉**」)。
2684
+ *
2685
+ * **为什么砍**(R↓):唤醒已从"可选实验能力"长成**全容器的续接绳**(🔵 **术语说明,2026-09-23 补**:
2686
+ * **"绳" = 我们对自己用 `send-later` 给自己排的"定时叫醒"条目的俗称** —— 一条链每轮给自己排下一根,
2687
+ * 链才不断。本仓注释与 SESSION.md 沿用此说法,**它不是产品名、也不是宿主概念**)——S142/S151/S185 的链、
2688
+ * 跨轨迹投递、`send-now`/`send-later` 都走它。留一个"能被误关的开关"的**代价**是
2689
+ * "整条链静默停摆且无人告警"(本仓已实测过同族事故:闸关 6.6h 零 tick、断链 1 天 14 小时);
2690
+ * 而**收益**只剩"关掉一个 5min unref 空检查"。⇒ **收益 < 代价,故删闸**(不是改为缺省开——是**没有这个键了**)。
2691
+ *
2692
+ * ⚠️ **兼容**:旧配置文件里残留的 `wakeSchedulerEnabled` **静默忽略**(无回退键、无告警噪声;
2693
+ * 与旧 `bootstrap` 段的处置先例一致)。**机制本身一行未动**(所有者:"我说的只是开关")。
2694
+ *
2695
+ * 历史(勿误读):v1.34 S-1 曾把两闸解耦(周期自唤醒 `autopilotWakeEnabled` / 本钟 `wakeSchedulerEnabled`),
2696
+ * 理由是"关 auto-trajectory 不得连带关一次性唤醒";随 ACC 侧 autopilot 于 v1.35.0 整段退场,
2697
+ * 两闸只剩本钟一座 ⇒ 现已整座移除。详见 `docs/cognitive-trajectory-scheduling.md` §S-1 与 v1.44 CHANGELOG。
2698
+ */
2699
+ /**
2700
+ * 🔴 **本钟的串行域**(C6b 硬约束):`clock.chain` 是**本实例私有**的——
2701
+ * 与 autopilot 的串行链**零共享**。autopilot 每轮跑 CCC 偏见脚本可能阻塞数十秒,
2702
+ * 共用链会把一次性唤醒一起卡住(这正是 P1 被否、取 P2 的理由,见 SESSION §12.8②)。
2703
+ *
2704
+ * **为什么这里是模块级常量**(而不是 `registerWakeScheduler` 的局部量):本钟的进程态是
2705
+ * **进程级可观测面**(`acc-diag` ①b / `containerClocks` 在读),且 `wakeSchedulerState()` /
2706
+ * `__resetWakeSchedulerStateForTest()` 是**公开导出**、可在未装配时被调用 ⇒ 实例必须**恒在**
2707
+ * (旧实现的 `schedulerRuntime` 也正是模块级常量)。装配只做 `clock.start()`。
2708
+ */
2709
+ /** 本钟的装配项(`ctx` 由 {@link registerWakeScheduler} 在装配点填入) */
2710
+ const clockOpts = {
2711
+ label: "认知轨迹唤醒调度器",
2712
+ ctx: void 0,
2713
+ events: ["session/created", "serenity/settings-changed"],
2714
+ body: () => runWakeTick(clockOpts.ctx),
2715
+ logFrom: {
2716
+ prefix: "[serenity-hooks] 认知轨迹唤醒 ",
2717
+ lines: (lines) => lines
2718
+ },
2719
+ startLog: () => "[serenity-hooks] ✓ 认知轨迹唤醒调度器启动(5min tick)"
2720
+ };
2721
+ const clock = createClock(clockOpts);
2722
+ /** 调度器进程态快照(只读)。
2723
+ * ⚠️ `enabled` 对**本钟已无意义**(2026-09-21 起无闸 ⇒ 恒 `true`);保留字段只为沿用工厂快照形状。
2724
+ * "这轮为什么没被唤起"现在看 `armed` / `lastSkipReason`(tick 内真实原因)+ **CRO 闸**(见 `croEnabled`)。 */
2725
+ function wakeSchedulerState() {
2726
+ return clock.snapshot();
2727
+ }
2728
+ /**
2729
+ * 取目标 agent:live 优先 → 冷会话走 sessionController.resolveAgent → 标题回退。
2730
+ * @param ctx 插件上下文
2731
+ * @param root CCC 根
2732
+ * @param dirName 目标认知轨迹目录名
2733
+ * @returns 命中返回 agent 与取得方式(how 进 lastResult,便于诊断);未命中返回原因
2734
+ * (原因带 `notReady` = **环境未就绪**而非投递失败,调用方**不得**据此记一次失败)
2735
+ *
2736
+ * 🔴 **前置条件:本轨迹名下必须有一条「未取代」的绑定记录**(2026-09-20 回源码答复 S151 实测):
2737
+ * 选人是**遍历 `listBoundSessionIds(root, dirName)` 给出的 id**(按绑定时间倒序),**live 只是这条
2738
+ * 记录的一个加分项**——「绑定里的那条恰好 live ⇒ 直接注入」,否则走冷载入。
2739
+ * ⇒ **「有 live 会话」≠「有绑定」**:会话活着但该轨迹**零条未取代绑定**时,本函数**直接返回
2740
+ * `无绑定会话记录…`**(`send-now` / `send-later` 到点投递同此判据)。
2741
+ * 实证(2026-09-20):S142 三条旧绑定于 **08:03** 被 `supersededAt` 取代(D69「同轨迹只留一条」),
2742
+ * 而当前载体的绑定**直到 17:05 那次 rebuild 才写入**(`queueRebuild` 内的 `appendBound(action:'rebuild')`)
2743
+ * ⇒ **10:42 的 `send-now` 落在这个窗口里** ⇒ 报"无绑定会话记录"(当时确有 live 会话)。
2744
+ * ⚠️ 已知**注释漂移**:本行上方"→ 标题回退"在函数体内**没有对应分支**(标题回退属 v1.29.2 R2,
2745
+ * 现行实现已只认绑定);**仅登记,未改**(改它属 ACC 代码改动,须具名令)。
2746
+ */
2747
+ async function acquireWakeAgent(ctx, root, dirName) {
2748
+ const agents = hostAgents(ctx);
2749
+ const ids = listBoundSessionIds(root, dirName, hostAliveProbe(ctx));
2750
+ for (const id of ids) {
2751
+ const live = agents?.get?.(id);
2752
+ if (live) return {
2753
+ agent: live,
2754
+ how: `live(bound ${id})`
2755
+ };
2756
+ }
2757
+ if (ids.length > 0) {
2758
+ const sc = hostService(ctx, "sessionController");
2759
+ const resolved = sc?.resolveAgent;
2760
+ if (typeof resolved !== "function") return {
2761
+ error: `宿主尚未就绪:目标会话未加载,且 sessionController 服务此刻不可用(重启后的启动窗口,或本进程确实无该服务)——绑定 id: ${ids[0]}`,
2762
+ notReady: true
2763
+ };
2764
+ const id = ids[0];
2765
+ try {
2766
+ const out = await resolved.call(sc, id);
2767
+ if (out?.agent) return {
2768
+ agent: out.agent,
2769
+ how: `cold-resume(${id})`
2770
+ };
2771
+ return { error: `sessionController.resolveAgent 未返回 agent(${id}): ${String(out?.error ?? "未知原因")}` };
2772
+ } catch (err) {
2773
+ return { error: `冷会话载入失败(${id}): ${String(err?.message ?? err)}` };
2774
+ }
2775
+ }
2776
+ return {
2777
+ /**
2778
+ * 🔴 **文案改指真相源(2026-10-23,guard 实测报障)**:原写「`AGENT_SESSIONS/.bindings.json` 中无 X」,
2779
+ * 而 D172 之后绑定真相源 = **`<轨迹目录>/current.dsh_session_bind`**,`.bindings.json`
2780
+ * **已退役且不在盘上**(实测不存在)⇒ 照旧文案排查的人会去翻一个**不存在的文件**。
2781
+ */
2782
+ error: `无绑定会话记录(AGENT_SESSIONS/${dirName}/current.dsh_session_bind 不存在或无有效会话)——目标轨迹需先被 cognitive_trajectory use 激活过,或由 send-now 自动创建载体`,
2783
+ noBinding: true
2784
+ };
2785
+ }
2786
+ /** 唤醒消息(唤起即投递正文:身份锚定 + 唤醒信息 + 任务) */
2787
+ function buildWakeText(entry, target) {
2788
+ return [
2789
+ `[认知轨迹唤醒] 到点(登记于 ${entry.createdAt},发起者 ${entry.createdBy || "未知"},id ${entry.id})。`,
2790
+ `当前时间:${localHuman()}(当地时区)。`,
2791
+ "",
2792
+ `身份锚定:继续 ${target.dirName} 的认知轨迹(SESSION.md: ${target.mdPath})。`,
2793
+ "",
2794
+ "唤醒信息:",
2795
+ entry.message,
2796
+ "",
2797
+ "任务:按上述唤醒信息继续本轨迹的工作;完成后把进展写入 SESSION.md。"
2798
+ ].join("\n");
2799
+ }
2800
+ /**
2801
+ * 投递一条唤醒(fire-and-forget:`followup` 入队即返回,不等模型结果)。
2802
+ * @param ctx 插件上下文
2803
+ * @param root CCC 根
2804
+ * @param entry 唤醒条目
2805
+ * @returns 投递结果(写回 lastResult)
2806
+ */
2807
+ async function deliverWake(ctx, root, entry) {
2808
+ const target = resolveWakeTarget(root, entry.target);
2809
+ if (!target) return {
2810
+ ok: false,
2811
+ detail: `目标认知轨迹未命中(${entry.target})`
2812
+ };
2813
+ const acquired = await acquireWakeAgent(ctx, root, target.dirName);
2814
+ if ("error" in acquired) return {
2815
+ ok: false,
2816
+ detail: acquired.error,
2817
+ notReady: acquired.notReady === true
2818
+ };
2819
+ try {
2820
+ acquired.agent.followup(createUserMessage({
2821
+ content: [{
2822
+ type: "text",
2823
+ text: buildWakeText(entry, target)
2824
+ }],
2825
+ source: PLUGIN_SOURCE
2826
+ }));
2827
+ } catch (err) {
2828
+ return {
2829
+ ok: false,
2830
+ detail: `投递失败(${acquired.how}): ${String(err?.message ?? err)}`
2831
+ };
2832
+ }
2833
+ return {
2834
+ ok: true,
2835
+ detail: `已投递 ${target.dirName}(${acquired.how})`
2836
+ };
2837
+ }
2838
+ /**
2839
+ * 即时消息正文(**刻意不复用** {@link buildWakeText}):唤醒文本以「**到点**(登记于…)」开头,
2840
+ * 对即时投递那是**错的事实**。本函数显式区分"这是即时消息、不是到点唤醒"。
2841
+ *
2842
+ * ⚠️ **v1.39.2 起"已入队"这句话必须删掉**(S142 §0r,所有者令「不排队、直接即时注入」):
2843
+ * 旧文案写"消息已进入你的队列;你此前若正在跑轮次,它在本轮结束后生效"——
2844
+ * 那是**旧的 followup 语义**;现在在跑的目标走 `steer`(当场注入当前轮),
2845
+ * 留着这句话就是**注入一条假陈述**(比没有更坏:目标会误判自己看到它的时机)。
2846
+ * @param target 目标轨迹(目录名 + SESSION.md)
2847
+ * @param message 递送正文
2848
+ * @param sender 发起者轨迹标识(供目标与人类识别来源)
2849
+ */
2850
+ function buildSendText(target, message, sender) {
2851
+ return [
2852
+ `[即时消息] 来自 ${sender || "未知轨迹"}。当前时间:${localHuman()}(当地时区)。`,
2853
+ "",
2854
+ `身份锚定:继续 ${target.dirName} 的认知轨迹(SESSION.md: ${target.mdPath})。`,
2855
+ "",
2856
+ "消息:",
2857
+ message,
2858
+ "",
2859
+ "说明:这是**即时投递**(不是到点唤醒)——它**不排队**:目标正在跑轮次就**当场注入当前轮**,目标空闲则立即起一轮。处理完照常把进展写入 SESSION.md。"
2860
+ ].join("\n");
2861
+ }
2862
+ /**
2863
+ * 即时投递(`cognitive_trajectory send-now`)——与 `send-later`(预约)**共用取用通路**(`acquireWakeAgent`),
2864
+ * 区别只在**时刻**(现在 vs 未来)与**回执**(有 vs 无):
2865
+ * · `send-later`(原 `wake-later`):未来时刻 + 一条 message → 落注册表 → 本文件的调度器到点投递(**fire-and-forget,无回执**)
2866
+ * · `send-now`(原 `send-message`):**此刻**把一条 message 递给目标 → **同步回执** → **不落注册表**
2867
+ *
2868
+ * **为什么冷路径落在这里(R↓,2026-09-16 所有者裁决)**:`addWake` 显式拒绝 `at ≤ now`
2869
+ * ("时刻必须在未来"——那是"**预约**"语义的地基,不许放宽)⇒ 即时投递**不能**靠一条
2870
+ * `at=now` 的注册表条目实现(那还会让调度器把一次性即时投递当 `pending` **每 tick 重试**)。
2871
+ * 必须直接走 {@link acquireWakeAgent}(live 优先 → `sessionController.resolveAgent` 冷载入)。
2872
+ * 这正对应所有者要的「**会话不活跃则等效于直接 wake**」:**同一条冷载入通路**,只是**不等 tick**。
2873
+ *
2874
+ * ⚠️ **承诺边界(不许含糊)**:回执只到「**已注入 / 已起轮**」(宿主调用未抛错),
2875
+ * **到不了**"目标已执行/已答复" —— 与 `deliverWake` 同一层。
2876
+ * 判"目标真的动了"必须用**文件级判据**(它自己的 SESSION.md),
2877
+ * **不得凭回执结案**(§12.B 的 `delivered ≠ 跑了一轮`)。
2878
+ *
2879
+ * 🔴 **v1.39.2 语义变更(S142 §0r,所有者 2026-09-17 令:「希望 send message 时不排队,
2880
+ * 可以直接作为提示词即时注入进去」)**:
2881
+ * 旧实现一律 `followup`(宿主注释:*Queue an ordinary follow-up turn*)⇒ 目标**正在跑轮次**时
2882
+ * 消息**排在轮次边界**(即旧文案的"不打断当前轮")。现按宿主 `agent.status` 分流:
2883
+ * · `status === 'idle'` ⇒ `followup`(宿主**同步**把 status 置 running ⇒ **立即起轮**,本就不排队)
2884
+ * · 否则(在跑) ⇒ **`steer`** —— **当场注入当前轮**,这才是"不排队"的关键路径
2885
+ * ⚠️ **fail-safe**:`steer` 抛错 ⇒ **退回 `followup`**:**宁可排队,不可静默丢消息**。
2886
+ * ⚠️ **只改 `send-now`**:`send-later`({@link deliverWake})**保持 `followup`** ——
2887
+ * 预约的本职是"到点唤起",且在跑时排队不打断是它被实证验收过的行为,不顺手动它。
2888
+ *
2889
+ * **归属**:本函数放在调度器侧而非工具侧 —— "怎么把一句话送到一条轨迹"是**调度器的知识**,
2890
+ * 工具只该知道"有这个动作"。这样 `cognitive-trajectory.ts` 完全不碰调度器内部(v1.35.0 硬约束①:
2891
+ * `send-later` 链路**一行为不动**,本次改动 = **纯新增**)。
2892
+ *
2893
+ * @param ctx 插件上下文
2894
+ * @param root CCC 根
2895
+ * @param target `S###` 或 AGENT_SESSIONS 目录名
2896
+ * @param message 递送正文
2897
+ * @param sender 发起者轨迹标识
2898
+ * @returns 投递结果(**同步**返回给调用方)
2899
+ */
2900
+ async function sendToCognitiveTrajectory(ctx, root, target, message, sender) {
2901
+ const t = resolveWakeTarget(root, target);
2902
+ if (!t) return {
2903
+ ok: false,
2904
+ detail: `目标认知轨迹未命中(${target})`
2905
+ };
2906
+ let acquired = await acquireWakeAgent(ctx, root, t.dirName);
2907
+ let createdNote = "";
2908
+ if ("error" in acquired) {
2909
+ if (!acquired.notReady && acquired.noBinding === true) {
2910
+ const carrier = carrierState(root, t.dirName, hostAliveProbe(ctx));
2911
+ if (carrier.state !== "never") {
2912
+ const why = carrier.state === "occupied" ? `该轨迹曾经由会话 ${carrier.sessionId} 承载,而它此刻不在了` : `该轨迹当前由会话 ${carrier.sessionId} 承载`;
2913
+ return {
2914
+ ok: false,
2915
+ detail: `${t.dirName}:${why} ⇒ **拒绝自动新建载体**(一条轨迹至多一个载体)。\n · 要让它重新跑起来:把那个会话恢复,或用 cognitive_trajectory rebuild 换代(显式 takeover)。\n · 不明所以就先 cognitive_trajectory show ${t.dirName} 看它的交代文件。`
2916
+ };
2917
+ }
2918
+ acquired = await createAndBindTarget(ctx, root, t.dirName);
2919
+ if ("error" in acquired) return {
2920
+ ok: false,
2921
+ detail: acquired.error
2922
+ };
2923
+ createdNote = `;目标原本无活跃 dsh 会话 ⇒ **已自动创建并绑定**一条(${acquired.how})`;
2924
+ } else return {
2925
+ ok: false,
2926
+ detail: acquired.error
2927
+ };
2928
+ }
2929
+ const agent = acquired.agent;
2930
+ const make = () => createUserMessage({
2931
+ content: [{
2932
+ type: "text",
2933
+ text: buildSendText(t, message, sender)
2934
+ }],
2935
+ source: PLUGIN_SOURCE
2936
+ });
2937
+ const freshCarrier = createdNote !== "";
2938
+ if (freshCarrier || agent.status === "idle") {
2939
+ try {
2940
+ agent.followup(make());
2941
+ } catch (err) {
2942
+ return {
2943
+ ok: false,
2944
+ detail: `投递失败(${acquired.how}): ${String(err?.message ?? err)}`
2945
+ };
2946
+ }
2947
+ return {
2948
+ ok: true,
2949
+ detail: `已即时投递 ${t.dirName}(${acquired.how};${freshCarrier ? "刚建的载体 ⇒ followup 排队,锚定先于任务" : "目标空闲 ⇒ followup 立即起轮"})${createdNote}`
2950
+ };
2951
+ }
2952
+ try {
2953
+ agent.steer(make());
2954
+ return {
2955
+ ok: true,
2956
+ detail: `已即时注入 ${t.dirName}(${acquired.how};目标在跑 ⇒ steer 当场注入当前轮)${createdNote}`
2957
+ };
2958
+ } catch (err) {
2959
+ try {
2960
+ agent.followup(make());
2961
+ } catch (err2) {
2962
+ return {
2963
+ ok: false,
2964
+ detail: `投递失败(${acquired.how}): steer 与 followup 均失败: ${String(err?.message ?? err)} / ${String(err2?.message ?? err2)}`
2965
+ };
2966
+ }
2967
+ return {
2968
+ ok: true,
2969
+ detail: `已投递 ${t.dirName}(${acquired.how};steer 不可用 ⇒ 退回 followup): ${String(err?.message ?? err)}${createdNote}`
2970
+ };
2971
+ }
2972
+ }
2973
+ /**
2974
+ * 🆕 **给一条「从未被激活过」的轨迹就地建一条 dsh 会话并绑定它**(2026-10-16 owner 令,S142)。
2975
+ *
2976
+ * 何时用:**只有** `send-now` 在 `acquireWakeAgent` 报「无绑定会话记录」时(见该处的判据说明)。
2977
+ *
2978
+ * ## 🔴 2026-10-18 根治:改成**和用户操作完全同一个调用形态**
2979
+ *
2980
+ * owner 原话:「应当和用户操作唤起的会话完全一样才行」。
2981
+ *
2982
+ * 上一版(v1.62.1)传的是 `{ cwd: root }`,然后**事后补** `attachSession()` 去挂工作区。
2983
+ * 那是**补救**,不是"一样" —— 宿主里那条路径(`sessionController.create` 的
2984
+ * `if (workspace !== void 0) await workspace.attachSession(sessionId)`)**只在 `workspaceId` 分支执行**:
2985
+ *
2986
+ * ```js
2987
+ * if (request.workspaceId !== void 0) workspace = registry.get(request.workspaceId)
2988
+ * const cwd = workspace?.path ?? request.cwd ?? this.defaultCwd
2989
+ * ...
2990
+ * if (workspace !== void 0) await workspace.attachSession(sessionId) // ← 只有这条分支
2991
+ * ```
2992
+ *
2993
+ * 而**用户在 UI 里新建会话传的正是 `workspaceId`**(见 `client/sessions/service.d.ts` 的
2994
+ * `create({ workspaceId?, cwd?, ... })`)⇒ 用户那条路天然会走到 `attachSession`。
2995
+ *
2996
+ * ⇒ 现在与用户**同一条分支**:先由 CCC 根查出 `workspaceId`,再用 `{ workspaceId }` 建。
2997
+ * **`attachToWorkspace` 降级为兜底**(只在"该目录还没有工作区登记"时才会用上),
2998
+ * 不再是主路径 —— 因为主路径已经由宿主自己做完。
2999
+ *
3000
+ * ## 三步(顺序不可换)
3001
+ * ① `sessionController.create({ workspaceId })` —— 与用户同一形态;宿主替我们挂工作区。
3002
+ * **idempotently adopt** ⇒ 重复调用不会建出第二条(不必自造"先查再建"的竞态窗口)。
3003
+ * ② `resolveAgent(sessionId)` —— 拿到**真 agent**。`create` 只返回 id,不给 agent。
3004
+ * ③ `appendBound(...)` —— 落绑定。**必须由本函数写**:`listBoundSessionIds` 是
3005
+ * `acquireWakeAgent` 的唯一选人依据,不写绑定则下一条消息**还是**找不到它
3006
+ * (那就成了"建了但永远用不上",比不建更坏)。
3007
+ *
3008
+ * 🔴 **为什么定位到 CCC 根这一级、不是轨迹目录**:会话的 cwd 决定它落在哪个 CCC
3009
+ * (`cccRootForCwd` 上溯 `.serenity`)。目标轨迹本就住在该 CCC 的 `AGENT_SESSIONS/` 下
3010
+ * ⇒ 落到 CCC 根才让它**进同一个 CCC**;落到轨迹目录会让它变成一个**不同 CCC**的会话。
3011
+ *
3012
+ * @returns 命中返回 agent 与取得方式;任一步失败返回**带原因**的 error(不静默、不误报成功)
3013
+ */
3014
+ async function createAndBindTarget(ctx, root, dirName) {
3015
+ const sc = hostService(ctx, "sessionController");
3016
+ const create = sc?.create;
3017
+ if (typeof create !== "function") return {
3018
+ error: "目标无活跃 dsh 会话,且 sessionController.create 不可用(绑定 id: 无)——无法就地创建载体;请先对该轨迹执行一次 cognitive_trajectory use",
3019
+ notReady: true
3020
+ };
3021
+ const ws = findWorkspaceByPath(listWorkspaces(ctx) ?? [], root);
3022
+ const workspaceId = typeof ws?.id === "string" && ws.id !== "" ? ws.id : void 0;
3023
+ let sessionId = "";
3024
+ try {
3025
+ const out = await create.call(sc, workspaceId !== void 0 ? { workspaceId } : { cwd: root });
3026
+ sessionId = String(out?.sessionId ?? "");
3027
+ } catch (err) {
3028
+ return { error: `就地创建会话失败(${dirName}): ${String(err?.message ?? err)}` };
3029
+ }
3030
+ if (!sessionId) return { error: `就地创建会话未返回 sessionId(${dirName})` };
3031
+ const resolved = sc?.resolveAgent;
3032
+ if (typeof resolved !== "function") return {
3033
+ error: `会话已建(${sessionId})但 sessionController.resolveAgent 不可用`,
3034
+ notReady: true
3035
+ };
3036
+ let agent;
3037
+ try {
3038
+ const out = await resolved.call(sc, sessionId);
3039
+ agent = out?.agent;
3040
+ if (!agent) return { error: `会话已建(${sessionId})但 resolveAgent 未返回 agent: ${String(out?.error ?? "未知原因")}` };
3041
+ } catch (err) {
3042
+ return { error: `会话已建(${sessionId})但载入失败: ${String(err?.message ?? err)}` };
3043
+ }
3044
+ const bound = appendBound({ header: {
3045
+ id: sessionId,
3046
+ cwd: root
3047
+ } }, "activate", {
3048
+ dirName,
3049
+ mdPath: join(root, "AGENT_SESSIONS", dirName, "SESSION.md"),
3050
+ note: "auto-created by send-now",
3051
+ createdAsUser: true
3052
+ });
3053
+ const openingNote = deliverCarrierOpening(agent, buildCarrierOpening(parseTrajectoryCode(dirName) ?? dirName, trajectoryDescOf(dirName))) ? ", opening-as-user" : ", ⚠️ 开场白未投递";
3054
+ const titleNote = renameCarrierTitle(ctx, agent, root, dirName);
3055
+ const wsNote = workspaceId !== void 0 ? "" : await attachToWorkspace(ctx, root, sessionId);
3056
+ const viaNote = workspaceId !== void 0 ? ", via-workspaceId(=用户同一形态)" : ", via-cwd+attach";
3057
+ const how = `auto-create(${sessionId}${bound.ok ? ", bound" : `, ⚠️ 绑定写入失败(${bound.code})`}${viaNote}${openingNote}${titleNote}${wsNote})`;
3058
+ return {
3059
+ agent,
3060
+ how
3061
+ };
3062
+ }
3063
+ /**
3064
+ * 把刚建的载体会话标题**钉成与 `use` 同一形态**(2026-10-21,owner 报「标题不规范」)。
3065
+ *
3066
+ * ## 为什么单独成函数
3067
+ *
3068
+ * 它是**全仓第二个**写会话标题的点(第一个是 `use`/`rebuild` 的 `renameDshSessionOnUse`)。
3069
+ * 集中在一处,将来若要改命名规则,判据仍在 `namingTitleFor` 一个地方。
3070
+ *
3071
+ * ## 三者顺序不可换
3072
+ *
3073
+ * 必须在 ③b(投开场白)**之后**调用:
3074
+ * - 开场白让 `titleInput.count` > 0,是标题得以成立的前提;
3075
+ * - 放在之后还有**降级好处**:万一 `rename` 失败,宿主自己的 fallback 标题仍在
3076
+ * (难看但存在),而不是两者皆无。
3077
+ *
3078
+ * @returns 回执片段(成功 `, titled` / 失败 `, ⚠️ 标题未写(原因)`)—— **必须出声**,
3079
+ * 否则"标题没写"会表现成"这条轨迹就是这名字"(D186:守卫可以拒绝,但不许不出声)。
3080
+ */
3081
+ function renameCarrierTitle(ctx, agent, root, dirName) {
3082
+ try {
3083
+ const titles = hostService(ctx, "sessionTitle");
3084
+ if (!titles || typeof titles.rename !== "function") return ", ⚠️ 标题未写(sessionTitle 不可用)";
3085
+ const session = agent.session;
3086
+ if (!session || typeof session !== "object") return ", ⚠️ 标题未写(无 session)";
3087
+ const title = namingTitleFor({
3088
+ sessionId: parseTrajectoryCode(dirName) ?? dirName,
3089
+ dirName,
3090
+ mdPath: join(root, "AGENT_SESSIONS", dirName, "SESSION.md")
3091
+ });
3092
+ titles.rename(session, title);
3093
+ return ", titled";
3094
+ } catch (err) {
3095
+ return `, ⚠️ 标题未写(${String(err?.message ?? err)})`;
3096
+ }
3097
+ }
3098
+ /**
3099
+ * 把刚建的会话挂进 **cwd 所属的那个工作区**(2026-10-17 缺陷修复)。
3100
+ *
3101
+ * 🔴 **为什么必须我们自己做(宿主源码取证,不是推断)**:
3102
+ * `sessionController.create()` 里 `attachSession` **只在 `workspaceId` 分支执行** ——
3103
+ * `dsh-api-session-controller/lib/index.js` 的 `create()`:
3104
+ * ```
3105
+ * if (request.workspaceId !== void 0) workspace = registry.get(request.workspaceId)
3106
+ * const cwd = workspace?.path ?? request.cwd ?? this.defaultCwd
3107
+ * ...
3108
+ * if (workspace !== void 0) await workspace.attachSession(sessionId) // ← 只有这条分支挂工作区
3109
+ * ```
3110
+ * 我们传的是 `{ cwd }`(**必须如此**:`workspaceId` 与 `cwd` 互斥,而工作区 id 是宿主的内部 uuid,
3111
+ * 我们又得按"这条轨迹住在哪个 CCC"来建会话)⇒ **建出来的会话从不登记进任何工作区**。
3112
+ *
3113
+ * 🔴 **症状(现场实证,本机真实数据)**:`send-now` 自动建出的那条会话
3114
+ * (`note` = `auto-created by send-now`)header `cwd` 确实是某个 CCC 根,
3115
+ * **却不在**该工作区的 `sessionIds` 里
3116
+ * (同一个 CCC 的另外两条会话都在)⇒ WebUI 里它不属于那个工作区。
3117
+ *
3118
+ * 🔴 **为什么按 `path` 找而不是"拿第一个"**:多个 CCC 并存(本机实有 5 个工作区)
3119
+ * ⇒ 拿第一个必然挂错。判据 = `workspace.path === root`(宿主 `Workspace.path` 是
3120
+ * **realpath 规范化**后的规范路径;`root` 已是 `cccRootForCwd` 上溯出来的真实目录,
3121
+ * 两侧同一来源,故可直接比)。
3122
+ *
3123
+ * ⚠️ **失败不阻断投递**:工作区归属影响的是"在 WebUI 里归哪个工作区",
3124
+ * 而消息投递已经完成 ⇒ 把原因**如实写进回执**,不吞、也不把成功改成失败。
3125
+ *
3126
+ * @param ctx 插件上下文
3127
+ * @param root CCC 根(= 该会话的 cwd)
3128
+ * @param sessionId 刚建出来的会话 id
3129
+ * @returns 追加到回执里的短注记(成功/跳过为空串,失败带原因)
3130
+ */
3131
+ async function attachToWorkspace(ctx, root, sessionId) {
3132
+ const workspaces = listWorkspaces(ctx);
3133
+ if (workspaces === void 0) return ", ⚠️ 工作区未挂载(workspaceRegistry 不可用)";
3134
+ const match = findWorkspaceByPath(workspaces, root);
3135
+ if (match === void 0) return "";
3136
+ if (typeof match.attachSession !== "function") return ", ⚠️ 工作区未挂载(attachSession 不可用)";
3137
+ if (Array.isArray(match.sessionIds) && match.sessionIds.includes(sessionId)) return "";
3138
+ try {
3139
+ await match.attachSession(sessionId);
3140
+ return ", workspace-attached";
3141
+ } catch (err) {
3142
+ return `, ⚠️ 工作区挂载失败: ${String(err?.message ?? err)}`;
3143
+ }
3144
+ }
3145
+ /** `workspaceRegistry.list()` 的读取收口(不可用返回 `undefined`,**与"空列表"区分**) */
3146
+ function listWorkspaces(ctx) {
3147
+ const reg = hostService(ctx, "workspaceRegistry");
3148
+ if (typeof reg?.list !== "function") return void 0;
3149
+ try {
3150
+ return reg.list() ?? [];
3151
+ } catch {
3152
+ return;
3153
+ }
3154
+ }
3155
+ /**
3156
+ * 在 `workspaceRegistry.list()` 里找 **path 等于该 CCC 根** 的那一个。
3157
+ *
3158
+ * 🔴 **为什么要 realpath 归一(不是画蛇添足)**:宿主的 `Workspace.path` 是
3159
+ * **`fs.realpath` 规范化**过的(`entity.d.ts` 逐字:*"Canonical directory path: the
3160
+ * `fs.realpath` of the path given at create time (trailing slashes, `..`, and symlinks
3161
+ * all resolved)"*)。而 `root` 来自 `cccRootForCwd` 的上溯——**上溯用的是字面路径**。
3162
+ * ⇒ 当 CCC 根是一个**符号链接**(部署上常见:家目录/项目目录被链到别处),
3163
+ * 两侧**逐字不等**,只比字符串会**挂错或挂不上**。
3164
+ *
3165
+ * ⚠️ 归一失败(路径不存在等)时**回落字面比较** —— 不改既有语义,只是多给一次机会。
3166
+ *
3167
+ * @param workspaces registry.list() 的返回
3168
+ * @param root CCC 根
3169
+ * @returns 命中的工作区,无则 undefined
3170
+ */
3171
+ function findWorkspaceByPath(workspaces, root) {
3172
+ const direct = workspaces.find((ws) => ws?.path === root);
3173
+ if (direct !== void 0) return direct;
3174
+ const canonical = (p) => {
3175
+ try {
3176
+ return realpathSync(p);
3177
+ } catch {
3178
+ return p;
3179
+ }
3180
+ };
3181
+ const want = canonical(root);
3182
+ return workspaces.find((ws) => typeof ws?.path === "string" && canonical(ws.path) === want);
3183
+ }
3184
+ /**
3185
+ * 本 tick 要扫的 CCC 根集合(**并集**)。
3186
+ *
3187
+ * C2(S142 2026-09-15 Q5):原实现是 `collectLiveCccs`——**只认 live 会话** ⇒
3188
+ * "完全冷掉的 CCC 不能自唤醒自己"(已知边界)。现改为 `ccc-roots.listCccs` 的并集枚举
3189
+ * (工作区注册表 ∪ 持久化会话 ∪ live 会话):**没有 live 会话的 CCC 也会被扫到**。
3190
+ *
3191
+ * ⚠️ 边界(本步**只**改"枚举哪些 CCC",**不改**"怎么找到要交付的 agent"):
3192
+ * 把 message 交给某条认知轨迹仍必须靠 **live 会话**解析(见本文件
3193
+ * `acquireWakeAgent`);无 live 会话时该 CCC 只是**被扫到、
3194
+ * 无交付对象** ⇒ `deliverWake` 返回 `无绑定会话记录…`,条目回 `pending` 并留痕。
3195
+ * 因此本函数**不得**让 tick 抛错(异常一律由调用方 tick 兜住,见 `runWakeTick` 的 try)。
3196
+ * @param ctx 插件上下文
3197
+ * @returns CCC 根列表(无任何来源 → `[]`)
3198
+ */
3199
+ async function collectWakeCccs(ctx) {
3200
+ return (await listCccs(ctx)).map((e) => e.root);
3201
+ }
3202
+ /**
3203
+ * CRO 唤起消息正文(**刻意不复用** {@link buildWakeText} / {@link buildSendText})。
3204
+ *
3205
+ * 三种唤起各有自己的**事实**;混用 = 注入一条假陈述(本容器栽过:`buildSendText` 里
3206
+ * 那句"消息已入队"就是一条假陈述,见该函数注释):
3207
+ * · `buildWakeText` —— 「**到点**了」(登记过的未来时刻,人/agent 算好的)
3208
+ * · `buildSendText` —— 「**有人此刻给我发了一条消息**」
3209
+ * · 本函数 —— 「**我自己的程序**看情况判定该叫我了」(**没有到点、也没有人发消息**)
3210
+ *
3211
+ * 🔴 `reason` 的呈现(设计 §4.2):**程序没写就明说没写**,不留空白 ——
3212
+ * 改成程序判定后,"当时为什么叫了"**不再能从时间表重建**(原因在程序肚子里);
3213
+ * 留空白 = 连"丢在哪一环"都看不出来。写明"未提供"至少把缺口标出来了(CCE:重建 > 保存)。
3214
+ * @param dirName 目标轨迹目录名
3215
+ * @param mdPath 目标 SESSION.md 绝对路径
3216
+ * @param prompt 程序给的唤起提示词
3217
+ * @param reason 程序给的判定理由(可为 null)
3218
+ * @returns 投递正文
3219
+ */
3220
+ function buildCroWakeText(dirName, mdPath, prompt, reason) {
3221
+ return [
3222
+ `[CRO 唤起] ${dirName} 自带的 continuous-re-occurrence.ts 判定:现在该唤起。`,
3223
+ `当前时间:${localHuman()}(当地时区)。`,
3224
+ "",
3225
+ `身份锚定:继续 ${dirName} 的认知轨迹(SESSION.md: ${mdPath})。`,
3226
+ "",
3227
+ `判定理由:${reason ?? "(程序未提供 reason —— 见 CRO 编写指南 §7 坑 3)"}`,
3228
+ "",
3229
+ "唤起提示词:",
3230
+ prompt,
3231
+ "",
3232
+ "说明:这是由**本轨迹自己的 CRO 程序**按当时情况做的判定(**不是**定时唤醒,**也不是**别人发的消息)。处理完照常把进展写入 SESSION.md。"
3233
+ ].join("\n");
3234
+ }
3235
+ /**
3236
+ * 投递一条 CRO 唤起(**fire-and-forget**,与 {@link deliverWake} 同层)。
3237
+ *
3238
+ * · **取用通路复用** {@link acquireWakeAgent}(live 优先 → 冷载入):这是调度器侧唯一的
3239
+ * "怎么把一句话送到一条轨迹"的知识 ⇒ 不另立第二条(设计 §9-2 的倾向:复用投递面)。
3240
+ * · 🔴 **不落唤醒表**(设计 §6.3):CRO 是"**持续判定**",不是"**某个未来时刻**"。
3241
+ * 落表会给 §0Q(投递即删、表只保在办)**增加一个新的写入源**。
3242
+ * · 用 `followup`(不用 `steer`):CRO 由**调度器**发起,与 `send-later` 同层;
3243
+ * `steer`(当场注入当前轮)是 `send-now` 的语义 —— 那是人类/agent 的**即时动作**。
3244
+ * · 🔴 **失败不回滚、不重试**(与条目投递不同):下一 tick 会**重新判定**——
3245
+ * CRO 的判据是"情况",不是"这条消息还没送出去" ⇒ 自愈来自**重判**,不来自重投。
3246
+ * @param ctx 插件上下文
3247
+ * @param root CCC 根
3248
+ * @param dirName 目标轨迹目录名(= CRO 只能唤起**自己**,设计 §9-8 的倾向)
3249
+ * @param prompt 唤起提示词
3250
+ * @param reason 判定理由
3251
+ * @returns 投递结果(进 tick 日志;**不写注册表**)
3252
+ */
3253
+ async function deliverCroWake(ctx, root, dirName, prompt, reason) {
3254
+ const mdPath = join(sessionsRoot(root), dirName, "SESSION.md");
3255
+ let acquired = await acquireWakeAgent(ctx, root, dirName);
3256
+ /**
3257
+ * 🔴 **载体自举(2026-10-23,guard 实测报障后补)**
3258
+ *
3259
+ * ## 缺陷(真实代价)
3260
+ *
3261
+ * 一条「**生来就是为了被自己的 CRO 叫醒**」的轨迹,**第一次自唤醒在物理上不可能**:
3262
+ * 它是程序化 `create` 出来的 ⇒ 没有载体 ⇒ `noBinding` ⇒ 投递被拒
3263
+ * ⇒ **恰恰在最需要被叫醒的那一刻没有载体可注入**。实测证据:
3264
+ * `cro-wake-log.log` 首行 `fail ... 无绑定会话记录`,而**那一轮的 CRO 判定完全正确**。
3265
+ *
3266
+ * ## 为什么可以自举,以及它与 `send-now` 的分支差在哪
3267
+ *
3268
+ * `send-now` 的自举理由是「完成一次**人/agent 的**意图」;CRO 是**每 5min 自动重判**,
3269
+ * 若照搬会有「**载体爆炸**」风险。故本路径**多一道闸**:
3270
+ *
3271
+ * - 只认 `carrierState === 'never'`(**从未有过载体**)⇒ 建一次;
3272
+ * - `occupied`(有过但那个会话不在了)⇒ **一律不建**,如实报错。
3273
+ *
3274
+ * ⇒ 自举**至多发生一次**(建完就有 `current.dsh_session_bind`,此后 `carrierState` 不再是 `never`)。
3275
+ * 这正是 `carrierState` 那道守卫的语义,与 `send-now` 共用同一条判据(不另立)。
3276
+ *
3277
+ * ⚠️ 只处理 `noBinding`;`notReady`(启动窗口)与冷载入失败**照原样返回** ——
3278
+ * 那些不是"没载体",建新载体解决不了且可能制造第二条。
3279
+ */
3280
+ if ("error" in acquired && acquired.noBinding === true) {
3281
+ const carrier = carrierState(root, dirName, hostAliveProbe(ctx));
3282
+ if (carrier.state === "never") {
3283
+ const created = await createAndBindTarget(ctx, root, dirName);
3284
+ if (!("error" in created)) {
3285
+ acquired = created;
3286
+ console.log(`[serenity-hooks] CRO 载体自举:${dirName} 从未有过载体 ⇒ 已自动创建并绑定(${created.how})`);
3287
+ }
3288
+ } else
3289
+ /**
3290
+ * 🔴 **拒绝自举时必须指名是哪一半**(D186:守卫可以拒绝,但不许不出声)。
3291
+ *
3292
+ * 若照原样把 `acquireWakeAgent` 的 `noBinding` 文案透传出去,`cro-wake-log` 里
3293
+ * 「`current.dsh_session_bind` 不存在或无有效会话」**两种状态长得一模一样**:
3294
+ * · `never` —— 那条轨迹**生来就没有载体**(本次已自举,不会走到这里);
3295
+ * · `occupied` —— **有过载体、那个会话现在不在了**(本分支拒绝自建)。
3296
+ * ⇒ 读日志的人分不出"该去救那个会话"还是"该去查为什么没建起来",
3297
+ * 而这正是本仓反复付代价的那类缺陷(D131:不靠一句话给状态命名,但**状态不同必须说出来**)。
3298
+ */
3299
+ acquired = { error: carrier.state === "occupied" ? `CRO 唤起未投递:${dirName} 曾经由会话 ${carrier.sessionId} 承载,而它此刻不在了 ⇒ **拒绝自动新建载体**(一条轨迹至多一个载体,而 CRO 每 5min 重判一次,自建会让载体反复膨胀)。\n · 要恢复:把那个会话救回来,或用 cognitive_trajectory rebuild 显式换代(takeover)。` : `CRO 唤起未投递:${dirName} 的载体声明指向会话 ${carrier.sessionId},它按探针**仍可用**,而选人路径却报"无绑定" ⇒ **两处读数打架,不新建载体**(先查绑定文件与归档集)。` };
3300
+ }
3301
+ if ("error" in acquired) return {
3302
+ ok: false,
3303
+ detail: acquired.error,
3304
+ notReady: acquired.notReady === true
3305
+ };
3306
+ try {
3307
+ acquired.agent.followup(createUserMessage({
3308
+ content: [{
3309
+ type: "text",
3310
+ text: buildCroWakeText(dirName, mdPath, prompt, reason)
3311
+ }],
3312
+ source: PLUGIN_SOURCE
3313
+ }));
3314
+ } catch (err) {
3315
+ return {
3316
+ ok: false,
3317
+ detail: `CRO 投递失败(${acquired.how}): ${String(err?.message ?? err)}`
3318
+ };
3319
+ }
3320
+ return {
3321
+ ok: true,
3322
+ detail: `已投递(${acquired.how})`
3323
+ };
3324
+ }
3325
+ /**
3326
+ * CRO 阶段:本 tick 对**每条启用了 CRO 的轨迹**跑一次它自带的程序,按判定投递。
3327
+ *
3328
+ * ## 为什么放在既有投递**之后**(R↓)
3329
+ * · 既有条目(`send-later`)**有到点承诺** ⇒ 优先级天然更高;
3330
+ * · CRO 是"看着情况叫",**晚一拍无成本**(tick 粒度本就是 5min);
3331
+ * · ⇒ 铁律(设计 §5):**CRO 的任何失败都不影响既有链路** —— 整段由调用方兜住,
3332
+ * 且**每条轨迹各自 try/catch**(一条坏程序不得连累同 tick 的其它轨迹)。
3333
+ *
3334
+ * ## 日志口径(刻意与既有投递不同)
3335
+ * "每 tick × 每条轨迹"都写一行 ⇒ 对"**常态是不唤起**"的 CRO 就是刷屏。
3336
+ * ⇒ 逐条只记 **唤起**(`reason` 进日志 —— 设计 §4.2 要求可重建)与 **跳过**(异常,必须看见);
3337
+ * **不唤起只进汇总行**(`唤起 x,不唤起 y,跳过 z`)。
3338
+ * ⚠️ **诚实标注**:因此"某条轨迹当轮**为何没叫**"**不进 tick 日志**(它在程序肚子里)。
3339
+ * 要完整的逐次判定审计,须**程序自己**写状态文件(设计 §3.3)——ACC 给不了。
3340
+ * @param ctx 插件上下文
3341
+ * @param root CCC 根
3342
+ * @param registry 本 tick 已载入的唤醒注册表条目(供快照的 `pendingWakes`;读坏时传 `[]`)
3343
+ * @param log 本 tick 的人读摘要(就地追加)
3344
+ */
3345
+ async function runCroPhase(ctx, root, registry, log) {
3346
+ if (readSimpleSettings().croEnabled === false) {
3347
+ log.push("CRO 阶段:已关闭(croEnabled=false)——既有唤醒投递不受影响");
3348
+ return;
3349
+ }
3350
+ let dirs;
3351
+ try {
3352
+ dirs = listCroCognitiveTrajectories(root);
3353
+ } catch {
3354
+ return;
3355
+ }
3356
+ if (dirs.length === 0) return;
3357
+ const nowMs = Date.now();
3358
+ const s = wakeSchedulerState();
3359
+ const scheduler = {
3360
+ armed: s.armed,
3361
+ enabled: s.enabled,
3362
+ ticks: s.ticks,
3363
+ lastSkipReason: s.lastSkipReason
3364
+ };
3365
+ const agents = hostAgents(ctx);
3366
+ const selfHost = safeHostname();
3367
+ let hostSkips = 0;
3368
+ let wakes = 0;
3369
+ let quiet = 0;
3370
+ let skips = 0;
3371
+ for (const dirName of dirs) try {
3372
+ const verdict = resolveCroHostBinding(root, dirName, selfHost);
3373
+ if (!verdict.allowed) {
3374
+ hostSkips += 1;
3375
+ log.push(`· ${root}: ${dirName}: CRO 机器绑定未通过(${verdict.code}):${verdict.detail}`);
3376
+ continue;
3377
+ }
3378
+ const bound = listBoundSessionIds(root, dirName, hostAliveProbe(ctx));
3379
+ const running = runningCarriersOf(bound, nowMs);
3380
+ const outcome = await evaluateCro(root, dirName, readCroSnapshotInput(root, dirName, nowMs, {
3381
+ boundSessionIds: bound,
3382
+ liveSessionIds: bound.filter((id) => Boolean(agents?.get?.(id))),
3383
+ runningSessionIds: running,
3384
+ pendingWakes: registry.filter((e) => e.state === "pending" && resolveWakeTarget(root, e.target)?.dirName === dirName).map((e) => ({
3385
+ id: e.id,
3386
+ at: e.at,
3387
+ createdAt: e.createdAt,
3388
+ createdBy: e.createdBy
3389
+ })),
3390
+ scheduler
3391
+ }));
3392
+ if (outcome.status === "wake") {
3393
+ wakes += 1;
3394
+ const res = await deliverCroWake(ctx, root, dirName, outcome.prompt, outcome.decision.reason);
3395
+ log.push(`· ${root}: ${renderCroOutcome(dirName, outcome)} → ${res.ok ? "已投递" : `投递未成功:${res.detail}`}`);
3396
+ {
3397
+ const rec = appendCroWakeLog(root, dirName, {
3398
+ ok: res.ok,
3399
+ ...res.notReady === true ? { notReady: true } : {},
3400
+ detail: res.detail,
3401
+ tick: s.ticks,
3402
+ reason: outcome.decision.reason ?? null,
3403
+ prompt: outcome.prompt
3404
+ }, nowMs);
3405
+ if (!rec.ok) log.push(`· ${root}: ${dirName}: CRO 流水写入失败(已忽略,不影响投递): ${rec.error}`);
3406
+ }
3407
+ } else if (outcome.status === "skipped") {
3408
+ skips += 1;
3409
+ log.push(`· ${root}: ${renderCroOutcome(dirName, outcome)}`);
3410
+ } else if (outcome.status === "no-wake") {
3411
+ quiet += 1;
3412
+ /**
3413
+ * 🔴 **2026-10-23(A102):`no-wake` 也落盘。**
3414
+ *
3415
+ * 修前这里**只累加计数**、一个字都不写该轨迹的流水 ⇒ 流水**永久停在上一次真唤起处**。
3416
+ * 实测后果(guard 轨迹,真机):它的 `cro-wake-log.log` 停在 `01:40:55`,
3417
+ * owner 据此认为"守卫从那以后就没工作过"——而真相是它**此后每个 tick 都在被评估**,
3418
+ * 只是判定为"不打扰"。⇒ **「我判了、只是不叫」与「我根本没被评估」在读数上完全一样。**
3419
+ *
3420
+ * 判据同 D209:**可以不出声地判"不打扰",但"判过"本身要留痕**。
3421
+ * 四态:`ok` / `fail` / `not-ready` / `no-wake`。
3422
+ *
3423
+ * ⚠️ 与 `wake` 分支同一条硬约束:**写流水失败绝不影响投递与既有链路**(设计 §5 铁律)。
3424
+ */
3425
+ {
3426
+ const rec = appendCroWakeLog(root, dirName, {
3427
+ ok: false,
3428
+ noWake: true,
3429
+ detail: outcome.detail,
3430
+ tick: s.ticks,
3431
+ reason: outcome.decision.reason ?? null
3432
+ }, nowMs);
3433
+ if (!rec.ok) log.push(`· ${root}: ${dirName}: CRO 流水写入失败(已忽略,不影响既有链路): ${rec.error}`);
3434
+ }
3435
+ }
3436
+ } catch (err) {
3437
+ skips += 1;
3438
+ log.push(`· ${root}: ${dirName}: CRO 内部异常(已忽略,不影响既有链路): ${String(err?.message ?? err)}`);
3439
+ }
3440
+ log.push(`· ${root}: CRO 评估 ${dirs.length} 条(唤起 ${wakes},不唤起 ${quiet},跳过 ${skips}${hostSkips > 0 ? `,非本机绑定 ${hostSkips}` : ""})`);
3441
+ }
3442
+ /**
3443
+ * 取本机机器名(**永不抛**)。
3444
+ *
3445
+ * `os.hostname()` 理论上可能抛(极少数环境)⇒ 这里兜底成空串。
3446
+ * ⚠️ **空串的后果是"全部不通过"**(`bound === self` 永不成立)⇒ 即 **fail-closed**,
3447
+ * 与所有者裁定同向(宁可都不跑,也不会"名字取不到就随便跑")。
3448
+ * @returns 本机机器名;取不到 ⇒ `''`
3449
+ */
3450
+ function safeHostname() {
3451
+ try {
3452
+ return hostname();
3453
+ } catch {
3454
+ return "";
3455
+ }
3456
+ }
3457
+ /** 一次调度 tick:遍历已知 CCC → 到期项串行投递 / 超窗项置 missed;返回人读摘要 */
3458
+ async function runWakeTick(ctx) {
3459
+ const log = [];
3460
+ const roots = await collectWakeCccs(ctx).catch(() => []);
3461
+ if (roots.length === 0) {
3462
+ clock.noteSkipReason("无已知 CCC 可扫(等某个会话/工作区出现)");
3463
+ return log;
3464
+ }
3465
+ clock.noteSkipReason(null);
3466
+ for (const root of roots) {
3467
+ const purged = purgeFinalizedWakes(root);
3468
+ if (purged.ok && purged.purged > 0) log.push(`· ${root}: 清除 ${purged.purged} 条已结案记录(存量首清)`);
3469
+ const { registry, error } = loadWakeRegistry(root);
3470
+ if (error) {
3471
+ log.push(`✗ ${root}: ${error}`);
3472
+ await runCroPhase(ctx, root, [], log);
3473
+ continue;
3474
+ }
3475
+ const { due, expired } = splitDueWakes(registry, Date.now(), WAKE_CATCH_UP_MS);
3476
+ for (const e of expired) {
3477
+ finalizeWake(root, e.id, "missed", `超过补跑窗口(${WAKE_CATCH_UP_MS / 36e5}h)未投递`);
3478
+ log.push(`· ${root}: ${e.id} → missed(超补跑窗口,已清除)`);
3479
+ }
3480
+ for (const e of due) {
3481
+ const res = await deliverWake(ctx, root, e);
3482
+ if (res.notReady) {
3483
+ log.push(`· ${root}: ${e.id} → 跳过(宿主服务未就绪,下个 tick 再试;不计失败)`);
3484
+ continue;
3485
+ }
3486
+ if (res.ok) {
3487
+ finalizeWake(root, e.id, "delivered", res.detail);
3488
+ log.push(`· ${root}: ${e.id} → delivered(已清除)`);
3489
+ } else {
3490
+ updateWake(root, e.id, {
3491
+ state: "pending",
3492
+ attempts: e.attempts + 1,
3493
+ lastResult: res.detail
3494
+ });
3495
+ log.push(`· ${root}: ${e.id} → 重试(${res.detail})`);
3496
+ }
3497
+ }
3498
+ await runCroPhase(ctx, root, registry.entries, log);
3499
+ await runInterruptedSafetyNet(ctx, root, log);
3500
+ }
3501
+ return log;
3502
+ }
3503
+ /**
3504
+ * 🔴🔴 **A104-C′(2026-10-23,owner 点头):容器级「被重启打断的会话」兜底 —— 每 tick 扫一次并注入。**
3505
+ *
3506
+ * ## 为什么必须做(不是"多一层保险",是**已有实测盲窗**)
3507
+ *
3508
+ * 2026-10-23 实测:`08:02` 一次 `restart-web` 打断了一个会话,此后 **13 分钟无人拉起**。
3509
+ * 用真回放(把日志截到争议时刻 + 重压 zstd 帧 + 同一份实现)验证:
3510
+ * - 截到 `08:07:30`/`08:12:40` ⇒ **两次都能判出该会话 `turn/end[interrupted]` 且其后无新 `turn/start`**
3511
+ * - 而那两个 tick **实际都没有叫** ⇒ **判据没错,是"没有被跑起来"**
3512
+ *
3513
+ * ⇒ 结论:**「守护程序自己修好」≠「一定有人拉起」**。任何单条 CRO 轨迹都可能因为
3514
+ * 自身在跑、绑定缺失、阈值保护、程序 bug 而漏掉 —— 而被打断的会话**不会自己恢复**。
3515
+ * ⇒ 兜底必须放在**容器自己**身上,且必须**自己完成"发现 + 注入"**(只扫不动没有用)。
3516
+ *
3517
+ * ## 边界(写在这里免得后人扩大它)
3518
+ *
3519
+ * - ⚠️ **这会真的起 agent、真烧算力** —— owner 已被告知并点头(2026-10-23)。
3520
+ * - 🔴 **必须排除 live 会话**:正在跑的会话不该被再注入一次(否则重复投递)。
3521
+ * - 🔴 **与 CRO 并存、不替代**:CRO 是**轨迹自己的策略**(何时该叫由它判),
3522
+ * 本兜底只覆盖「**无论谁都没叫**」这一格。两者重复命中时,靠 `live` 排除天然去重。
3523
+ * - ⚠️ **本函数任何失败都不得连累既有链路**(设计 §5 铁律):整体 try/catch 吞掉并留一行日志。
3524
+ */
3525
+ async function runInterruptedSafetyNet(ctx, root, log) {
3526
+ const s0 = wakeSchedulerState();
3527
+ const tickNow = s0.ticks + 1;
3528
+ const phase = tickNow <= 1 ? "启动首拍" : tickNow <= 3 ? "刚启动窗口" : "常规 tick";
3529
+ const tickTag = `tick=${s0.ticks}(当前第${tickNow}拍·${phase})`;
3530
+ try {
3531
+ const agents = hostAgents(ctx);
3532
+ const sc = hostService(ctx, "sessionController");
3533
+ /**
3534
+ * 🔴 **A105:live 排除的粒度 =「此刻有没有在跑」,不是「宿主认不认识它」。**
3535
+ *
3536
+ * 修前:`collectLiveSessionIdsForSafetyNet` 取的是 `hostSessions(ctx).list()`
3537
+ * ⇒ 那是**载入状态**(会话对象在本进程里),**不是**轮次状态 ⇒
3538
+ * 一条「已载入、但上一轮被重启打断」的会话会被**误排除**,
3539
+ * 而那恰恰是**最该被接走**的一类(2026-10-23 实测:这正是那次 13 分钟盲窗的形状)。
3540
+ *
3541
+ * ⇒ 现在与权威 `interrupted` 动作**用同一个取数口**(`listRunningSessionIds`),
3542
+ * 两处对"在跑"的认知**不可能再不一致**(A105 的全部目的就是这一条)。
3543
+ */
3544
+ const liveIds = new Set(listRunningSessionIds(Date.now()));
3545
+ const scan = scanInterruptedSessions(`${sessionsRootDir()}/${projectDirNameForCwd(root)}`, { liveIds });
3546
+ /**
3547
+ * 🔴 **A106:无命中也要出声**(`ok`/`fail`/`not-ready`/`no-wake` 之后缺的第五格)。
3548
+ *
3549
+ * 修前这里 `return`,**一行都不写** ⇒ 宿主日志里没有任何「中断兜底」字样 ⇒
3550
+ * **「我扫了、没东西」与「我没被跑」在读数上完全一样**(D216 同族第四次)。
3551
+ * ⇒ 判据同 A102:**可以不出声地判"没事",但"判过"本身要留痕**。
3552
+ * ⚠️ 密度很低(每 tick 一行,5min 一次),且**必须带拍号与扫描读数**,
3553
+ * 否则又是一堆分不清是哪一拍的日志行(guard 明确要求)。
3554
+ */
3555
+ if (scan.sessions.length === 0) {
3556
+ log.push(`· ${root}: 中断兜底 ${tickTag} → 扫 ${scan.scanned} 个 · 命中 0 · 排除在跑 ${liveIds.size} 个 · 跳过 ${scan.skipped} ⇒ 无需注入`);
3557
+ return;
3558
+ }
3559
+ const resumeOne = createResumeOne({
3560
+ agents,
3561
+ sessionController: sc,
3562
+ makeMessage: (text) => createUserMessage({
3563
+ content: [{
3564
+ type: "text",
3565
+ text
3566
+ }],
3567
+ source: PLUGIN_SOURCE
3568
+ })
3569
+ });
3570
+ log.push(`· ${root}: 中断兜底 ${tickTag} → 扫 ${scan.scanned} 个 · 命中 ${scan.sessions.length} · 排除在跑 ${liveIds.size} 个`);
3571
+ for (const hit of scan.sessions) try {
3572
+ const out = await resumeOne(hit);
3573
+ log.push(`· ${root}: 中断兜底 ${hit.sessionId}(turn ${hit.turn ?? "?"})→ ` + (out.ok ? `已注入(${out.how ?? "?"})` : `未注入:${out.error ?? "未知"}`));
3574
+ } catch (err) {
3575
+ log.push(`· ${root}: 中断兜底 ${hit.sessionId} 注入抛错(已忽略):${String(err?.message ?? err)}`);
3576
+ }
3577
+ } catch (err) {
3578
+ log.push(`· ${root}: 中断兜底 ${tickTag} 整体失败(已忽略,不影响既有链路):${String(err?.message ?? err)}`);
3579
+ }
3580
+ }
3581
+ /**
3582
+ * ⚠️ **2026-10-23 A105 已删除 `collectLiveSessionIdsForSafetyNet`。**
3583
+ *
3584
+ * 它取的是 `hostSessions(ctx).list()` =「宿主认不认识」,而 A105 的结论是
3585
+ * 判据必须是「**此刻有没有在跑**」。现在两处(本兜底与权威 `interrupted` 动作)
3586
+ * 都走 `listRunningSessionIds()` —— **同一张表、同一个口径**,不可能再打架。
3587
+ *
3588
+ * 🔴 后人若要"把 live 表也加回来做双保险":**不要** ——
3589
+ * `live ∧ ¬running` 恰是「已载入、但上一轮被打断」那一类,**它最该被接走**;
3590
+ * 加回来就是把最该救的那一格重新排除掉。
3591
+ */
3592
+ /**
3593
+ * 装配调度器(index.ts apply 调用):5min tick + 生命周期 disposer +
3594
+ * 会话出现/settings 变化时热启动(与 autopilot 同款触发面,互不干扰)。
3595
+ * @param ctx 插件上下文
3596
+ */
3597
+ function registerWakeScheduler(ctx) {
3598
+ clockOpts.ctx = ctx;
3599
+ clock.start();
3600
+ registerDisposer(ctx, "认知轨迹唤醒调度器", () => clock.dispose());
3601
+ }
3602
+ //#endregion
3603
+ export { DEFAULT_LOOKBACK_MS as a, scanInterruptedSessions as c, registerDisposer as d, buildCroSnapshot as f, listWakes as g, addWake as h, wakeSchedulerState as i, listRunningSessionIds as l, WAKE_CATCH_UP_MS as m, scheduler_exports as n, createResumeOne as o, listCroCognitiveTrajectories as p, sendToCognitiveTrajectory as r, projectDirNameForCwd as s, registerWakeScheduler as t, registerCroTurnTracking as u };