@shgroup/dsh-serenity-hooks 1.37.0 → 1.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/dsh.plugin.json +1 -1
- package/lib/index.js +41 -475
- package/lib/tools/trajectory.d.ts +4 -0
- package/lib/{trajectory-bound-DETSEBpL.js → trajectory-bound-U3ZU_mI6.js} +2 -1
- package/lib/trajectory-ops.d.ts +3 -1
- package/lib/wake-scheduler-Ebdyvzuq.js +798 -0
- package/lib/wake-scheduler.d.ts +38 -0
- package/package.json +1 -1
- package/lib/wake-registry-Cj2aSHME.js +0 -258
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
import { r as hostService, t as hostAgents } from "./access-fiehjxV6.js";
|
|
2
|
+
import { o as listCccs } from "./ccc-roots-Bd_SjEs7.js";
|
|
3
|
+
import { _ as sessionsRoot, n as listBoundSessionIds, u as findSession } from "./trajectory-bound-U3ZU_mI6.js";
|
|
4
|
+
import { t as readSimpleSettings } from "./settings-section-DMDUoQum.js";
|
|
5
|
+
import { basename, dirname, join } from "node:path";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
8
|
+
//#region src/wake-registry.ts
|
|
9
|
+
/**
|
|
10
|
+
* wake-registry.ts — trajectory 唤醒注册表(D58,S142 2026-09-13)
|
|
11
|
+
*
|
|
12
|
+
* 需求(用户原话):一个 CCC 内允许多个 trajectory 任意形态并行;某个 trajectory 可以
|
|
13
|
+
* **预定未来唤醒自己或别人**,本质上是一次「未来时刻 + 一条 message」的投递;「周期自唤醒」
|
|
14
|
+
* 曾是它的特例(原 ACC autopilot / D59,该机制已于 2026-09-15 整段退场)。协作边界:
|
|
15
|
+
* 互相可见、**无阻塞无等待**。
|
|
16
|
+
*
|
|
17
|
+
* 本模块只负责**注册表本身**(纯数据层,无宿主依赖,可单测):
|
|
18
|
+
* - 落点:CCC 内 `AGENT_SESSIONS/wake-registry.json`(随 CCC git,人可读可审计)
|
|
19
|
+
* - 状态机:`pending` →(投递成功)`delivered`|(超补跑窗口)`missed`|(`wake rm` 物理移除)
|
|
20
|
+
* - 投递即终结:一次性,不重投(无回执语义——发起方不 await,见设计文档 §2)
|
|
21
|
+
*
|
|
22
|
+
* 调度与投递在 `wake-scheduler.ts`(需要 ctx),本模块保持纯净。
|
|
23
|
+
*/
|
|
24
|
+
/** 注册表相对 CCC 根的路径(与 `.bindings.json` 同居 AGENT_SESSIONS/) */
|
|
25
|
+
const WAKE_REGISTRY_REL_PATH = "AGENT_SESSIONS/wake-registry.json";
|
|
26
|
+
/** 注册表格式版本(结构变更时 +1;读取端只接受本版本) */
|
|
27
|
+
const WAKE_REGISTRY_VERSION = 1;
|
|
28
|
+
/**
|
|
29
|
+
* 补跑窗口(缺省 2h):dsh 停机期间到期的条目,若迟到不超过本窗口则补投,
|
|
30
|
+
* 超过则置 `missed` 留痕(不补投——避免"半夜该做的事早上突然连环跑")。
|
|
31
|
+
*/
|
|
32
|
+
const WAKE_CATCH_UP_MS = 72e5;
|
|
33
|
+
/** 注册表绝对路径 */
|
|
34
|
+
function wakeRegistryPath(root) {
|
|
35
|
+
return join(root, WAKE_REGISTRY_REL_PATH);
|
|
36
|
+
}
|
|
37
|
+
/** 空注册表 */
|
|
38
|
+
function emptyRegistry() {
|
|
39
|
+
return {
|
|
40
|
+
version: WAKE_REGISTRY_VERSION,
|
|
41
|
+
entries: []
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** 条目形状校验(容忍手改:字段缺失/类型不符 → 丢弃该条,不阻断整表) */
|
|
45
|
+
function asEntry(value) {
|
|
46
|
+
const e = value;
|
|
47
|
+
if (!e || typeof e.id !== "string" || typeof e.target !== "string") return null;
|
|
48
|
+
if (typeof e.at !== "string" || typeof e.message !== "string") return null;
|
|
49
|
+
const state = e.state === "delivered" || e.state === "missed" || e.state === "cancelled" ? e.state : "pending";
|
|
50
|
+
return {
|
|
51
|
+
id: e.id,
|
|
52
|
+
target: e.target,
|
|
53
|
+
at: e.at,
|
|
54
|
+
message: e.message,
|
|
55
|
+
state,
|
|
56
|
+
createdBy: typeof e.createdBy === "string" ? e.createdBy : "",
|
|
57
|
+
createdAt: typeof e.createdAt === "string" ? e.createdAt : "",
|
|
58
|
+
attempts: typeof e.attempts === "number" && Number.isFinite(e.attempts) ? e.attempts : 0,
|
|
59
|
+
lastResult: typeof e.lastResult === "string" ? e.lastResult : null,
|
|
60
|
+
deliveredAt: typeof e.deliveredAt === "string" ? e.deliveredAt : null
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 读注册表。文件缺失 → 空表;损坏 → 空表 + error(不抛错,调用方决定是否告警)。
|
|
65
|
+
* @param root CCC 根
|
|
66
|
+
* @returns 注册表与读取错误(无错为 null)
|
|
67
|
+
*/
|
|
68
|
+
function loadWakeRegistry(root) {
|
|
69
|
+
const path = wakeRegistryPath(root);
|
|
70
|
+
if (!existsSync(path)) return {
|
|
71
|
+
registry: emptyRegistry(),
|
|
72
|
+
error: null
|
|
73
|
+
};
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
76
|
+
const raw = Array.isArray(parsed.entries) ? parsed.entries : [];
|
|
77
|
+
const entries = [];
|
|
78
|
+
for (const item of raw) {
|
|
79
|
+
const e = asEntry(item);
|
|
80
|
+
if (e) entries.push(e);
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
registry: {
|
|
84
|
+
version: WAKE_REGISTRY_VERSION,
|
|
85
|
+
entries
|
|
86
|
+
},
|
|
87
|
+
error: null
|
|
88
|
+
};
|
|
89
|
+
} catch (err) {
|
|
90
|
+
return {
|
|
91
|
+
registry: emptyRegistry(),
|
|
92
|
+
error: `唤醒注册表解析失败(${String(err?.message ?? err)})`
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** 原子写(tmp + rename;与 .bindings.json 同款形态) */
|
|
97
|
+
function saveWakeRegistry(root, registry) {
|
|
98
|
+
const path = wakeRegistryPath(root);
|
|
99
|
+
try {
|
|
100
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
101
|
+
const tmp = `${path}.tmp`;
|
|
102
|
+
writeFileSync(tmp, `${JSON.stringify({
|
|
103
|
+
version: WAKE_REGISTRY_VERSION,
|
|
104
|
+
entries: registry.entries
|
|
105
|
+
}, null, 2)}\n`, "utf-8");
|
|
106
|
+
renameSync(tmp, path);
|
|
107
|
+
return {
|
|
108
|
+
ok: true,
|
|
109
|
+
error: null
|
|
110
|
+
};
|
|
111
|
+
} catch (err) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
error: `唤醒注册表写入失败(${String(err?.message ?? err)})`
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** 条目 id:`w-<UTC 到分钟>-<4 hex>`(可读 + 低碰撞;不复用由"物理移除"保证) */
|
|
119
|
+
function newWakeId(nowMs, rand = Math.random) {
|
|
120
|
+
const iso = new Date(nowMs).toISOString();
|
|
121
|
+
return `w-${`${iso.slice(0, 10).replace(/-/g, "")}-${iso.slice(11, 16).replace(":", "")}`}-${Math.floor(rand() * 65536).toString(16).padStart(4, "0")}`;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* 解析唤醒时刻。接受两种写法(设计文档 §7):
|
|
125
|
+
* - 绝对:RFC3339(含时区偏移,如 `2026-09-14T09:00:00+08:00`)
|
|
126
|
+
* - 相对:`+30m` / `+2h`(便于 agent 直接登记"半小时后")
|
|
127
|
+
* @param input 用户/agent 传入的时刻串
|
|
128
|
+
* @param nowMs 当前时刻(相对写法基准)
|
|
129
|
+
* @returns `{ ok, ms }` 或 `{ ok: false, error }`
|
|
130
|
+
*/
|
|
131
|
+
function parseWakeAt(input, nowMs) {
|
|
132
|
+
const raw = input.trim();
|
|
133
|
+
if (raw === "") return {
|
|
134
|
+
ok: false,
|
|
135
|
+
error: "时刻为空"
|
|
136
|
+
};
|
|
137
|
+
const rel = /^\+(\d+(?:\.\d+)?)([mhd])$/.exec(raw);
|
|
138
|
+
if (rel) {
|
|
139
|
+
const n = Number(rel[1]);
|
|
140
|
+
const unit = rel[2];
|
|
141
|
+
return {
|
|
142
|
+
ok: true,
|
|
143
|
+
ms: nowMs + n * (unit === "m" ? 6e4 : unit === "h" ? 36e5 : 864e5)
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const ms = Date.parse(raw);
|
|
147
|
+
if (!Number.isFinite(ms)) return {
|
|
148
|
+
ok: false,
|
|
149
|
+
error: `时刻无法解析(须 RFC3339 或 +30m/+2h): ${raw}`
|
|
150
|
+
};
|
|
151
|
+
return {
|
|
152
|
+
ok: true,
|
|
153
|
+
ms
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* 登记一条唤醒。
|
|
158
|
+
* @param root CCC 根
|
|
159
|
+
* @param input 目标 trajectory / 时刻串 / message / 发起者 / 当前时刻
|
|
160
|
+
* @returns 成功返回条目,失败返回稳定错误文案(不抛错)
|
|
161
|
+
*/
|
|
162
|
+
function addWake(root, input) {
|
|
163
|
+
const target = input.target.trim();
|
|
164
|
+
if (target === "") return {
|
|
165
|
+
ok: false,
|
|
166
|
+
error: "target 为空(须 S### 或 AGENT_SESSIONS 目录名)"
|
|
167
|
+
};
|
|
168
|
+
const message = input.message.trim();
|
|
169
|
+
if (message === "") return {
|
|
170
|
+
ok: false,
|
|
171
|
+
error: "message 为空(唤醒必须携带一条信息)"
|
|
172
|
+
};
|
|
173
|
+
const parsed = parseWakeAt(input.at, input.nowMs);
|
|
174
|
+
if (!parsed.ok) return {
|
|
175
|
+
ok: false,
|
|
176
|
+
error: parsed.error
|
|
177
|
+
};
|
|
178
|
+
if (parsed.ms <= input.nowMs) return {
|
|
179
|
+
ok: false,
|
|
180
|
+
error: "时刻必须在未来(唤醒只允许可预期的未来)"
|
|
181
|
+
};
|
|
182
|
+
const { registry, error } = loadWakeRegistry(root);
|
|
183
|
+
if (error) return {
|
|
184
|
+
ok: false,
|
|
185
|
+
error
|
|
186
|
+
};
|
|
187
|
+
const entry = {
|
|
188
|
+
id: newWakeId(input.nowMs),
|
|
189
|
+
target,
|
|
190
|
+
at: new Date(parsed.ms).toISOString(),
|
|
191
|
+
message,
|
|
192
|
+
state: "pending",
|
|
193
|
+
createdBy: input.createdBy,
|
|
194
|
+
createdAt: new Date(input.nowMs).toISOString(),
|
|
195
|
+
attempts: 0,
|
|
196
|
+
lastResult: null,
|
|
197
|
+
deliveredAt: null
|
|
198
|
+
};
|
|
199
|
+
registry.entries.push(entry);
|
|
200
|
+
const saved = saveWakeRegistry(root, registry);
|
|
201
|
+
if (!saved.ok) return {
|
|
202
|
+
ok: false,
|
|
203
|
+
error: saved.error ?? "写入失败"
|
|
204
|
+
};
|
|
205
|
+
return {
|
|
206
|
+
ok: true,
|
|
207
|
+
entry
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* 到期切分(纯函数,可单测):
|
|
212
|
+
* - `due`:`state === 'pending'` 且 `now >= at`(在补跑窗口内)→ 应投递
|
|
213
|
+
* - `expired`:`state === 'pending'` 且 `now - at > catchUpMs` → 应置 missed(不投递)
|
|
214
|
+
* @param registry 注册表
|
|
215
|
+
* @param nowMs 当前时刻
|
|
216
|
+
* @param catchUpMs 补跑窗口
|
|
217
|
+
*/
|
|
218
|
+
function splitDueWakes(registry, nowMs, catchUpMs = WAKE_CATCH_UP_MS) {
|
|
219
|
+
const due = [];
|
|
220
|
+
const expired = [];
|
|
221
|
+
for (const e of registry.entries) {
|
|
222
|
+
if (e.state !== "pending") continue;
|
|
223
|
+
const at = Date.parse(e.at);
|
|
224
|
+
if (!Number.isFinite(at) || nowMs < at) continue;
|
|
225
|
+
if (nowMs - at > catchUpMs) expired.push(e);
|
|
226
|
+
else due.push(e);
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
due,
|
|
230
|
+
expired
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* 更新一条(状态推进;调用方给最终状态与结果文案)。
|
|
235
|
+
* @param root CCC 根
|
|
236
|
+
* @param id 条目 id
|
|
237
|
+
* @param patch 要写入的字段
|
|
238
|
+
* @returns 写入结果
|
|
239
|
+
*/
|
|
240
|
+
function updateWake(root, id, patch) {
|
|
241
|
+
const { registry, error } = loadWakeRegistry(root);
|
|
242
|
+
if (error) return {
|
|
243
|
+
ok: false,
|
|
244
|
+
error
|
|
245
|
+
};
|
|
246
|
+
const entry = registry.entries.find((e) => e.id === id);
|
|
247
|
+
if (!entry) return {
|
|
248
|
+
ok: false,
|
|
249
|
+
error: `wake_not_found: ${id}`
|
|
250
|
+
};
|
|
251
|
+
Object.assign(entry, patch);
|
|
252
|
+
return saveWakeRegistry(root, registry);
|
|
253
|
+
}
|
|
254
|
+
/** 列条目(默认全量;`pendingOnly` 只列在办项——面板/工具用) */
|
|
255
|
+
function listWakes(root, opts = {}) {
|
|
256
|
+
const { registry, error } = loadWakeRegistry(root);
|
|
257
|
+
return {
|
|
258
|
+
entries: [...opts.pendingOnly ? registry.entries.filter((e) => e.state === "pending") : registry.entries].sort((a, b) => Date.parse(a.at) - Date.parse(b.at)),
|
|
259
|
+
error
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
//#endregion
|
|
263
|
+
//#region src/host/effect.ts
|
|
264
|
+
/** 已告警的标签(进程级去重:重复装配/多测试替身不刷屏,但绝不静默) */
|
|
265
|
+
const warnedLabels = /* @__PURE__ */ new Set();
|
|
266
|
+
/**
|
|
267
|
+
* 登记一个卸载时执行的清理。
|
|
268
|
+
* @param ctx 宿主插件上下文
|
|
269
|
+
* @param label 日志标签(定位是哪个资源未装配)
|
|
270
|
+
* @param cleanup 卸载时执行;抛错被吞掉(不阻断其它清理)
|
|
271
|
+
* @returns 是否成功装配(false = 宿主无 `ctx.effect`,已告警)
|
|
272
|
+
*/
|
|
273
|
+
function registerDisposer(ctx, label, cleanup) {
|
|
274
|
+
const effect = ctx?.effect;
|
|
275
|
+
if (typeof effect !== "function") {
|
|
276
|
+
if (!warnedLabels.has(label)) {
|
|
277
|
+
warnedLabels.add(label);
|
|
278
|
+
console.warn(`[serenity-hooks] ✗ 资源拆卸未装配(${label}):ctx.effect 不可用(宿主契约缺失)`);
|
|
279
|
+
}
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
effect.call(ctx, () => () => {
|
|
284
|
+
try {
|
|
285
|
+
cleanup();
|
|
286
|
+
} catch (err) {
|
|
287
|
+
console.warn(`[serenity-hooks] ✗ 资源清理失败(${label}): ${String(err?.message ?? err)}`);
|
|
288
|
+
}
|
|
289
|
+
}, `dsh-serenity-hooks: ${label}`);
|
|
290
|
+
return true;
|
|
291
|
+
} catch (err) {
|
|
292
|
+
console.warn(`[serenity-hooks] ✗ 资源拆卸注册失败(${label}): ${String(err?.message ?? err)}`);
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
//#endregion
|
|
297
|
+
//#region src/clock-runtime.ts
|
|
298
|
+
/**
|
|
299
|
+
* clock-runtime.ts — **时钟运行时工厂**(S142 §38 复审候选 C6b,路径 **P2**)
|
|
300
|
+
*
|
|
301
|
+
* 本仓有**两条各自独立运行**的 5min 时钟:
|
|
302
|
+
* · 唤醒调度器(`wake-scheduler.ts`,一次性唤醒,D58)
|
|
303
|
+
* · autopilot 时钟(`autopilot-trajectory.ts`,周期自唤醒,D59)
|
|
304
|
+
* 二者的**业务判据完全不同**(谁来扫 / 扫什么 / 投递给谁 / 按什么周期),但**引擎骨架逐字同形**
|
|
305
|
+
* ——进程态 + 快照 + 复位 + `startTimer` + tick 外壳 + 事件接线 + disposer + `timer.unref()`。
|
|
306
|
+
* 本条把**引擎**抽到本工厂,**各钟仍各持自己的定时器与自己的串行链**。
|
|
307
|
+
*
|
|
308
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
309
|
+
* 🔴 硬约束一:**两条时钟绝不可共用"排队执行"的链**(这是 P2 与 P1 的分界判据)
|
|
310
|
+
*
|
|
311
|
+
* autopilot 每轮要跑该 CCC 的**偏见脚本**(`fetchBiasContent`,超时 60s),可能阻塞数十秒;
|
|
312
|
+
* 若与唤醒调度器共用一条串行链,会把另一条一起**卡住**(一次性唤醒被无关的偏见脚本拖住)。
|
|
313
|
+
* ⇒ 本工厂把 `chain` 作为**每实例私有状态**:`createClock()` 调用一次 = 一条独立的串行域。
|
|
314
|
+
* 实例之间**零共享**(无模块级单例、无共享 Promise、无共享 map)。
|
|
315
|
+
* ⚠️ 若日后有人把 `chain` 提到模块级"省一次分配",**两条时钟的隔离即刻失效**——不许。
|
|
316
|
+
*
|
|
317
|
+
* 🔴 硬约束二:**武装门只判全局闸**(`gate()`),**不判"本次有无目标"**
|
|
318
|
+
*
|
|
319
|
+
* 2026-09-14 F 段实测缺陷:旧实现把"有 live CCC"当**武装前置条件**,而宿主刚重启时 live 会话
|
|
320
|
+
* 必为空,且**"恢复旧会话"不触发 `session/created`**(只有新建会)⇒ 启动瞬间判定落空 =
|
|
321
|
+
* **时钟永久不武装**(实测 6.6h 零 tick)。⇒ 现在「**全局闸开即武装**」,"有无目标"降级为
|
|
322
|
+
* tick 内的廉价判定。**武装与"本次有无目标"是两件事,本工厂不得把二者耦合。**
|
|
323
|
+
*
|
|
324
|
+
* 🔴 硬约束三:**两条闸必须继续分开传**(v1.34 S-1 解耦)
|
|
325
|
+
*
|
|
326
|
+
* 唤醒调度器 `wakeSchedulerEnabled`(**缺省开**)|autopilot `autopilotWakeEnabled`
|
|
327
|
+
* (**缺省关**)——取值方向相反。工厂取 `gate` **回调**(每次现读),**不做**任何合并或缓存。
|
|
328
|
+
*
|
|
329
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
330
|
+
* 两条时钟**逐项语义差**(`ClockOptions` 的每个可配项都对应下表一行,不是随意的灵活性):
|
|
331
|
+
*
|
|
332
|
+
* | 项 | 唤醒调度器 | autopilot | 工厂表达 |
|
|
333
|
+
* |----|-----------|-----------|---------|
|
|
334
|
+
* | `chain` | 有(全局串行) | 有(全局串行) | **总是私有**(硬约束一) |
|
|
335
|
+
* | `lastTickLog` | 有(逐行摘要) | **无** | `logFrom` 不给 = 不记 |
|
|
336
|
+
* | `ticks` 记账 | 工厂在 body **完成后**记 | **本钟**在同步段记 | `bodyCountsTick` + `begin` |
|
|
337
|
+
* | body 返回类型 | `string[]`(人读摘要) | `void` | 泛型 `T` |
|
|
338
|
+
* | 启动日志 | 固定串 | 含启用 CCC 计数 | `startLog: () => string` |
|
|
339
|
+
* | body 异常兜底 | 留痕 + `console.warn` | 同 | 工厂统一(**吞异常不改语义**) |
|
|
340
|
+
*
|
|
341
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
342
|
+
* **本工厂是"加法式"重构**(P2 的性质):不新增任何判定逻辑,只把同形骨架收成一份代码;
|
|
343
|
+
* 两个模块仍各自导出 `wakeSchedulerState()` / `autopilotClockState()` 与各自的
|
|
344
|
+
* `__reset…ForTest()`,**对外可观测面(字段名与语义)零变化**——`acc-diag` ①b 段、
|
|
345
|
+
* `container-status.ts:containerClocks()` 与面板都在读它们(见该文件 :225–250 的设计红线)。
|
|
346
|
+
*
|
|
347
|
+
* 为什么**不**取 P1(真合并成一个 `setInterval` + 两条策略)——2026-09-15 所有者裁决:
|
|
348
|
+
* P1 只多省 ~20 行,却要把 `armed` 拆成"宿主 + 每策略"两层、并强行分开串行域,
|
|
349
|
+
* 即**动两条正在跑的时钟的骨架**;**不值当**(裁决原文见 SESSION §12.8②)。
|
|
350
|
+
*/
|
|
351
|
+
/**
|
|
352
|
+
* tick 周期(**5 分钟**)。
|
|
353
|
+
*
|
|
354
|
+
* 2026-09-15 S142「ACC 侧 autopilot 退场」:本值原从 `autopilot-core.ts` 导入(`TICK_MS`),
|
|
355
|
+
* 但该模块随 autopilot 退场,而**本模块的唯一消费者是唤醒调度器**(`wake-scheduler`)——
|
|
356
|
+
* 值本身与 autopilot 无关 ⇒ 随消费者归位到本模块(不再依赖 autopilot 判据层)。
|
|
357
|
+
*
|
|
358
|
+
* ⚠️ 语义不得变:该值同时是**报告文案的来源**("等下个 Nmin tick"),历史上曾因文案写死
|
|
359
|
+
* 10min 与实现 5min 不符而误导排查(§12.7 文案纠错)。
|
|
360
|
+
*/
|
|
361
|
+
const TICK_MS = 3e5;
|
|
362
|
+
/**
|
|
363
|
+
* 挂事件 → 触发 `start()`(**每个事件一次 try/catch 吞异常**:事件通道缺失不阻断时钟)。
|
|
364
|
+
*
|
|
365
|
+
* 为什么吞异常:宿主(headless profile 等)可能没有事件通道;**启动时已 `start()` 过一次**,
|
|
366
|
+
* 事件只是"跟上面板开关 / 新会话"的热启动补丁,缺它不该让整个装配抛错。
|
|
367
|
+
* @param on 宿主的 `on`(可能不存在)
|
|
368
|
+
* @param ctx `on` 的 `this`(宿主 API 常要求绑定)
|
|
369
|
+
* @param clock 目标时钟
|
|
370
|
+
* @param events 事件名列表(回调恒为 `() => clock.start()`)
|
|
371
|
+
*/
|
|
372
|
+
function attachClockEvents(ctx, clock, events) {
|
|
373
|
+
const on = ctx?.on;
|
|
374
|
+
if (typeof on !== "function") return;
|
|
375
|
+
for (const name of events) try {
|
|
376
|
+
on.call(ctx, name, () => clock.start());
|
|
377
|
+
} catch {}
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* 建一条时钟(**每钟各调一次**——本工厂**不是**单例,调用次数 = 时钟条数)。
|
|
381
|
+
*
|
|
382
|
+
* 顺序忠实于既有实现:`start()` 先武装并立即跑一次 tick → 再挂事件。
|
|
383
|
+
* 拆卸由调用方经 `registerDisposer(ctx, label, clock.dispose)` 落到宿主的 `ctx.effect`。
|
|
384
|
+
* @param opts 见 {@link ClockOptions}(每项对应一条既有的语义差)
|
|
385
|
+
* @returns 该钟的 `{start, dispose, snapshot, reset}`
|
|
386
|
+
*/
|
|
387
|
+
function createClock(opts) {
|
|
388
|
+
const runtime = {
|
|
389
|
+
armed: false,
|
|
390
|
+
armedAt: null,
|
|
391
|
+
lastTickAt: null,
|
|
392
|
+
ticks: 0,
|
|
393
|
+
lastSkipReason: null
|
|
394
|
+
};
|
|
395
|
+
if (opts.logFrom) runtime.lastTickLog = [];
|
|
396
|
+
const st = {
|
|
397
|
+
timer: null,
|
|
398
|
+
chain: Promise.resolve(),
|
|
399
|
+
runtime,
|
|
400
|
+
wired: false,
|
|
401
|
+
opts
|
|
402
|
+
};
|
|
403
|
+
/** 记一次 tick(工厂内部与 body 共用同一实现——只有一处会计逻辑) */
|
|
404
|
+
const countTick = () => {
|
|
405
|
+
runtime.lastTickAt = Date.now();
|
|
406
|
+
runtime.ticks += 1;
|
|
407
|
+
};
|
|
408
|
+
/** 逐行打印(`logFrom` 缺省 ⇒ 本钟无 tick 日志,整段为死代码路径) */
|
|
409
|
+
const emit = (lines) => {
|
|
410
|
+
if (!opts.logFrom) return;
|
|
411
|
+
for (const line of lines) console.log(`${opts.logFrom.prefix}${line}`);
|
|
412
|
+
};
|
|
413
|
+
const tick = () => {
|
|
414
|
+
if (!opts.gate()) {
|
|
415
|
+
runtime.lastSkipReason = opts.gateOffReason;
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (opts.begin) try {
|
|
419
|
+
opts.begin();
|
|
420
|
+
} catch (err) {
|
|
421
|
+
console.warn(`[serenity-hooks] ✗ tick 前置异常: ${String(err?.message ?? err)}(${opts.label})`);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
st.chain = st.chain.then(async () => {
|
|
425
|
+
try {
|
|
426
|
+
const value = await opts.body();
|
|
427
|
+
if (!opts.bodyCountsTick) countTick();
|
|
428
|
+
if (opts.logFrom) {
|
|
429
|
+
runtime.lastTickLog = opts.logFrom.lines(value);
|
|
430
|
+
emit(runtime.lastTickLog);
|
|
431
|
+
}
|
|
432
|
+
} catch (err) {
|
|
433
|
+
const msg = `✗ tick 异常: ${String(err?.message ?? err)}`;
|
|
434
|
+
if (opts.logFrom) runtime.lastTickLog = [msg];
|
|
435
|
+
console.warn(`[serenity-hooks] ${msg}(${opts.label})`);
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
};
|
|
439
|
+
/**
|
|
440
|
+
* 幂等启动 + 武装门:**已武装 → 原样返回**(防重复起定时器);
|
|
441
|
+
* **闸关 → 不武装**(零资源占用);闸开则**无条件**武装(硬约束二:不判"有无目标")。
|
|
442
|
+
*/
|
|
443
|
+
const start = () => {
|
|
444
|
+
if (st.timer) return;
|
|
445
|
+
if (opts.events && !st.wired) {
|
|
446
|
+
st.wired = true;
|
|
447
|
+
attachClockEvents(opts.ctx, api, opts.events);
|
|
448
|
+
}
|
|
449
|
+
if (!opts.gate()) return;
|
|
450
|
+
st.timer = setInterval(tick, TICK_MS);
|
|
451
|
+
st.timer.unref();
|
|
452
|
+
runtime.armed = true;
|
|
453
|
+
runtime.armedAt = Date.now();
|
|
454
|
+
console.log(opts.startLog());
|
|
455
|
+
if (opts.immediate !== false) tick();
|
|
456
|
+
};
|
|
457
|
+
const dispose = () => {
|
|
458
|
+
if (st.timer) {
|
|
459
|
+
clearInterval(st.timer);
|
|
460
|
+
st.timer = null;
|
|
461
|
+
}
|
|
462
|
+
runtime.armed = false;
|
|
463
|
+
runtime.armedAt = null;
|
|
464
|
+
opts.onDispose?.();
|
|
465
|
+
};
|
|
466
|
+
const api = {
|
|
467
|
+
start,
|
|
468
|
+
dispose,
|
|
469
|
+
snapshot: () => ({
|
|
470
|
+
enabled: opts.gate(),
|
|
471
|
+
...runtime,
|
|
472
|
+
...runtime.lastTickLog ? { lastTickLog: [...runtime.lastTickLog] } : {}
|
|
473
|
+
}),
|
|
474
|
+
noteSkipReason: (reason) => {
|
|
475
|
+
runtime.lastSkipReason = reason;
|
|
476
|
+
},
|
|
477
|
+
countTick,
|
|
478
|
+
enqueue: (work) => {
|
|
479
|
+
st.chain = st.chain.then(work, () => void 0).then(() => void 0, (err) => {
|
|
480
|
+
console.warn(`[serenity-hooks] ✗ 串行任务异常(${opts.label}): ${String(err?.message ?? err)}`);
|
|
481
|
+
});
|
|
482
|
+
},
|
|
483
|
+
reset: () => {
|
|
484
|
+
if (st.timer) {
|
|
485
|
+
clearInterval(st.timer);
|
|
486
|
+
st.timer = null;
|
|
487
|
+
}
|
|
488
|
+
runtime.armed = false;
|
|
489
|
+
runtime.armedAt = null;
|
|
490
|
+
runtime.lastTickAt = null;
|
|
491
|
+
runtime.ticks = 0;
|
|
492
|
+
runtime.lastSkipReason = null;
|
|
493
|
+
if (opts.logFrom) runtime.lastTickLog = [];
|
|
494
|
+
st.wired = false;
|
|
495
|
+
opts.onReset?.();
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
return api;
|
|
499
|
+
}
|
|
500
|
+
//#endregion
|
|
501
|
+
//#region src/wake-scheduler.ts
|
|
502
|
+
const PLUGIN_SOURCE = {
|
|
503
|
+
kind: "plugin",
|
|
504
|
+
plugin: "dsh-serenity-hooks"
|
|
505
|
+
};
|
|
506
|
+
/**
|
|
507
|
+
* 解析条目的 target(`S###` 或完整目录名)→ 目录名 + SESSION.md 路径。
|
|
508
|
+
* @param root CCC 根
|
|
509
|
+
* @param target 条目里的目标标识
|
|
510
|
+
* @returns 命中返回目录信息,未命中 null
|
|
511
|
+
*/
|
|
512
|
+
function resolveWakeTarget(root, target) {
|
|
513
|
+
const found = findSession(sessionsRoot(root), target);
|
|
514
|
+
if (!found) return null;
|
|
515
|
+
const mdPath = join(found.path, "SESSION.md");
|
|
516
|
+
if (!existsSync(mdPath)) return null;
|
|
517
|
+
return {
|
|
518
|
+
dirName: basename(found.path),
|
|
519
|
+
mdPath
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* 全局闸:**只看 `wakeSchedulerEnabled`**(缺省**开**;显式 `false` 才关)。
|
|
524
|
+
*
|
|
525
|
+
* ⚠️ 2026-09-15 用户裁决(S-1 **解耦**):「auto-trajectory 的开关**只关闭 auto-trajectory 唤醒**」。
|
|
526
|
+
* ⇒ 本函数**不得**再把 `autopilotEnabled`(周期自唤醒闸)作为回退键 —— 旧实现是
|
|
527
|
+
* `trajectoryEnabled === true || autopilotEnabled === true`,后果实测:所有者关掉 auto-trajectory 后,
|
|
528
|
+
* **一次性唤醒 `wake-later` 被连带关掉**,注册表条目静默滞留(当日 6.6h 零 tick)。
|
|
529
|
+
* 缺省由 false 改 true 的理由:唤醒注册表已是一等机制(D58),条目**全部由人类/agent 显式登记**
|
|
530
|
+
* 未来时刻(无环境自主性),不需要"默认关"的实验保护;代价 = 一个 unref 的 5min 空检查。
|
|
531
|
+
*
|
|
532
|
+
* 设置服务不可用 → 关(保守;异常由日志与 `dashboard health` 响亮暴露,不静默)。
|
|
533
|
+
*/
|
|
534
|
+
function wakeSchedulerEnabled() {
|
|
535
|
+
try {
|
|
536
|
+
return readSimpleSettings().wakeSchedulerEnabled !== false;
|
|
537
|
+
} catch {
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* 🔴 **本钟的串行域**(C6b 硬约束):`clock.chain` 是**本实例私有**的——
|
|
543
|
+
* 与 autopilot 的串行链**零共享**。autopilot 每轮跑 CCC 偏见脚本可能阻塞数十秒,
|
|
544
|
+
* 共用链会把一次性唤醒一起卡住(这正是 P1 被否、取 P2 的理由,见 SESSION §12.8②)。
|
|
545
|
+
*
|
|
546
|
+
* **为什么这里是模块级常量**(而不是 `registerWakeScheduler` 的局部量):本钟的进程态是
|
|
547
|
+
* **进程级可观测面**(`acc-diag` ①b / `containerClocks` 在读),且 `wakeSchedulerState()` /
|
|
548
|
+
* `__resetWakeSchedulerStateForTest()` 是**公开导出**、可在未装配时被调用 ⇒ 实例必须**恒在**
|
|
549
|
+
* (旧实现的 `schedulerRuntime` 也正是模块级常量)。装配只做 `clock.start()`。
|
|
550
|
+
*/
|
|
551
|
+
/** 本钟的装配项(`ctx` 由 {@link registerWakeScheduler} 在装配点填入) */
|
|
552
|
+
const clockOpts = {
|
|
553
|
+
label: "trajectory 唤醒调度器",
|
|
554
|
+
ctx: void 0,
|
|
555
|
+
events: ["session/created", "serenity/settings-changed"],
|
|
556
|
+
gate: wakeSchedulerEnabled,
|
|
557
|
+
gateOffReason: "唤醒调度器闸关闭(wakeSchedulerEnabled=false)",
|
|
558
|
+
body: () => runWakeTick(clockOpts.ctx),
|
|
559
|
+
logFrom: {
|
|
560
|
+
prefix: "[serenity-hooks] trajectory 唤醒 ",
|
|
561
|
+
lines: (lines) => lines
|
|
562
|
+
},
|
|
563
|
+
startLog: () => "[serenity-hooks] ✓ trajectory 唤醒调度器启动(5min tick)"
|
|
564
|
+
};
|
|
565
|
+
const clock = createClock(clockOpts);
|
|
566
|
+
/** 调度器进程态快照(只读;`enabled` = 全局闸**当前**值,便于区分"未武装"与"闸关") */
|
|
567
|
+
function wakeSchedulerState() {
|
|
568
|
+
return clock.snapshot();
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* 取目标 agent:live 优先 → 冷会话走 sessionController.resolveAgent → 标题回退。
|
|
572
|
+
* @param ctx 插件上下文
|
|
573
|
+
* @param root CCC 根
|
|
574
|
+
* @param dirName 目标 trajectory 目录名
|
|
575
|
+
* @returns 命中返回 agent 与取得方式(how 进 lastResult,便于诊断);未命中返回原因
|
|
576
|
+
*/
|
|
577
|
+
async function acquireWakeAgent(ctx, root, dirName) {
|
|
578
|
+
const agents = hostAgents(ctx);
|
|
579
|
+
const ids = listBoundSessionIds(root, dirName);
|
|
580
|
+
for (const id of ids) {
|
|
581
|
+
const live = agents?.get?.(id);
|
|
582
|
+
if (live) return {
|
|
583
|
+
agent: live,
|
|
584
|
+
how: `live(bound ${id})`
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
if (ids.length > 0) {
|
|
588
|
+
const sc = hostService(ctx, "sessionController");
|
|
589
|
+
const resolved = sc?.resolveAgent;
|
|
590
|
+
if (typeof resolved !== "function") return { error: `目标会话未加载且 sessionController 不可用(headless profile?)——绑定 id: ${ids[0]}` };
|
|
591
|
+
const id = ids[0];
|
|
592
|
+
try {
|
|
593
|
+
const out = await resolved.call(sc, id);
|
|
594
|
+
if (out?.agent) return {
|
|
595
|
+
agent: out.agent,
|
|
596
|
+
how: `cold-resume(${id})`
|
|
597
|
+
};
|
|
598
|
+
return { error: `sessionController.resolveAgent 未返回 agent(${id}): ${String(out?.error ?? "未知原因")}` };
|
|
599
|
+
} catch (err) {
|
|
600
|
+
return { error: `冷会话载入失败(${id}): ${String(err?.message ?? err)}` };
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return { error: `无绑定会话记录(AGENT_SESSIONS/.bindings.json 中无 ${dirName})——目标轨迹需先被 container_trajectory use 激活过` };
|
|
604
|
+
}
|
|
605
|
+
/** 唤醒消息(唤起即投递正文:身份锚定 + 唤醒信息 + 任务) */
|
|
606
|
+
function buildWakeText(entry, target) {
|
|
607
|
+
return [
|
|
608
|
+
`[trajectory 唤醒] 到点(登记于 ${entry.createdAt},发起者 ${entry.createdBy || "未知"},id ${entry.id})。`,
|
|
609
|
+
"",
|
|
610
|
+
`身份锚定:继续 ${target.dirName} 的 trajectory(SESSION.md: ${target.mdPath})。`,
|
|
611
|
+
"",
|
|
612
|
+
"唤醒信息:",
|
|
613
|
+
entry.message,
|
|
614
|
+
"",
|
|
615
|
+
"任务:按上述唤醒信息继续本轨迹的工作;完成后把进展写入 SESSION.md。"
|
|
616
|
+
].join("\n");
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* 投递一条唤醒(fire-and-forget:`followup` 入队即返回,不等模型结果)。
|
|
620
|
+
* @param ctx 插件上下文
|
|
621
|
+
* @param root CCC 根
|
|
622
|
+
* @param entry 唤醒条目
|
|
623
|
+
* @returns 投递结果(写回 lastResult)
|
|
624
|
+
*/
|
|
625
|
+
async function deliverWake(ctx, root, entry) {
|
|
626
|
+
const target = resolveWakeTarget(root, entry.target);
|
|
627
|
+
if (!target) return {
|
|
628
|
+
ok: false,
|
|
629
|
+
detail: `目标 trajectory 未命中(${entry.target})`
|
|
630
|
+
};
|
|
631
|
+
const acquired = await acquireWakeAgent(ctx, root, target.dirName);
|
|
632
|
+
if ("error" in acquired) return {
|
|
633
|
+
ok: false,
|
|
634
|
+
detail: acquired.error
|
|
635
|
+
};
|
|
636
|
+
try {
|
|
637
|
+
acquired.agent.followup(createUserMessage({
|
|
638
|
+
content: [{
|
|
639
|
+
type: "text",
|
|
640
|
+
text: buildWakeText(entry, target)
|
|
641
|
+
}],
|
|
642
|
+
source: PLUGIN_SOURCE
|
|
643
|
+
}));
|
|
644
|
+
} catch (err) {
|
|
645
|
+
return {
|
|
646
|
+
ok: false,
|
|
647
|
+
detail: `投递失败(${acquired.how}): ${String(err?.message ?? err)}`
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
return {
|
|
651
|
+
ok: true,
|
|
652
|
+
detail: `已投递 ${target.dirName}(${acquired.how})`
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* 即时消息正文(**刻意不复用** {@link buildWakeText}):唤醒文本以「**到点**(登记于…)」开头,
|
|
657
|
+
* 对即时投递那是**错的事实**。本函数显式区分"这是即时消息、不是到点唤醒",
|
|
658
|
+
* 并写明"消息已入队、若此前在跑轮次则本轮结束后生效"(防目标误判时序)。
|
|
659
|
+
* @param target 目标轨迹(目录名 + SESSION.md)
|
|
660
|
+
* @param message 递送正文
|
|
661
|
+
* @param sender 发起者轨迹标识(供目标与人类识别来源)
|
|
662
|
+
*/
|
|
663
|
+
function buildSendText(target, message, sender) {
|
|
664
|
+
return [
|
|
665
|
+
`[即时消息] 来自 ${sender || "未知轨迹"}。`,
|
|
666
|
+
"",
|
|
667
|
+
`身份锚定:继续 ${target.dirName} 的 trajectory(SESSION.md: ${target.mdPath})。`,
|
|
668
|
+
"",
|
|
669
|
+
"消息:",
|
|
670
|
+
message,
|
|
671
|
+
"",
|
|
672
|
+
"说明:这是**即时投递**(不是到点唤醒)——消息已进入你的队列;你此前若正在跑轮次,它在本轮结束后生效。处理完照常把进展写入 SESSION.md。"
|
|
673
|
+
].join("\n");
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* 即时投递(`container_trajectory send-message`)——与唤醒**共用取用通路**,
|
|
677
|
+
* 区别只在**时刻**(现在 vs 未来)与**回执**(有 vs 无):
|
|
678
|
+
* · `wake-later`:未来时刻 + 一条 message → 落注册表 → 本文件的调度器到点投递(**fire-and-forget,无回执**)
|
|
679
|
+
* · `send-message`:**此刻**把一条 message 递进目标队列 → **同步回执** → **不落注册表**
|
|
680
|
+
*
|
|
681
|
+
* **为什么冷路径落在这里(R↓,2026-09-16 所有者裁决)**:`addWake` 显式拒绝 `at ≤ now`
|
|
682
|
+
* ("时刻必须在未来"——那是"**预约**"语义的地基,不许放宽)⇒ 即时投递**不能**靠一条
|
|
683
|
+
* `at=now` 的注册表条目实现(那还会让调度器把一次性即时投递当 `pending` **每 tick 重试**)。
|
|
684
|
+
* 必须直接走 {@link acquireWakeAgent}(live 优先 → `sessionController.resolveAgent` 冷载入)。
|
|
685
|
+
* 这正对应所有者要的「**会话不活跃则等效于直接 wake**」:**同一条冷载入通路**,只是**不等 tick**。
|
|
686
|
+
*
|
|
687
|
+
* ⚠️ **承诺边界(不许含糊)**:回执只到「**已入队**」(`followup` 未抛错),
|
|
688
|
+
* **到不了**"目标已执行/已答复" —— 与 `deliverWake` 同一层。目标若正在跑轮次,
|
|
689
|
+
* 消息**排在轮次边界**(**不打断当前轮**)。判"目标真的动了"必须用**文件级判据**
|
|
690
|
+
* (它自己的 SESSION.md),**不得凭回执结案**(§12.B 的 `delivered ≠ 跑了一轮`)。
|
|
691
|
+
*
|
|
692
|
+
* **归属**:本函数放在调度器侧而非工具侧 —— "怎么把一句话送到一条轨迹"是**调度器的知识**,
|
|
693
|
+
* 工具只该知道"有这个动作"。这样 `trajectory.ts` 完全不碰调度器内部(v1.35.0 硬约束①:
|
|
694
|
+
* `wake-later` 链路**一行为不动**,本次改动 = **纯新增**)。
|
|
695
|
+
*
|
|
696
|
+
* @param ctx 插件上下文
|
|
697
|
+
* @param root CCC 根
|
|
698
|
+
* @param target `S###` 或 AGENT_SESSIONS 目录名
|
|
699
|
+
* @param message 递送正文
|
|
700
|
+
* @param sender 发起者轨迹标识
|
|
701
|
+
* @returns 投递结果(**同步**返回给调用方)
|
|
702
|
+
*/
|
|
703
|
+
async function sendToTrajectory(ctx, root, target, message, sender) {
|
|
704
|
+
const t = resolveWakeTarget(root, target);
|
|
705
|
+
if (!t) return {
|
|
706
|
+
ok: false,
|
|
707
|
+
detail: `目标 trajectory 未命中(${target})`
|
|
708
|
+
};
|
|
709
|
+
const acquired = await acquireWakeAgent(ctx, root, t.dirName);
|
|
710
|
+
if ("error" in acquired) return {
|
|
711
|
+
ok: false,
|
|
712
|
+
detail: acquired.error
|
|
713
|
+
};
|
|
714
|
+
try {
|
|
715
|
+
acquired.agent.followup(createUserMessage({
|
|
716
|
+
content: [{
|
|
717
|
+
type: "text",
|
|
718
|
+
text: buildSendText(t, message, sender)
|
|
719
|
+
}],
|
|
720
|
+
source: PLUGIN_SOURCE
|
|
721
|
+
}));
|
|
722
|
+
} catch (err) {
|
|
723
|
+
return {
|
|
724
|
+
ok: false,
|
|
725
|
+
detail: `投递失败(${acquired.how}): ${String(err?.message ?? err)}`
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
return {
|
|
729
|
+
ok: true,
|
|
730
|
+
detail: `已即时投递 ${t.dirName}(${acquired.how})`
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* 本 tick 要扫的 CCC 根集合(**并集**)。
|
|
735
|
+
*
|
|
736
|
+
* C2(S142 2026-09-15 Q5):原实现是 `collectLiveCccs`——**只认 live 会话** ⇒
|
|
737
|
+
* "完全冷掉的 CCC 不能自唤醒自己"(已知边界)。现改为 `ccc-roots.listCccs` 的并集枚举
|
|
738
|
+
* (工作区注册表 ∪ 持久化会话 ∪ live 会话):**没有 live 会话的 CCC 也会被扫到**。
|
|
739
|
+
*
|
|
740
|
+
* ⚠️ 边界(本步**只**改"枚举哪些 CCC",**不改**"怎么找到要交付的 agent"):
|
|
741
|
+
* 把 message 交给某条 trajectory 仍必须靠 **live 会话**解析(见本文件
|
|
742
|
+
* `acquireWakeAgent`);无 live 会话时该 CCC 只是**被扫到、
|
|
743
|
+
* 无交付对象** ⇒ `deliverWake` 返回 `无绑定会话记录…`,条目回 `pending` 并留痕。
|
|
744
|
+
* 因此本函数**不得**让 tick 抛错(异常一律由调用方 tick 兜住,见 `runWakeTick` 的 try)。
|
|
745
|
+
* @param ctx 插件上下文
|
|
746
|
+
* @returns CCC 根列表(无任何来源 → `[]`)
|
|
747
|
+
*/
|
|
748
|
+
async function collectWakeCccs(ctx) {
|
|
749
|
+
return (await listCccs(ctx)).map((e) => e.root);
|
|
750
|
+
}
|
|
751
|
+
/** 一次调度 tick:遍历已知 CCC → 到期项串行投递 / 超窗项置 missed;返回人读摘要 */
|
|
752
|
+
async function runWakeTick(ctx) {
|
|
753
|
+
const log = [];
|
|
754
|
+
const roots = await collectWakeCccs(ctx).catch(() => []);
|
|
755
|
+
if (roots.length === 0) {
|
|
756
|
+
clock.noteSkipReason("无已知 CCC 可扫(等某个会话/工作区出现)");
|
|
757
|
+
return log;
|
|
758
|
+
}
|
|
759
|
+
clock.noteSkipReason(null);
|
|
760
|
+
for (const root of roots) {
|
|
761
|
+
const { registry, error } = loadWakeRegistry(root);
|
|
762
|
+
if (error) {
|
|
763
|
+
log.push(`✗ ${root}: ${error}`);
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
const { due, expired } = splitDueWakes(registry, Date.now(), WAKE_CATCH_UP_MS);
|
|
767
|
+
for (const e of expired) {
|
|
768
|
+
updateWake(root, e.id, {
|
|
769
|
+
state: "missed",
|
|
770
|
+
lastResult: `超过补跑窗口(${WAKE_CATCH_UP_MS / 36e5}h)未投递`
|
|
771
|
+
});
|
|
772
|
+
log.push(`· ${root}: ${e.id} → missed(超补跑窗口)`);
|
|
773
|
+
}
|
|
774
|
+
for (const e of due) {
|
|
775
|
+
const res = await deliverWake(ctx, root, e);
|
|
776
|
+
updateWake(root, e.id, {
|
|
777
|
+
state: res.ok ? "delivered" : "pending",
|
|
778
|
+
attempts: e.attempts + 1,
|
|
779
|
+
lastResult: res.detail,
|
|
780
|
+
deliveredAt: res.ok ? (/* @__PURE__ */ new Date()).toISOString() : null
|
|
781
|
+
});
|
|
782
|
+
log.push(`· ${root}: ${e.id} → ${res.ok ? "delivered" : `重试(${res.detail})`}`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return log;
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* 装配调度器(index.ts apply 调用):5min tick + 生命周期 disposer +
|
|
789
|
+
* 会话出现/settings 变化时热启动(与 autopilot 同款触发面,互不干扰)。
|
|
790
|
+
* @param ctx 插件上下文
|
|
791
|
+
*/
|
|
792
|
+
function registerWakeScheduler(ctx) {
|
|
793
|
+
clockOpts.ctx = ctx;
|
|
794
|
+
clock.start();
|
|
795
|
+
registerDisposer(ctx, "trajectory 唤醒调度器", () => clock.dispose());
|
|
796
|
+
}
|
|
797
|
+
//#endregion
|
|
798
|
+
export { WAKE_CATCH_UP_MS as a, registerDisposer as i, sendToTrajectory as n, addWake as o, wakeSchedulerState as r, listWakes as s, registerWakeScheduler as t };
|