@shgroup/dsh-serenity-hooks 1.41.0 → 1.43.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 +27 -0
- package/dsh.plugin.json +1 -1
- package/lib/cro-guide.d.ts +48 -0
- package/lib/cro-turns.d.ts +95 -0
- package/lib/cro.d.ts +306 -0
- package/lib/diag-ops.d.ts +3 -1
- package/lib/index.js +218 -10
- package/lib/tools/trajectory.d.ts +4 -1
- package/lib/{trajectory-bound-4xkZDfmO.js → trajectory-bound-kxvMuCui.js} +3 -2
- package/lib/trajectory-ops.d.ts +8 -2
- package/lib/wake-registry.d.ts +53 -0
- package/lib/{wake-scheduler-BRnPUnER.js → wake-scheduler-DC4-imVO.js} +833 -15
- package/lib/wake-scheduler.d.ts +19 -0
- package/package.json +1 -1
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { r as hostService, t as hostAgents } from "./access-fiehjxV6.js";
|
|
2
|
+
import { h as resolveInside } from "./ccc-NlLr_sxy.js";
|
|
2
3
|
import { o as listCccs } from "./ccc-roots-Bd_SjEs7.js";
|
|
3
|
-
import { D as localHuman, O as localIdStamp, T as isoLocal, b as sessionsRoot, n as listBoundSessionIds, p as findSession } from "./trajectory-bound-
|
|
4
|
+
import { D as localHuman, O as localIdStamp, T as isoLocal, b as sessionsRoot, n as listBoundSessionIds, p as findSession } from "./trajectory-bound-kxvMuCui.js";
|
|
4
5
|
import { t as readSimpleSettings } from "./settings-section-DMDUoQum.js";
|
|
5
6
|
import { basename, dirname, join } from "node:path";
|
|
6
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
7
9
|
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
8
10
|
//#region src/wake-registry.ts
|
|
9
11
|
/**
|
|
@@ -230,7 +232,109 @@ function splitDueWakes(registry, nowMs, catchUpMs = WAKE_CATCH_UP_MS) {
|
|
|
230
232
|
};
|
|
231
233
|
}
|
|
232
234
|
/**
|
|
235
|
+
* 🔴 结案并**移除**一条(owner 2026-09-19 裁定「**直接删**」,S142 §0Q 甲案)。
|
|
236
|
+
*
|
|
237
|
+
* ## 为什么删(R↓)
|
|
238
|
+
*
|
|
239
|
+
* 本模块原本是「`pending` → 终态」但**终态条目永不移除** ⇒ 文件**只增**(实测 308 KB / 143+ 条)。
|
|
240
|
+
* owner 明令「应当**唤醒后就清理**的」⇒ 终态即删。
|
|
241
|
+
*
|
|
242
|
+
* **审计不丢**:由 **SESSION.md 与工具输出**承担——这正是本文件 `removeWake` 的既有注释承诺
|
|
243
|
+
* (*"审计由 SESSION.md 与工具输出承担——注册表只保留在办项"*)。
|
|
244
|
+
* ⚠️ 此前该承诺**并未兑现**(`updateWake` 只改 state 从不删行)⇒ 本次是**把承诺做实**。
|
|
245
|
+
*
|
|
246
|
+
* ## 与 `updateWake` 的分工(**别混用**)
|
|
247
|
+
*
|
|
248
|
+
* - `updateWake`:**仍在办**的推进(失败重试 `state:'pending'` + `attempts+1`)⇒ **保留行**;
|
|
249
|
+
* - `finalizeWake`:**不再办**的归宿(投递成功 / 超窗 missed)⇒ 写终态后**同一笔事务里删掉**。
|
|
250
|
+
*
|
|
251
|
+
* 🔴 **为什么两个动作要同一次写盘**:若先 `updateWake(delivered)` 再 `removeWake`,
|
|
252
|
+
* 中间被打断会留下一条"已终结但没删"的条目 ⇒ 又回到只增。
|
|
253
|
+
* 故本函数**读一次、改一次、删一次、写一次**(单次原子落盘)。
|
|
254
|
+
*
|
|
255
|
+
* @param root CCC 根
|
|
256
|
+
* @param id 条目 id
|
|
257
|
+
* @param terminal 终态(`delivered` / `missed` / `cancelled`)——写入仅用于**日志文案**,
|
|
258
|
+
* 随即整行移除;保留该参数是为了让调用点语义自明("它归到哪一档了")。
|
|
259
|
+
* @param result 人读结果(进日志,不进表)
|
|
260
|
+
* @returns `removed` = 被移除的条目(供调用方打印"它是什么");不存在 → 稳定错误码
|
|
261
|
+
*/
|
|
262
|
+
function finalizeWake(root, id, terminal, result) {
|
|
263
|
+
const { registry, error } = loadWakeRegistry(root);
|
|
264
|
+
if (error) return {
|
|
265
|
+
ok: false,
|
|
266
|
+
error,
|
|
267
|
+
removed: null
|
|
268
|
+
};
|
|
269
|
+
const idx = registry.entries.findIndex((e) => e.id === id);
|
|
270
|
+
if (idx < 0) return {
|
|
271
|
+
ok: false,
|
|
272
|
+
error: `wake_not_found: ${id}`,
|
|
273
|
+
removed: null
|
|
274
|
+
};
|
|
275
|
+
const [removed] = registry.entries.splice(idx, 1);
|
|
276
|
+
const saved = saveWakeRegistry(root, registry);
|
|
277
|
+
if (!saved.ok) return {
|
|
278
|
+
ok: false,
|
|
279
|
+
error: saved.error,
|
|
280
|
+
removed: null
|
|
281
|
+
};
|
|
282
|
+
return {
|
|
283
|
+
ok: true,
|
|
284
|
+
error: null,
|
|
285
|
+
removed: {
|
|
286
|
+
...removed,
|
|
287
|
+
state: terminal,
|
|
288
|
+
lastResult: result
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* 一次性清除**存量终态条目**(`delivered` / `missed` / `cancelled`)。
|
|
294
|
+
*
|
|
295
|
+
* ## 为什么需要(不是可选)
|
|
296
|
+
*
|
|
297
|
+
* 本函数上线前,表里已积压 143+ 条终态记录(308 KB)⇒ **只改行为不改存量 = 文件不再涨但仍很大**。
|
|
298
|
+
* 故首次运行须清一次。**幂等**(无终态条目时空转,不写盘)。
|
|
299
|
+
*
|
|
300
|
+
* 审计不丢:与 `finalizeWake` 同理(终态本就属性"已结案",其史由 SESSION.md 承担)。
|
|
301
|
+
* @param root CCC 根
|
|
302
|
+
* @returns 清掉几条(0 = 本来就没有)
|
|
303
|
+
*/
|
|
304
|
+
function purgeFinalizedWakes(root) {
|
|
305
|
+
const { registry, error } = loadWakeRegistry(root);
|
|
306
|
+
if (error) return {
|
|
307
|
+
ok: false,
|
|
308
|
+
error,
|
|
309
|
+
purged: 0
|
|
310
|
+
};
|
|
311
|
+
const kept = registry.entries.filter((e) => e.state === "pending");
|
|
312
|
+
const purged = registry.entries.length - kept.length;
|
|
313
|
+
if (purged === 0) return {
|
|
314
|
+
ok: true,
|
|
315
|
+
error: null,
|
|
316
|
+
purged: 0
|
|
317
|
+
};
|
|
318
|
+
const saved = saveWakeRegistry(root, {
|
|
319
|
+
...registry,
|
|
320
|
+
entries: kept
|
|
321
|
+
});
|
|
322
|
+
if (!saved.ok) return {
|
|
323
|
+
ok: false,
|
|
324
|
+
error: saved.error,
|
|
325
|
+
purged: 0
|
|
326
|
+
};
|
|
327
|
+
return {
|
|
328
|
+
ok: true,
|
|
329
|
+
error: null,
|
|
330
|
+
purged
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
233
334
|
* 更新一条(状态推进;调用方给最终状态与结果文案)。
|
|
335
|
+
*
|
|
336
|
+
* ⚠️ **只用于"仍在办"的推进**(主要是失败重试)。**终态请用 `finalizeWake`**——
|
|
337
|
+
* 否则该行会永远留下(这正是本表过去只增的根因,见 `finalizeWake` 注释)。
|
|
234
338
|
* @param root CCC 根
|
|
235
339
|
* @param id 条目 id
|
|
236
340
|
* @param patch 要写入的字段
|
|
@@ -259,6 +363,459 @@ function listWakes(root, opts = {}) {
|
|
|
259
363
|
};
|
|
260
364
|
}
|
|
261
365
|
//#endregion
|
|
366
|
+
//#region src/cro.ts
|
|
367
|
+
/**
|
|
368
|
+
* cro.ts — CRO(Continuous Re-Occurrence · 持续再发生)机制层(S142 §7.8,2026-09-19)
|
|
369
|
+
*
|
|
370
|
+
* ## 这东西用来干什么(白话)
|
|
371
|
+
*
|
|
372
|
+
* 今天叫醒一条轨迹的方式是「几点几分叫我」。但**该不该醒,往往不是时间说了算**:
|
|
373
|
+
* 比如 S185 应该「天亮 + 家里有人 + 非高峰」才醒;已经在干活就不该再叫;
|
|
374
|
+
* 日志太大了,下次叫它时该让它先整理。
|
|
375
|
+
*
|
|
376
|
+
* CRO 让**一条轨迹自己带一段程序**,由 ACC 在每次检查时跑它,
|
|
377
|
+
* **由这段程序决定「现在该不该叫我、叫我的时候说什么」**。
|
|
378
|
+
*
|
|
379
|
+
* ## 归属(所有者 2026-09-19 定:**机制属 ACC,CCC 是用户**)
|
|
380
|
+
*
|
|
381
|
+
* | 面 | 归谁 | 内容 |
|
|
382
|
+
* |---|---|---|
|
|
383
|
+
* | **机制** | 🔴 **ACC(本模块)** | 契约 / 调度 / 执行 / 状态暴露 / 容错 |
|
|
384
|
+
* | **程序** | 🔴 **CCC(用户)** | 那段判定的 TS 代码,放**轨迹自己的目录**里 |
|
|
385
|
+
*
|
|
386
|
+
* ⇒ 与 `send-later` 同构:**工具在 ACC,用它在 CCC**。
|
|
387
|
+
*
|
|
388
|
+
* ## 命名(防混淆,写死)
|
|
389
|
+
*
|
|
390
|
+
* **`CRO` = 那段程序**(实体名),**不是** ACC 标准 §0.1 三环节(发生/存储/**再发生**)的第三环本身。
|
|
391
|
+
* 命名来源 = §0.3「**Trajectory 在寻找 Agent**」——CRO 是「再发生」这一环的自动化。
|
|
392
|
+
*
|
|
393
|
+
* ## 位置与形态(设计 §2)
|
|
394
|
+
*
|
|
395
|
+
* `<CCC 根>/AGENT_SESSIONS/<轨迹目录>/continuous-re-occurrence.ts`
|
|
396
|
+
* · **文件名全写**(所有者令);· **程序只有一个**:**文件在 = 启用,不在 = 禁用**(无 enabled 字段、无注册表)。
|
|
397
|
+
*
|
|
398
|
+
* ## 🔴 运行契约 = B 案(**ACC 只 spawn,从不 import**)
|
|
399
|
+
*
|
|
400
|
+
* 所有者 2026-09-19:「**B 是设计,A 只是文档友好**」。
|
|
401
|
+
* 若 ACC **import** 那段 TS,会出现两件事:
|
|
402
|
+
* ① ACC 依赖 CCC 的**源码路径**(npm 装机版在别处 ⇒ 两条路径都要活 = 两个真相源);
|
|
403
|
+
* ② **用户程序语法错会让 ACC 启动失败** ⇒ **一个用户程序的错误放倒整个容器**。
|
|
404
|
+
* ⇒ 进程边界把这两件事同时挡掉:**路径只需一个(文件系统),错误被隔离在子进程里**。
|
|
405
|
+
*
|
|
406
|
+
* ## 🔴 铁律:CRO 的任何失败,绝不影响既有机制(设计 §5)
|
|
407
|
+
*
|
|
408
|
+
* `send-later` / `send-now` / 唤醒表的投递**照常工作**。
|
|
409
|
+
* 先例 = `weixin-hook.ts` 的**旁路容忍**("超时 kill / 非 0 退出 / spawn 失败 → 仅日志返回,绝不抛")。
|
|
410
|
+
* ⇒ 本模块的**每一个**导出函数**都不抛错**(返回结构化失败)。
|
|
411
|
+
*/
|
|
412
|
+
/** 入口文件名(**全写**,所有者令 2026-09-19;不改缩写) */
|
|
413
|
+
const CRO_FILENAME = "continuous-re-occurrence.ts";
|
|
414
|
+
/** 硬超时(沿用 `biasProvider` 先例的 60s;设计 §5 情形 2) */
|
|
415
|
+
const CRO_TIMEOUT_MS = 6e4;
|
|
416
|
+
/** stdout 上限(防一个坏程序把日志撑爆;沿用 hook 截断思路) */
|
|
417
|
+
const CRO_STDOUT_MAX = 65536;
|
|
418
|
+
/** SESSION.md 文件名(与 `trajectory-ops.ts` 同值;此处本地常量避免反向依赖) */
|
|
419
|
+
const SESSION_MD = "SESSION.md";
|
|
420
|
+
/**
|
|
421
|
+
* 展示码派生(**只用于展示**,不作识别依据 —— §0I U4:锚永远是 `dirName`)。
|
|
422
|
+
*
|
|
423
|
+
* 判据(设计 §1):**编号不是固定格式,不同 CCC 格式不同**(本容器 `S###`,别处可能是 issue 号
|
|
424
|
+
* 或自定义前缀)⇒ 这里只做**尽力而为的展示性提取**,取不到就返回空串。
|
|
425
|
+
* 🔴 **绝不**因为取不到就拒绝服务——`dirName` 才是硬锚。
|
|
426
|
+
* @param dirName 轨迹目录名
|
|
427
|
+
* @returns `S###` 形态的展示码;取不到 ⇒ `''`
|
|
428
|
+
*/
|
|
429
|
+
function deriveCode(dirName) {
|
|
430
|
+
const m = /(?:^|--)S(\d{2,})(?:--|$)/.exec(dirName);
|
|
431
|
+
return m ? `S${m[1]}` : "";
|
|
432
|
+
}
|
|
433
|
+
/** epoch ms → 当地 ISO(不可解析 ⇒ null) */
|
|
434
|
+
function isoOrNull(ms) {
|
|
435
|
+
return typeof ms === "number" && Number.isFinite(ms) ? isoLocal(ms) : null;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* 装配快照(**纯函数**:只做数据整形,不读 fs、不看 ctx)⇒ 可穷举测试。
|
|
439
|
+
* @param input 已读好的原始数据
|
|
440
|
+
* @returns 喂给 CRO 程序的快照
|
|
441
|
+
*/
|
|
442
|
+
function buildCroSnapshot(input) {
|
|
443
|
+
return {
|
|
444
|
+
version: 1,
|
|
445
|
+
identity: {
|
|
446
|
+
dirName: input.dirName,
|
|
447
|
+
code: deriveCode(input.dirName),
|
|
448
|
+
cccRoot: input.cccRoot
|
|
449
|
+
},
|
|
450
|
+
time: {
|
|
451
|
+
now: isoLocal(input.nowMs),
|
|
452
|
+
nowMs: input.nowMs,
|
|
453
|
+
nowLocal: new Date(input.nowMs).toString()
|
|
454
|
+
},
|
|
455
|
+
body: {
|
|
456
|
+
sessionMdPath: input.sessionMdPath,
|
|
457
|
+
sessionMdBytes: input.sessionMdBytes,
|
|
458
|
+
sessionMdMtime: isoOrNull(input.sessionMdMtimeMs),
|
|
459
|
+
references: input.references.map((r) => ({
|
|
460
|
+
name: r.name,
|
|
461
|
+
bytes: r.bytes,
|
|
462
|
+
mtime: isoLocal(r.mtimeMs)
|
|
463
|
+
}))
|
|
464
|
+
},
|
|
465
|
+
binding: {
|
|
466
|
+
boundSessionIds: input.boundSessionIds,
|
|
467
|
+
liveSessionIds: input.liveSessionIds,
|
|
468
|
+
runningSessionIds: input.runningSessionIds
|
|
469
|
+
},
|
|
470
|
+
scheduling: {
|
|
471
|
+
pendingWakes: input.pendingWakes,
|
|
472
|
+
scheduler: input.scheduler
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* 解析 CRO 程序 stdout(**纯函数** ⇒ 可穷举测试)。
|
|
478
|
+
*
|
|
479
|
+
* ## 边界(设计 §4.1)
|
|
480
|
+
* · `wake` 缺省 / `false` ⇒ **不唤起**(**这是常态**)——`prompt` 可省;
|
|
481
|
+
* · `wake: true` 但 `prompt` 空 / 非串 ⇒ 🔴 **非法** ⇒ 返回错误(调用方**跳过本轮**,不投递空消息);
|
|
482
|
+
* · 非 JSON / 非对象 ⇒ 错误。
|
|
483
|
+
*
|
|
484
|
+
* ## 🔴 为什么 `reason` 重要(CCE:重建 > 保存)
|
|
485
|
+
* 改成程序判定后,**「当时为什么叫了」不再能从时间表重建**(原因在程序肚子里:
|
|
486
|
+
* 可能有随机、可能看了外部数据)⇒ 要求程序自报理由,把「决策依据」重新变成**可重建的**。
|
|
487
|
+
* ⇒ **强烈建议但不强制**(强制会让简单程序难写;设计 §9-6 待裁,本版取"建议")。
|
|
488
|
+
*
|
|
489
|
+
* @param stdout 程序标准输出(可含前后空白;允许多行 JSON 文本)
|
|
490
|
+
* @returns 决策 或 结构化错误
|
|
491
|
+
*/
|
|
492
|
+
function parseCroOutput(stdout) {
|
|
493
|
+
const text = stdout.trim();
|
|
494
|
+
if (text === "") return {
|
|
495
|
+
ok: false,
|
|
496
|
+
error: "cro-empty-output",
|
|
497
|
+
detail: "程序没有输出(期望 stdout 一行 JSON)"
|
|
498
|
+
};
|
|
499
|
+
let raw;
|
|
500
|
+
try {
|
|
501
|
+
raw = JSON.parse(text);
|
|
502
|
+
} catch (err) {
|
|
503
|
+
return {
|
|
504
|
+
ok: false,
|
|
505
|
+
error: "cro-bad-json",
|
|
506
|
+
detail: `输出不是合法 JSON: ${String(err?.message ?? err)}`
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
|
|
510
|
+
ok: false,
|
|
511
|
+
error: "cro-bad-json",
|
|
512
|
+
detail: "输出必须是 JSON 对象"
|
|
513
|
+
};
|
|
514
|
+
const obj = raw;
|
|
515
|
+
const wake = obj.wake === true;
|
|
516
|
+
const reason = typeof obj.reason === "string" && obj.reason.trim() !== "" ? obj.reason.trim() : null;
|
|
517
|
+
if (!wake) return {
|
|
518
|
+
ok: true,
|
|
519
|
+
decision: {
|
|
520
|
+
wake: false,
|
|
521
|
+
prompt: null,
|
|
522
|
+
reason
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
if (typeof obj.prompt !== "string" || obj.prompt.trim() === "") return {
|
|
526
|
+
ok: false,
|
|
527
|
+
error: "cro-missing-prompt",
|
|
528
|
+
detail: "wake=true 但 prompt 缺失或为空"
|
|
529
|
+
};
|
|
530
|
+
return {
|
|
531
|
+
ok: true,
|
|
532
|
+
decision: {
|
|
533
|
+
wake: true,
|
|
534
|
+
prompt: obj.prompt.trim(),
|
|
535
|
+
reason
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* CRO 程序路径:`<CCC 根>/AGENT_SESSIONS/<dirName>/continuous-re-occurrence.ts`。
|
|
541
|
+
*
|
|
542
|
+
* ⚠️ 路径逃逸校验(`resolveInside`):`dirName` 来自配置/工具入参,
|
|
543
|
+
* 必须确保解析后仍在 CCC 根内(同 `weixin-hook.ts:160` 手法)。**抛错由调用方吞**。
|
|
544
|
+
* @param root CCC 根
|
|
545
|
+
* @param dirName 轨迹目录名
|
|
546
|
+
* @returns 绝对路径
|
|
547
|
+
*/
|
|
548
|
+
function croScriptPath(root, dirName) {
|
|
549
|
+
return resolveInside(root, join("AGENT_SESSIONS", dirName, CRO_FILENAME));
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* 列出**启用了 CRO 的轨迹目录名**(供调度器每 tick 扫描,设计 §6.1)。
|
|
553
|
+
*
|
|
554
|
+
* ## 判据与形态(R↓)
|
|
555
|
+
* · 判据 = **文件在不在**(同 §2.1)——**没有注册表**,所以"谁启用了"只能靠**扫目录**:
|
|
556
|
+
* `AGENT_SESSIONS/` 下每个目录查一次 `<目录>/continuous-re-occurrence.ts`。
|
|
557
|
+
* · **一次 readdir + 每个目录一次 existsSync**:N 条轨迹的代价是 O(N) 次 `stat`,
|
|
558
|
+
* 每 5min 一次 —— 与既有 `listSessions` 同量级,可接受。
|
|
559
|
+
* · 跳过 **`_` 前缀**(`_archived` / `_skiff-logs` / `_weixin-logs` = 系统与日志目录)
|
|
560
|
+
* 与 **`.` 前缀**(隐藏)——它们**不是轨迹**,且扫它们纯属浪费。
|
|
561
|
+
* · 🔴 **本函数绝不抛错**(返回 `[]`)——它跑在调度器 tick 内,任何异常都可能影响既有链路
|
|
562
|
+
* (设计 §5 铁律)。目录读不到(不存在 / 权限)⇒ `[]` = "没有轨迹启用 CRO",语义正确。
|
|
563
|
+
*
|
|
564
|
+
* ⚠️ **为什么不做缓存**:缓存会引入"文件删了但缓存还在"的失效模式(本容器栽过的"第二真相源"),
|
|
565
|
+
* 而这里省下的只是一次 `readdir`——**不值当**。
|
|
566
|
+
*
|
|
567
|
+
* @param root CCC 根
|
|
568
|
+
* @returns 启用了 CRO 的轨迹目录名(**排序后**,保证同 tick 顺序稳定、便于日志比对)
|
|
569
|
+
*/
|
|
570
|
+
function listCroTrajectories(root) {
|
|
571
|
+
const rootDir = sessionsRoot(root);
|
|
572
|
+
let entries;
|
|
573
|
+
try {
|
|
574
|
+
entries = readdirSync(rootDir, { withFileTypes: true });
|
|
575
|
+
} catch {
|
|
576
|
+
return [];
|
|
577
|
+
}
|
|
578
|
+
const out = [];
|
|
579
|
+
for (const e of entries) {
|
|
580
|
+
if (!e.isDirectory()) continue;
|
|
581
|
+
if (e.name.startsWith("_") || e.name.startsWith(".")) continue;
|
|
582
|
+
try {
|
|
583
|
+
if (existsSync(join(rootDir, e.name, "continuous-re-occurrence.ts"))) out.push(e.name);
|
|
584
|
+
} catch {}
|
|
585
|
+
}
|
|
586
|
+
return out.sort();
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* 执行一次 CRO 程序(**永不抛** —— 旁路容忍铁律)。
|
|
590
|
+
*
|
|
591
|
+
* runner 顺序(照 `weixin-hook.ts:171-174` 的 bun 优先 / node 兜底):
|
|
592
|
+
* `bun` → `process.execPath`(同运行时)。
|
|
593
|
+
* 判据:`ENOENT`(二进制不存在)⇒ 试下一个;**其余失败视为最终结果**
|
|
594
|
+
* (程序自己报错就是报错,不该用另一个 runner 掩盖——与 hook 一致)。
|
|
595
|
+
* @param scriptAbs 程序绝对路径
|
|
596
|
+
* @param stdinJson 喂给程序的快照 JSON
|
|
597
|
+
* @param timeoutMs 硬超时
|
|
598
|
+
* @returns 执行结果(含输出的解析交由 `parseCroOutput`)
|
|
599
|
+
*/
|
|
600
|
+
async function runCroProcess(scriptAbs, stdinJson, timeoutMs = CRO_TIMEOUT_MS) {
|
|
601
|
+
const runners = [["bun", [scriptAbs]], [process.execPath, [scriptAbs]]];
|
|
602
|
+
for (const [cmd, args] of runners) {
|
|
603
|
+
const res = await runCroOnce(cmd, args, stdinJson, timeoutMs);
|
|
604
|
+
if (!res.ok && res.code === "ENOENT") continue;
|
|
605
|
+
return res;
|
|
606
|
+
}
|
|
607
|
+
return {
|
|
608
|
+
ok: false,
|
|
609
|
+
stdout: "",
|
|
610
|
+
error: "CRO 执行失败(bun 与 node 均不可用)",
|
|
611
|
+
code: "cro-no-runner"
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
/** 单次 spawn(Promise 化 + 超时 kill + 输出截断 + **绝不抛**) */
|
|
615
|
+
function runCroOnce(cmd, args, stdinJson, timeoutMs) {
|
|
616
|
+
return new Promise((resolvePromise) => {
|
|
617
|
+
let settled = false;
|
|
618
|
+
let stdout = "";
|
|
619
|
+
let stderr = "";
|
|
620
|
+
let child = null;
|
|
621
|
+
try {
|
|
622
|
+
child = spawn(cmd, args, {
|
|
623
|
+
stdio: [
|
|
624
|
+
"pipe",
|
|
625
|
+
"pipe",
|
|
626
|
+
"pipe"
|
|
627
|
+
],
|
|
628
|
+
env: { ...process.env }
|
|
629
|
+
});
|
|
630
|
+
} catch (err) {
|
|
631
|
+
return resolvePromise({
|
|
632
|
+
ok: false,
|
|
633
|
+
stdout: "",
|
|
634
|
+
error: String(err),
|
|
635
|
+
code: "SPAWN_ERR"
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
const timer = setTimeout(() => {
|
|
639
|
+
if (settled) return;
|
|
640
|
+
settled = true;
|
|
641
|
+
child?.kill("SIGKILL");
|
|
642
|
+
resolvePromise({
|
|
643
|
+
ok: false,
|
|
644
|
+
stdout,
|
|
645
|
+
error: `CRO 执行超时(>${Math.round(timeoutMs / 1e3)}s,已 kill)`,
|
|
646
|
+
code: "cro-timeout"
|
|
647
|
+
});
|
|
648
|
+
}, timeoutMs);
|
|
649
|
+
child.stdin?.on("error", () => {});
|
|
650
|
+
try {
|
|
651
|
+
child.stdin?.write(stdinJson);
|
|
652
|
+
child.stdin?.end();
|
|
653
|
+
} catch {}
|
|
654
|
+
child.stdout?.on("data", (d) => {
|
|
655
|
+
stdout = (stdout + d.toString()).slice(0, CRO_STDOUT_MAX);
|
|
656
|
+
});
|
|
657
|
+
child.stderr?.on("data", (d) => {
|
|
658
|
+
stderr = (stderr + d.toString()).slice(0, CRO_STDOUT_MAX);
|
|
659
|
+
});
|
|
660
|
+
child.on("error", (err) => {
|
|
661
|
+
if (settled) return;
|
|
662
|
+
settled = true;
|
|
663
|
+
clearTimeout(timer);
|
|
664
|
+
resolvePromise({
|
|
665
|
+
ok: false,
|
|
666
|
+
stdout,
|
|
667
|
+
error: String(err.message ?? err),
|
|
668
|
+
code: err.code
|
|
669
|
+
});
|
|
670
|
+
});
|
|
671
|
+
child.on("close", (code) => {
|
|
672
|
+
if (settled) return;
|
|
673
|
+
settled = true;
|
|
674
|
+
clearTimeout(timer);
|
|
675
|
+
if (code === 0) resolvePromise({
|
|
676
|
+
ok: true,
|
|
677
|
+
stdout,
|
|
678
|
+
error: null
|
|
679
|
+
});
|
|
680
|
+
else {
|
|
681
|
+
const detail = stderr.trim().split("\n")[0] ?? "";
|
|
682
|
+
resolvePromise({
|
|
683
|
+
ok: false,
|
|
684
|
+
stdout,
|
|
685
|
+
error: `CRO 程序退出码 ${code}${detail ? `:${detail}` : ""}`,
|
|
686
|
+
code: "cro-exit-nonzero"
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
/** 读目录清单(不存在/读失败 ⇒ 空数组;**不回退抛错**) */
|
|
693
|
+
function readDirEntries(dir) {
|
|
694
|
+
try {
|
|
695
|
+
return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile()).map((e) => {
|
|
696
|
+
const p = join(dir, e.name);
|
|
697
|
+
try {
|
|
698
|
+
const st = statSync(p);
|
|
699
|
+
return {
|
|
700
|
+
name: e.name,
|
|
701
|
+
bytes: st.size,
|
|
702
|
+
mtimeMs: st.mtimeMs
|
|
703
|
+
};
|
|
704
|
+
} catch {
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
}).filter((x) => x !== null);
|
|
708
|
+
} catch {
|
|
709
|
+
return [];
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
/** 读 SESSION.md 体积与 mtime(不存在/读失败 ⇒ null) */
|
|
713
|
+
function readMdStat(mdPath) {
|
|
714
|
+
try {
|
|
715
|
+
const st = statSync(mdPath);
|
|
716
|
+
return {
|
|
717
|
+
bytes: st.size,
|
|
718
|
+
mtimeMs: st.mtimeMs
|
|
719
|
+
};
|
|
720
|
+
} catch {
|
|
721
|
+
return {
|
|
722
|
+
bytes: null,
|
|
723
|
+
mtimeMs: null
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
/** 读取快照所需的 fs 侧数据(**纯读取,不抛**) */
|
|
728
|
+
function readCroSnapshotInput(root, dirName, nowMs, extras) {
|
|
729
|
+
const dir = join(sessionsRoot(root), dirName);
|
|
730
|
+
const mdPath = join(dir, SESSION_MD);
|
|
731
|
+
const md = readMdStat(mdPath);
|
|
732
|
+
return {
|
|
733
|
+
dirName,
|
|
734
|
+
cccRoot: root,
|
|
735
|
+
nowMs,
|
|
736
|
+
sessionMdPath: mdPath,
|
|
737
|
+
sessionMdBytes: md.bytes,
|
|
738
|
+
sessionMdMtimeMs: md.mtimeMs,
|
|
739
|
+
references: readDirEntries(join(dir, "references")),
|
|
740
|
+
boundSessionIds: extras.boundSessionIds,
|
|
741
|
+
liveSessionIds: extras.liveSessionIds,
|
|
742
|
+
runningSessionIds: extras.runningSessionIds,
|
|
743
|
+
pendingWakes: extras.pendingWakes,
|
|
744
|
+
scheduler: extras.scheduler
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* 评估一条轨迹的 CRO 程序:**文件在 ⇒ 跑它;不在 ⇒ 未启用**(设计 §2.1)。
|
|
749
|
+
*
|
|
750
|
+
* 🔴 **本函数永不抛错**(旁路容忍铁律):任何失败都返回 `{status:'skipped'}`,
|
|
751
|
+
* 调用方据此**跳过本轮**、**不影响既有投递链路**。
|
|
752
|
+
*
|
|
753
|
+
* @param root CCC 根
|
|
754
|
+
* @param dirName 轨迹目录名
|
|
755
|
+
* @param snapshotInput 已读好的快照输入
|
|
756
|
+
* @param deps 可注入依赖(测试用)
|
|
757
|
+
* @returns 四态结果
|
|
758
|
+
*/
|
|
759
|
+
async function evaluateCro(root, dirName, snapshotInput, deps = {}) {
|
|
760
|
+
let scriptAbs;
|
|
761
|
+
try {
|
|
762
|
+
scriptAbs = croScriptPath(root, dirName);
|
|
763
|
+
} catch (err) {
|
|
764
|
+
return {
|
|
765
|
+
status: "disabled",
|
|
766
|
+
detail: `CRO 路径非法(须在 CCC 根内):${String(err?.message ?? err)}`
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
if (!existsSync(scriptAbs)) return {
|
|
770
|
+
status: "disabled",
|
|
771
|
+
detail: `未启用(无 ${CRO_FILENAME})`
|
|
772
|
+
};
|
|
773
|
+
const snapshot = buildCroSnapshot(snapshotInput);
|
|
774
|
+
let stdinJson;
|
|
775
|
+
try {
|
|
776
|
+
stdinJson = JSON.stringify(snapshot);
|
|
777
|
+
} catch (err) {
|
|
778
|
+
return {
|
|
779
|
+
status: "skipped",
|
|
780
|
+
detail: `快照序列化失败:${String(err?.message ?? err)}`,
|
|
781
|
+
code: "cro-snapshot"
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
const run = await (deps.runner ?? runCroProcess)(scriptAbs, stdinJson, deps.timeoutMs ?? 6e4);
|
|
785
|
+
if (!run.ok) return {
|
|
786
|
+
status: "skipped",
|
|
787
|
+
detail: run.error ?? "CRO 执行失败",
|
|
788
|
+
...run.code ? { code: run.code } : {}
|
|
789
|
+
};
|
|
790
|
+
const parsed = parseCroOutput(run.stdout);
|
|
791
|
+
if (!parsed.ok) return {
|
|
792
|
+
status: "skipped",
|
|
793
|
+
detail: parsed.detail ?? parsed.error,
|
|
794
|
+
code: parsed.error
|
|
795
|
+
};
|
|
796
|
+
const decision = parsed.decision;
|
|
797
|
+
if (!decision.wake) return {
|
|
798
|
+
status: "no-wake",
|
|
799
|
+
decision,
|
|
800
|
+
detail: decision.reason ?? "程序判定:不唤起"
|
|
801
|
+
};
|
|
802
|
+
return {
|
|
803
|
+
status: "wake",
|
|
804
|
+
decision,
|
|
805
|
+
prompt: decision.prompt,
|
|
806
|
+
detail: decision.reason ?? "程序判定:唤起"
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
/** 人读一行(日志用;`evaluateCro` 结果的稳定摘要) */
|
|
810
|
+
function renderCroOutcome(dirName, outcome) {
|
|
811
|
+
switch (outcome.status) {
|
|
812
|
+
case "disabled": return `${dirName}: CRO ${outcome.detail}`;
|
|
813
|
+
case "skipped": return `${dirName}: CRO 跳过本轮(${outcome.detail})`;
|
|
814
|
+
case "no-wake": return `${dirName}: CRO 不唤起(${outcome.detail})`;
|
|
815
|
+
case "wake": return `${dirName}: CRO 唤起(${outcome.detail})`;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
//#endregion
|
|
262
819
|
//#region src/host/effect.ts
|
|
263
820
|
/** 已告警的标签(进程级去重:重复装配/多测试替身不刷屏,但绝不静默) */
|
|
264
821
|
const warnedLabels = /* @__PURE__ */ new Set();
|
|
@@ -293,6 +850,115 @@ function registerDisposer(ctx, label, cleanup) {
|
|
|
293
850
|
}
|
|
294
851
|
}
|
|
295
852
|
//#endregion
|
|
853
|
+
//#region src/cro-turns.ts
|
|
854
|
+
/** 一次「在跑」记录的 TTL(见文件头的方向性判据) */
|
|
855
|
+
const TURN_TTL_MS = 18e5;
|
|
856
|
+
/**
|
|
857
|
+
* 进程内的「正在跑」表(键 = 载体 dsh 会话 id)。
|
|
858
|
+
*
|
|
859
|
+
* ⚠️ **仅内存**:进程重启即空 —— 这是**可接受的**,因为重启后宿主会重发
|
|
860
|
+
* `session-start`(或至少下一次 `turn-stopping` 会清掉残留),且空表 = "没有在跑",
|
|
861
|
+
* 落到上面那条**便宜**的误判方向。
|
|
862
|
+
*/
|
|
863
|
+
const runningBySession = /* @__PURE__ */ new Map();
|
|
864
|
+
/** 取载体 id(事件 payload 的 `agent` 面;取不到 ⇒ null) */
|
|
865
|
+
function sessionIdOf(agent) {
|
|
866
|
+
const id = agent?.session?.id;
|
|
867
|
+
return typeof id === "string" && id !== "" ? id : null;
|
|
868
|
+
}
|
|
869
|
+
/** 标记「在跑」(`agent/session-start` + `agent/status=running` 调用) */
|
|
870
|
+
function markTurnRunning(sessionId, nowMs, turn = null) {
|
|
871
|
+
if (!sessionId) return;
|
|
872
|
+
runningBySession.set(sessionId, {
|
|
873
|
+
at: nowMs,
|
|
874
|
+
turn
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
/** 清除「在跑」(`turn-stopping` / disposed 调用) */
|
|
878
|
+
function clearTurnRunning(sessionId) {
|
|
879
|
+
if (!sessionId) return;
|
|
880
|
+
runningBySession.delete(sessionId);
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* 列出**当前在跑**的载体 id(按 TTL 过滤 + **顺手清除过期项**)。
|
|
884
|
+
*
|
|
885
|
+
* 🔴 **本函数带副作用(清理)是刻意的**:它是"异常路径兜底"的唯一执行点——
|
|
886
|
+
* 只过滤不清除会让表留下永不过期的僵尸项(正是"只增不清"病)。
|
|
887
|
+
* @param nowMs 当前时刻
|
|
888
|
+
* @param ttlMs TTL(缺省 {@link TURN_TTL_MS})
|
|
889
|
+
* @returns 在跑的载体 id 列表(顺序不保证)
|
|
890
|
+
*/
|
|
891
|
+
function listRunningSessionIds(nowMs, ttlMs = TURN_TTL_MS) {
|
|
892
|
+
const out = [];
|
|
893
|
+
for (const [id, rec] of runningBySession) {
|
|
894
|
+
if (nowMs - rec.at > ttlMs) {
|
|
895
|
+
runningBySession.delete(id);
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
out.push(id);
|
|
899
|
+
}
|
|
900
|
+
return out;
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* 按轨迹聚合:该轨迹**是否有载体在跑**(`listBoundSessionIds` 给候选,再与在跑集求交)。
|
|
904
|
+
* @param boundSessionIds 绑定该轨迹的载体 id
|
|
905
|
+
* @param nowMs 当前时刻
|
|
906
|
+
* @param ttlMs TTL
|
|
907
|
+
* @returns 在跑的载体 id(**可能多条**;空数组 = 没有在跑)
|
|
908
|
+
*/
|
|
909
|
+
function runningCarriersOf(boundSessionIds, nowMs, ttlMs = TURN_TTL_MS) {
|
|
910
|
+
const running = new Set(listRunningSessionIds(nowMs, ttlMs));
|
|
911
|
+
return boundSessionIds.filter((id) => running.has(id));
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* 装配追踪(`index.ts` apply 调用):订阅四个既有事件 + 拆卸。
|
|
915
|
+
*
|
|
916
|
+
* **为什么订阅四个而不是两个**(R↓):
|
|
917
|
+
* · 前两个(`session-start` / `turn-stopping`)负责**语义**——"在不在跑";
|
|
918
|
+
* · 后两个(`agent/disposed` / `session/disposed`)负责**生命周期**——
|
|
919
|
+
* 载体没了必须清项,否则表按载体只增(本模块的头号风险)。
|
|
920
|
+
*
|
|
921
|
+
* ⚠️ **失败不抛**:任一事件通道缺失 ⇒ 响亮日志 + 该订阅跳过(**apply 不可成为启动单点**,
|
|
922
|
+
* 与 `registerDeepseekVisionPatch` 等既有装配同款纪律)。
|
|
923
|
+
* @param ctx 插件上下文
|
|
924
|
+
*/
|
|
925
|
+
function registerCroTurnTracking(ctx) {
|
|
926
|
+
const on = (event, handler) => {
|
|
927
|
+
try {
|
|
928
|
+
ctx.on(event, handler);
|
|
929
|
+
} catch (err) {
|
|
930
|
+
console.log(`[serenity-hooks] ✗ CRO turn 追踪未装配(${event} 不可用): ${String(err?.message ?? err)}`);
|
|
931
|
+
}
|
|
932
|
+
};
|
|
933
|
+
on("agent/session-start", (payload) => {
|
|
934
|
+
const id = sessionIdOf(payload?.agent);
|
|
935
|
+
if (id) markTurnRunning(id, Date.now());
|
|
936
|
+
});
|
|
937
|
+
on("agent/status", (payload) => {
|
|
938
|
+
const p = payload;
|
|
939
|
+
const id = sessionIdOf(p?.agent);
|
|
940
|
+
if (!id) return;
|
|
941
|
+
const turn = typeof p.turn === "number" ? p.turn : null;
|
|
942
|
+
if (p.status === "running") markTurnRunning(id, Date.now(), turn);
|
|
943
|
+
else if (p.status === "idle") clearTurnRunning(id);
|
|
944
|
+
});
|
|
945
|
+
on("agent/turn-stopping", (payload) => {
|
|
946
|
+
const id = sessionIdOf(payload?.agent);
|
|
947
|
+
if (id) clearTurnRunning(id);
|
|
948
|
+
});
|
|
949
|
+
on("agent/disposed", (payload) => {
|
|
950
|
+
const id = sessionIdOf(payload?.agent);
|
|
951
|
+
if (id) clearTurnRunning(id);
|
|
952
|
+
});
|
|
953
|
+
on("session/disposed", (payload) => {
|
|
954
|
+
const id = payload?.id;
|
|
955
|
+
if (typeof id === "string" && id !== "") clearTurnRunning(id);
|
|
956
|
+
});
|
|
957
|
+
registerDisposer(ctx, "cro turn tracking", () => {
|
|
958
|
+
runningBySession.clear();
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
//#endregion
|
|
296
962
|
//#region src/clock-runtime.ts
|
|
297
963
|
/**
|
|
298
964
|
* clock-runtime.ts — **时钟运行时工厂**(S142 §38 复审候选 C6b,路径 **P2**)
|
|
@@ -791,6 +1457,153 @@ async function sendToTrajectory(ctx, root, target, message, sender) {
|
|
|
791
1457
|
async function collectWakeCccs(ctx) {
|
|
792
1458
|
return (await listCccs(ctx)).map((e) => e.root);
|
|
793
1459
|
}
|
|
1460
|
+
/**
|
|
1461
|
+
* CRO 唤起消息正文(**刻意不复用** {@link buildWakeText} / {@link buildSendText})。
|
|
1462
|
+
*
|
|
1463
|
+
* 三种唤起各有自己的**事实**;混用 = 注入一条假陈述(本容器栽过:`buildSendText` 里
|
|
1464
|
+
* 那句"消息已入队"就是一条假陈述,见该函数注释):
|
|
1465
|
+
* · `buildWakeText` —— 「**到点**了」(登记过的未来时刻,人/agent 算好的)
|
|
1466
|
+
* · `buildSendText` —— 「**有人此刻给我发了一条消息**」
|
|
1467
|
+
* · 本函数 —— 「**我自己的程序**看情况判定该叫我了」(**没有到点、也没有人发消息**)
|
|
1468
|
+
*
|
|
1469
|
+
* 🔴 `reason` 的呈现(设计 §4.2):**程序没写就明说没写**,不留空白 ——
|
|
1470
|
+
* 改成程序判定后,"当时为什么叫了"**不再能从时间表重建**(原因在程序肚子里);
|
|
1471
|
+
* 留空白 = 连"丢在哪一环"都看不出来。写明"未提供"至少把缺口标出来了(CCE:重建 > 保存)。
|
|
1472
|
+
* @param dirName 目标轨迹目录名
|
|
1473
|
+
* @param mdPath 目标 SESSION.md 绝对路径
|
|
1474
|
+
* @param prompt 程序给的唤起提示词
|
|
1475
|
+
* @param reason 程序给的判定理由(可为 null)
|
|
1476
|
+
* @returns 投递正文
|
|
1477
|
+
*/
|
|
1478
|
+
function buildCroWakeText(dirName, mdPath, prompt, reason) {
|
|
1479
|
+
return [
|
|
1480
|
+
`[CRO 唤起] ${dirName} 自带的 continuous-re-occurrence.ts 判定:现在该唤起。`,
|
|
1481
|
+
`当前时间:${localHuman()}(当地时区)。`,
|
|
1482
|
+
"",
|
|
1483
|
+
`身份锚定:继续 ${dirName} 的 trajectory(SESSION.md: ${mdPath})。`,
|
|
1484
|
+
"",
|
|
1485
|
+
`判定理由:${reason ?? "(程序未提供 reason —— 见 CRO 编写指南 §7 坑 3)"}`,
|
|
1486
|
+
"",
|
|
1487
|
+
"唤起提示词:",
|
|
1488
|
+
prompt,
|
|
1489
|
+
"",
|
|
1490
|
+
"说明:这是由**本轨迹自己的 CRO 程序**按当时情况做的判定(**不是**定时唤醒,**也不是**别人发的消息)。处理完照常把进展写入 SESSION.md。"
|
|
1491
|
+
].join("\n");
|
|
1492
|
+
}
|
|
1493
|
+
/**
|
|
1494
|
+
* 投递一条 CRO 唤起(**fire-and-forget**,与 {@link deliverWake} 同层)。
|
|
1495
|
+
*
|
|
1496
|
+
* · **取用通路复用** {@link acquireWakeAgent}(live 优先 → 冷载入):这是调度器侧唯一的
|
|
1497
|
+
* "怎么把一句话送到一条轨迹"的知识 ⇒ 不另立第二条(设计 §9-2 的倾向:复用投递面)。
|
|
1498
|
+
* · 🔴 **不落唤醒表**(设计 §6.3):CRO 是"**持续判定**",不是"**某个未来时刻**"。
|
|
1499
|
+
* 落表会给 §0Q(投递即删、表只保在办)**增加一个新的写入源**。
|
|
1500
|
+
* · 用 `followup`(不用 `steer`):CRO 由**调度器**发起,与 `send-later` 同层;
|
|
1501
|
+
* `steer`(当场注入当前轮)是 `send-now` 的语义 —— 那是人类/agent 的**即时动作**。
|
|
1502
|
+
* · 🔴 **失败不回滚、不重试**(与条目投递不同):下一 tick 会**重新判定**——
|
|
1503
|
+
* CRO 的判据是"情况",不是"这条消息还没送出去" ⇒ 自愈来自**重判**,不来自重投。
|
|
1504
|
+
* @param ctx 插件上下文
|
|
1505
|
+
* @param root CCC 根
|
|
1506
|
+
* @param dirName 目标轨迹目录名(= CRO 只能唤起**自己**,设计 §9-8 的倾向)
|
|
1507
|
+
* @param prompt 唤起提示词
|
|
1508
|
+
* @param reason 判定理由
|
|
1509
|
+
* @returns 投递结果(进 tick 日志;**不写注册表**)
|
|
1510
|
+
*/
|
|
1511
|
+
async function deliverCroWake(ctx, root, dirName, prompt, reason) {
|
|
1512
|
+
const mdPath = join(sessionsRoot(root), dirName, "SESSION.md");
|
|
1513
|
+
const acquired = await acquireWakeAgent(ctx, root, dirName);
|
|
1514
|
+
if ("error" in acquired) return {
|
|
1515
|
+
ok: false,
|
|
1516
|
+
detail: acquired.error,
|
|
1517
|
+
notReady: acquired.notReady === true
|
|
1518
|
+
};
|
|
1519
|
+
try {
|
|
1520
|
+
acquired.agent.followup(createUserMessage({
|
|
1521
|
+
content: [{
|
|
1522
|
+
type: "text",
|
|
1523
|
+
text: buildCroWakeText(dirName, mdPath, prompt, reason)
|
|
1524
|
+
}],
|
|
1525
|
+
source: PLUGIN_SOURCE
|
|
1526
|
+
}));
|
|
1527
|
+
} catch (err) {
|
|
1528
|
+
return {
|
|
1529
|
+
ok: false,
|
|
1530
|
+
detail: `CRO 投递失败(${acquired.how}): ${String(err?.message ?? err)}`
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
return {
|
|
1534
|
+
ok: true,
|
|
1535
|
+
detail: `已投递(${acquired.how})`
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
/**
|
|
1539
|
+
* CRO 阶段:本 tick 对**每条启用了 CRO 的轨迹**跑一次它自带的程序,按判定投递。
|
|
1540
|
+
*
|
|
1541
|
+
* ## 为什么放在既有投递**之后**(R↓)
|
|
1542
|
+
* · 既有条目(`send-later`)**有到点承诺** ⇒ 优先级天然更高;
|
|
1543
|
+
* · CRO 是"看着情况叫",**晚一拍无成本**(tick 粒度本就是 5min);
|
|
1544
|
+
* · ⇒ 铁律(设计 §5):**CRO 的任何失败都不影响既有链路** —— 整段由调用方兜住,
|
|
1545
|
+
* 且**每条轨迹各自 try/catch**(一条坏程序不得连累同 tick 的其它轨迹)。
|
|
1546
|
+
*
|
|
1547
|
+
* ## 日志口径(刻意与既有投递不同)
|
|
1548
|
+
* "每 tick × 每条轨迹"都写一行 ⇒ 对"**常态是不唤起**"的 CRO 就是刷屏。
|
|
1549
|
+
* ⇒ 逐条只记 **唤起**(`reason` 进日志 —— 设计 §4.2 要求可重建)与 **跳过**(异常,必须看见);
|
|
1550
|
+
* **不唤起只进汇总行**(`唤起 x,不唤起 y,跳过 z`)。
|
|
1551
|
+
* ⚠️ **诚实标注**:因此"某条轨迹当轮**为何没叫**"**不进 tick 日志**(它在程序肚子里)。
|
|
1552
|
+
* 要完整的逐次判定审计,须**程序自己**写状态文件(设计 §3.3)——ACC 给不了。
|
|
1553
|
+
* @param ctx 插件上下文
|
|
1554
|
+
* @param root CCC 根
|
|
1555
|
+
* @param registry 本 tick 已载入的唤醒注册表条目(供快照的 `pendingWakes`;读坏时传 `[]`)
|
|
1556
|
+
* @param log 本 tick 的人读摘要(就地追加)
|
|
1557
|
+
*/
|
|
1558
|
+
async function runCroPhase(ctx, root, registry, log) {
|
|
1559
|
+
let dirs;
|
|
1560
|
+
try {
|
|
1561
|
+
dirs = listCroTrajectories(root);
|
|
1562
|
+
} catch {
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
if (dirs.length === 0) return;
|
|
1566
|
+
const nowMs = Date.now();
|
|
1567
|
+
const s = wakeSchedulerState();
|
|
1568
|
+
const scheduler = {
|
|
1569
|
+
armed: s.armed,
|
|
1570
|
+
enabled: s.enabled,
|
|
1571
|
+
ticks: s.ticks,
|
|
1572
|
+
lastSkipReason: s.lastSkipReason
|
|
1573
|
+
};
|
|
1574
|
+
const agents = hostAgents(ctx);
|
|
1575
|
+
let wakes = 0;
|
|
1576
|
+
let quiet = 0;
|
|
1577
|
+
let skips = 0;
|
|
1578
|
+
for (const dirName of dirs) try {
|
|
1579
|
+
const bound = listBoundSessionIds(root, dirName);
|
|
1580
|
+
const running = runningCarriersOf(bound, nowMs);
|
|
1581
|
+
const outcome = await evaluateCro(root, dirName, readCroSnapshotInput(root, dirName, nowMs, {
|
|
1582
|
+
boundSessionIds: bound,
|
|
1583
|
+
liveSessionIds: bound.filter((id) => Boolean(agents?.get?.(id))),
|
|
1584
|
+
runningSessionIds: running,
|
|
1585
|
+
pendingWakes: registry.filter((e) => e.state === "pending" && resolveWakeTarget(root, e.target)?.dirName === dirName).map((e) => ({
|
|
1586
|
+
id: e.id,
|
|
1587
|
+
at: e.at,
|
|
1588
|
+
createdAt: e.createdAt,
|
|
1589
|
+
createdBy: e.createdBy
|
|
1590
|
+
})),
|
|
1591
|
+
scheduler
|
|
1592
|
+
}));
|
|
1593
|
+
if (outcome.status === "wake") {
|
|
1594
|
+
wakes += 1;
|
|
1595
|
+
const res = await deliverCroWake(ctx, root, dirName, outcome.prompt, outcome.decision.reason);
|
|
1596
|
+
log.push(`· ${root}: ${renderCroOutcome(dirName, outcome)} → ${res.ok ? "已投递" : `投递未成功:${res.detail}`}`);
|
|
1597
|
+
} else if (outcome.status === "skipped") {
|
|
1598
|
+
skips += 1;
|
|
1599
|
+
log.push(`· ${root}: ${renderCroOutcome(dirName, outcome)}`);
|
|
1600
|
+
} else if (outcome.status === "no-wake") quiet += 1;
|
|
1601
|
+
} catch (err) {
|
|
1602
|
+
skips += 1;
|
|
1603
|
+
log.push(`· ${root}: ${dirName}: CRO 内部异常(已忽略,不影响既有链路): ${String(err?.message ?? err)}`);
|
|
1604
|
+
}
|
|
1605
|
+
log.push(`· ${root}: CRO 评估 ${dirs.length} 条(唤起 ${wakes},不唤起 ${quiet},跳过 ${skips})`);
|
|
1606
|
+
}
|
|
794
1607
|
/** 一次调度 tick:遍历已知 CCC → 到期项串行投递 / 超窗项置 missed;返回人读摘要 */
|
|
795
1608
|
async function runWakeTick(ctx) {
|
|
796
1609
|
const log = [];
|
|
@@ -801,18 +1614,18 @@ async function runWakeTick(ctx) {
|
|
|
801
1614
|
}
|
|
802
1615
|
clock.noteSkipReason(null);
|
|
803
1616
|
for (const root of roots) {
|
|
1617
|
+
const purged = purgeFinalizedWakes(root);
|
|
1618
|
+
if (purged.ok && purged.purged > 0) log.push(`· ${root}: 清除 ${purged.purged} 条已结案记录(存量首清)`);
|
|
804
1619
|
const { registry, error } = loadWakeRegistry(root);
|
|
805
1620
|
if (error) {
|
|
806
1621
|
log.push(`✗ ${root}: ${error}`);
|
|
1622
|
+
await runCroPhase(ctx, root, [], log);
|
|
807
1623
|
continue;
|
|
808
1624
|
}
|
|
809
1625
|
const { due, expired } = splitDueWakes(registry, Date.now(), WAKE_CATCH_UP_MS);
|
|
810
1626
|
for (const e of expired) {
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
lastResult: `超过补跑窗口(${WAKE_CATCH_UP_MS / 36e5}h)未投递`
|
|
814
|
-
});
|
|
815
|
-
log.push(`· ${root}: ${e.id} → missed(超补跑窗口)`);
|
|
1627
|
+
finalizeWake(root, e.id, "missed", `超过补跑窗口(${WAKE_CATCH_UP_MS / 36e5}h)未投递`);
|
|
1628
|
+
log.push(`· ${root}: ${e.id} → missed(超补跑窗口,已清除)`);
|
|
816
1629
|
}
|
|
817
1630
|
for (const e of due) {
|
|
818
1631
|
const res = await deliverWake(ctx, root, e);
|
|
@@ -820,14 +1633,19 @@ async function runWakeTick(ctx) {
|
|
|
820
1633
|
log.push(`· ${root}: ${e.id} → 跳过(宿主服务未就绪,下个 tick 再试;不计失败)`);
|
|
821
1634
|
continue;
|
|
822
1635
|
}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
1636
|
+
if (res.ok) {
|
|
1637
|
+
finalizeWake(root, e.id, "delivered", res.detail);
|
|
1638
|
+
log.push(`· ${root}: ${e.id} → delivered(已清除)`);
|
|
1639
|
+
} else {
|
|
1640
|
+
updateWake(root, e.id, {
|
|
1641
|
+
state: "pending",
|
|
1642
|
+
attempts: e.attempts + 1,
|
|
1643
|
+
lastResult: res.detail
|
|
1644
|
+
});
|
|
1645
|
+
log.push(`· ${root}: ${e.id} → 重试(${res.detail})`);
|
|
1646
|
+
}
|
|
830
1647
|
}
|
|
1648
|
+
await runCroPhase(ctx, root, registry.entries, log);
|
|
831
1649
|
}
|
|
832
1650
|
return log;
|
|
833
1651
|
}
|
|
@@ -842,4 +1660,4 @@ function registerWakeScheduler(ctx) {
|
|
|
842
1660
|
registerDisposer(ctx, "trajectory 唤醒调度器", () => clock.dispose());
|
|
843
1661
|
}
|
|
844
1662
|
//#endregion
|
|
845
|
-
export {
|
|
1663
|
+
export { registerDisposer as a, addWake as c, registerCroTurnTracking as i, listWakes as l, sendToTrajectory as n, buildCroSnapshot as o, wakeSchedulerState as r, WAKE_CATCH_UP_MS as s, registerWakeScheduler as t };
|