@shgroup/dsh-serenity-hooks 1.42.0 → 1.44.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 +28 -1
- package/dsh.plugin.json +1 -1
- package/lib/{ccc-NlLr_sxy.js → ccc-DG4Oc7I1.js} +22 -1
- package/lib/{ccc-roots-Bd_SjEs7.js → ccc-roots-FxWXuZPw.js} +2 -2
- package/lib/ccc.d.ts +36 -0
- package/lib/cro-guide.d.ts +48 -0
- package/lib/cro-log.d.ts +135 -0
- package/lib/cro-turns.d.ts +95 -0
- package/lib/cro.d.ts +306 -0
- package/lib/index.js +4170 -3923
- package/lib/{skiff-registry-FTjoWQTQ.js → skiff-registry-BjIX_KD1.js} +19 -1
- package/lib/{skiff-role-CEHL5cek.js → skiff-role-KqQA6pkr.js} +1 -1
- package/lib/tools/trajectory.d.ts +4 -1
- package/lib/{trajectory-bound-4xkZDfmO.js → trajectory-bound-BzdzsDPI.js} +4 -3
- package/lib/trajectory-ops.d.ts +8 -2
- package/lib/trajectory-skills.d.ts +22 -5
- package/lib/{wake-scheduler-1AsvNQPW.js → wake-scheduler-C2GwdriD.js} +933 -6
- package/lib/wake-scheduler.d.ts +19 -0
- package/lib/{weixin-route-B2ajFypI.js → weixin-route-CWMRa36W.js} +1 -1
- package/package.json +1 -1
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { r as hostService, t as hostAgents } from "./access-fiehjxV6.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { g as resolveInside } from "./ccc-DG4Oc7I1.js";
|
|
3
|
+
import { o as listCccs } from "./ccc-roots-FxWXuZPw.js";
|
|
4
|
+
import { D as localHuman, O as localIdStamp, T as isoLocal, b as sessionsRoot, n as listBoundSessionIds, p as findSession } from "./trajectory-bound-BzdzsDPI.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
|
/**
|
|
@@ -42,7 +44,7 @@ function emptyRegistry() {
|
|
|
42
44
|
};
|
|
43
45
|
}
|
|
44
46
|
/** 条目形状校验(容忍手改:字段缺失/类型不符 → 丢弃该条,不阻断整表) */
|
|
45
|
-
function asEntry(value) {
|
|
47
|
+
function asEntry$1(value) {
|
|
46
48
|
const e = value;
|
|
47
49
|
if (!e || typeof e.id !== "string" || typeof e.target !== "string") return null;
|
|
48
50
|
if (typeof e.at !== "string" || typeof e.message !== "string") return null;
|
|
@@ -76,7 +78,7 @@ function loadWakeRegistry(root) {
|
|
|
76
78
|
const raw = Array.isArray(parsed.entries) ? parsed.entries : [];
|
|
77
79
|
const entries = [];
|
|
78
80
|
for (const item of raw) {
|
|
79
|
-
const e = asEntry(item);
|
|
81
|
+
const e = asEntry$1(item);
|
|
80
82
|
if (e) entries.push(e);
|
|
81
83
|
}
|
|
82
84
|
return {
|
|
@@ -361,6 +363,459 @@ function listWakes(root, opts = {}) {
|
|
|
361
363
|
};
|
|
362
364
|
}
|
|
363
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
|
|
364
819
|
//#region src/host/effect.ts
|
|
365
820
|
/** 已告警的标签(进程级去重:重复装配/多测试替身不刷屏,但绝不静默) */
|
|
366
821
|
const warnedLabels = /* @__PURE__ */ new Set();
|
|
@@ -395,6 +850,319 @@ function registerDisposer(ctx, label, cleanup) {
|
|
|
395
850
|
}
|
|
396
851
|
}
|
|
397
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
|
|
962
|
+
//#region src/cro-log.ts
|
|
963
|
+
/**
|
|
964
|
+
* cro-log.ts — CRO 唤起日志(**ACC 侧**,每轨迹一份固定名文件,2026-09-20 S142 §7.16)
|
|
965
|
+
*
|
|
966
|
+
* ## 所有者令(本模块的存在理由)
|
|
967
|
+
*
|
|
968
|
+
* 「S151 和 S185 的验证都说明,trajectory 倾向于自己做个结构化记录来记录自身 CRO 的执行情况,
|
|
969
|
+
* 这是个结构化流水账;**我们 ACC 有必要进行 CRO 唤醒的日志记录**,当 CRO 存在时,每次**成功唤醒**
|
|
970
|
+
* 写相关信息,但是保留数量要少,**只保留仅两日**的记录即可,**这个时间是程序化的**」
|
|
971
|
+
* +「**这个日志写结构化文件到 trajectory 目录,文件名固定即可**」
|
|
972
|
+
* +「**失败成功都记录**」。
|
|
973
|
+
*
|
|
974
|
+
* ## 它解决什么麻烦(白话)
|
|
975
|
+
*
|
|
976
|
+
* 在此之前「ACC 到底叫过谁、叫了几次、哪次**没叫成**」**ACC 自己一个字都不留** ——
|
|
977
|
+
* 要查只能去读**被叫的那个程序**写的记录;程序没写 / 写坏 / 被换掉 ⇒ **这段历史就是空白**。
|
|
978
|
+
* ⇒ 本模块让 ACC **自己那一侧**留一份只记两天的流水,使「唤起历史」**不再依赖被观测者是否老实**。
|
|
979
|
+
*
|
|
980
|
+
* ## 🔴 为什么**不是**第二真相源(本容器铁律,勿删此论证)
|
|
981
|
+
*
|
|
982
|
+
* 轨迹目录里因此**并排两个文件、主语各一**:
|
|
983
|
+
*
|
|
984
|
+
* | 文件 | 谁写 | 主语 | 回答 |
|
|
985
|
+
* |---|---|---|---|
|
|
986
|
+
* | `<轨迹目录>/cro-state.json`(CCC 侧程序自建,名字自定) | **CCC 的 CRO 程序** | 程序 | 「**我判了什么**」(含"没叫"的判定、心跳阈值、环境谓词) |
|
|
987
|
+
* | `<轨迹目录>/cro-wake-log.json`(**本模块**) | **ACC** | ACC | 「**我把什么送出去了、送成没有**」 |
|
|
988
|
+
*
|
|
989
|
+
* **内容不重叠**:本模块**不复制**判定细节(不存 `lastDecision` / 阈值 / 谓词);
|
|
990
|
+
* 而程序**不可能**知道投递结果(CRO 投递是 fire-and-forget)。
|
|
991
|
+
* ⇒ 两者**互补**:完整审计 = 两者合看(本模块 = 投递面|程序状态 = 判定面)。
|
|
992
|
+
*
|
|
993
|
+
* ## 🔴 轮转(所有者明示「程序化的」)
|
|
994
|
+
*
|
|
995
|
+
* **写入时按窗裁剪**:每次落盘只保留 `at >= now - CRO_LOG_RETENTION_MS` 的条目,
|
|
996
|
+
* 随后**原子写**(`tmp + rename`,与 `wake-registry.ts` 同款形态)。
|
|
997
|
+
* ⇒ 清理与写入**是同一笔操作** ⇒ **结构上不存在"忘了清"的可能**(比"另设一个每 tick 的清理步"更硬:
|
|
998
|
+
* 那多一个"可能忘了跑"的面)。窗宽是**常量**,**无配置键、无人工清理动作**。
|
|
999
|
+
*
|
|
1000
|
+
* ## 🔴 铁律(与 `cro.ts` 同族)
|
|
1001
|
+
*
|
|
1002
|
+
* **本模块的每个导出函数都不抛错**:写盘 / 裁剪 / 解析失败一律返回结构化失败,
|
|
1003
|
+
* 由调用方只记一行 tick 日志 ⇒ **CRO 流水的问题绝不影响投递与既有链路**(设计 §5)。
|
|
1004
|
+
*
|
|
1005
|
+
* ## 保留量级(实测,非估计)
|
|
1006
|
+
*
|
|
1007
|
+
* 实测最高 = S185 的 40min 心跳(≈36 条/天);两天窗 ⇒ **≤ ~80 条、< 20 KB**。
|
|
1008
|
+
* 最坏(某程序每 5min 都叫)⇒ 288 条/天 ⇒ 两天 **≤ ~600 条、< 100 KB** —— 轮转**自带上限**。
|
|
1009
|
+
*/
|
|
1010
|
+
/** 固定文件名(所有者令:「文件名固定即可」)——与程序自建的 `cro-state.json` 并排、主语分明 */
|
|
1011
|
+
const CRO_LOG_FILENAME = "cro-wake-log.json";
|
|
1012
|
+
/** 保留窗 = 2 日(所有者令「只保留仅两日」);**常量** ⇒ 「这个时间是程序化的」 */
|
|
1013
|
+
const CRO_LOG_RETENTION_MS = 1728e5;
|
|
1014
|
+
const CRO_LOG_VERSION = 1;
|
|
1015
|
+
/** 日志绝对路径 */
|
|
1016
|
+
function croWakeLogPath(root, dirName) {
|
|
1017
|
+
return join(sessionsRoot(root), dirName, CRO_LOG_FILENAME);
|
|
1018
|
+
}
|
|
1019
|
+
function emptyLog() {
|
|
1020
|
+
return {
|
|
1021
|
+
version: CRO_LOG_VERSION,
|
|
1022
|
+
entries: []
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
/** 条目形状校验(容忍手改:字段缺失/类型不符 → 丢弃该条,不阻断整档) */
|
|
1026
|
+
function asEntry(value) {
|
|
1027
|
+
const e = value;
|
|
1028
|
+
if (!e || typeof e !== "object") return null;
|
|
1029
|
+
if (typeof e.at !== "string" || e.at === "") return null;
|
|
1030
|
+
if (typeof e.ok !== "boolean") return null;
|
|
1031
|
+
if (typeof e.detail !== "string") return null;
|
|
1032
|
+
const digest = e.promptDigest ?? {};
|
|
1033
|
+
return {
|
|
1034
|
+
at: e.at,
|
|
1035
|
+
ok: e.ok,
|
|
1036
|
+
detail: e.detail,
|
|
1037
|
+
tick: typeof e.tick === "number" ? e.tick : 0,
|
|
1038
|
+
reason: typeof e.reason === "string" ? e.reason : null,
|
|
1039
|
+
promptDigest: {
|
|
1040
|
+
length: typeof digest.length === "number" ? digest.length : 0,
|
|
1041
|
+
head: typeof digest.head === "string" ? digest.head : ""
|
|
1042
|
+
}
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
/** 提示词摘要(长度 + 前 N 字;超长不截断标记——长度字段本身就是信号) */
|
|
1046
|
+
function summarizeCroPrompt(prompt) {
|
|
1047
|
+
const text = typeof prompt === "string" ? prompt : "";
|
|
1048
|
+
return {
|
|
1049
|
+
length: [...text].length,
|
|
1050
|
+
head: [...text].slice(0, 80).join("")
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* 读取日志(**永不抛**;读坏 ⇒ 当作空档 + 返回 error 文本)。
|
|
1055
|
+
* @param root CCC 根
|
|
1056
|
+
* @param dirName 轨迹目录名
|
|
1057
|
+
*/
|
|
1058
|
+
function loadCroWakeLog(root, dirName) {
|
|
1059
|
+
const path = croWakeLogPath(root, dirName);
|
|
1060
|
+
if (!existsSync(path)) return {
|
|
1061
|
+
log: emptyLog(),
|
|
1062
|
+
error: null
|
|
1063
|
+
};
|
|
1064
|
+
try {
|
|
1065
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
1066
|
+
const raw = Array.isArray(parsed.entries) ? parsed.entries : [];
|
|
1067
|
+
const entries = [];
|
|
1068
|
+
for (const item of raw) {
|
|
1069
|
+
const e = asEntry(item);
|
|
1070
|
+
if (e) entries.push(e);
|
|
1071
|
+
}
|
|
1072
|
+
return {
|
|
1073
|
+
log: {
|
|
1074
|
+
version: CRO_LOG_VERSION,
|
|
1075
|
+
entries
|
|
1076
|
+
},
|
|
1077
|
+
error: null
|
|
1078
|
+
};
|
|
1079
|
+
} catch (err) {
|
|
1080
|
+
return {
|
|
1081
|
+
log: emptyLog(),
|
|
1082
|
+
error: `CRO 流水解析失败(${String(err?.message ?? err)})`
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* 按窗裁剪(**纯函数** ⇒ 可穷举测试)。
|
|
1088
|
+
*
|
|
1089
|
+
* 判据:`Date.parse(at) >= nowMs - windowMs` 保留;反之丢弃。
|
|
1090
|
+
* 🔴 **`at` 不可解析者一律丢弃**(本模块只写 `isoLocal()` 产物 ⇒ 不可解析 = 被手改或损坏),
|
|
1091
|
+
* **并在返回值里报数**(`dropped`)——**不静默吞掉**(本容器反复栽的"安静失败"纪律)。
|
|
1092
|
+
*/
|
|
1093
|
+
function pruneCroLogEntries(entries, nowMs, windowMs = CRO_LOG_RETENTION_MS) {
|
|
1094
|
+
const floor = nowMs - windowMs;
|
|
1095
|
+
const kept = [];
|
|
1096
|
+
let pruned = 0;
|
|
1097
|
+
let dropped = 0;
|
|
1098
|
+
for (const e of entries) {
|
|
1099
|
+
const t = Date.parse(e.at);
|
|
1100
|
+
if (Number.isNaN(t)) {
|
|
1101
|
+
dropped += 1;
|
|
1102
|
+
continue;
|
|
1103
|
+
}
|
|
1104
|
+
if (t >= floor) kept.push(e);
|
|
1105
|
+
else pruned += 1;
|
|
1106
|
+
}
|
|
1107
|
+
return {
|
|
1108
|
+
kept,
|
|
1109
|
+
pruned,
|
|
1110
|
+
dropped
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* 追加一条唤起记录并按窗裁剪(**本模块主入口;永不抛**)。
|
|
1115
|
+
*
|
|
1116
|
+
* 顺序 = 读 → 追加 → 裁剪 → **原子写**。任一步失败 ⇒ `{ok:false, error}`,
|
|
1117
|
+
* **不抛**(调用方只记一行 tick 日志;**绝不影响投递**)。
|
|
1118
|
+
*
|
|
1119
|
+
* 无 CRO 的轨迹**不会**因本函数产生文件:只有真的发生过一次唤起尝试才会建/写该档
|
|
1120
|
+
* (所有者令「**当 CRO 存在时**」)。
|
|
1121
|
+
*
|
|
1122
|
+
* @param root CCC 根
|
|
1123
|
+
* @param dirName 轨迹目录名
|
|
1124
|
+
* @param input 本次唤起尝试(成败都记)
|
|
1125
|
+
* @param nowMs 当前毫秒(可注入 ⇒ 便于测裁剪边界)
|
|
1126
|
+
* @returns 写入结果与裁剪计数
|
|
1127
|
+
*/
|
|
1128
|
+
function appendCroWakeLog(root, dirName, input, nowMs = Date.now()) {
|
|
1129
|
+
const path = croWakeLogPath(root, dirName);
|
|
1130
|
+
try {
|
|
1131
|
+
const loaded = loadCroWakeLog(root, dirName);
|
|
1132
|
+
const entry = {
|
|
1133
|
+
at: isoLocal(nowMs),
|
|
1134
|
+
ok: input.ok,
|
|
1135
|
+
detail: input.detail,
|
|
1136
|
+
tick: input.tick,
|
|
1137
|
+
reason: input.reason,
|
|
1138
|
+
promptDigest: summarizeCroPrompt(input.prompt)
|
|
1139
|
+
};
|
|
1140
|
+
const { kept, pruned, dropped } = pruneCroLogEntries([...loaded.log.entries, entry], nowMs);
|
|
1141
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1142
|
+
const tmp = `${path}.tmp`;
|
|
1143
|
+
writeFileSync(tmp, `${JSON.stringify({
|
|
1144
|
+
version: CRO_LOG_VERSION,
|
|
1145
|
+
entries: kept
|
|
1146
|
+
}, null, 2)}\n`, "utf-8");
|
|
1147
|
+
renameSync(tmp, path);
|
|
1148
|
+
return {
|
|
1149
|
+
ok: true,
|
|
1150
|
+
error: null,
|
|
1151
|
+
kept: kept.length,
|
|
1152
|
+
pruned,
|
|
1153
|
+
dropped
|
|
1154
|
+
};
|
|
1155
|
+
} catch (err) {
|
|
1156
|
+
return {
|
|
1157
|
+
ok: false,
|
|
1158
|
+
error: `CRO 流水写入失败(${String(err?.message ?? err)})`,
|
|
1159
|
+
kept: 0,
|
|
1160
|
+
pruned: 0,
|
|
1161
|
+
dropped: 0
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
//#endregion
|
|
398
1166
|
//#region src/clock-runtime.ts
|
|
399
1167
|
/**
|
|
400
1168
|
* clock-runtime.ts — **时钟运行时工厂**(S142 §38 复审候选 C6b,路径 **P2**)
|
|
@@ -893,6 +1661,163 @@ async function sendToTrajectory(ctx, root, target, message, sender) {
|
|
|
893
1661
|
async function collectWakeCccs(ctx) {
|
|
894
1662
|
return (await listCccs(ctx)).map((e) => e.root);
|
|
895
1663
|
}
|
|
1664
|
+
/**
|
|
1665
|
+
* CRO 唤起消息正文(**刻意不复用** {@link buildWakeText} / {@link buildSendText})。
|
|
1666
|
+
*
|
|
1667
|
+
* 三种唤起各有自己的**事实**;混用 = 注入一条假陈述(本容器栽过:`buildSendText` 里
|
|
1668
|
+
* 那句"消息已入队"就是一条假陈述,见该函数注释):
|
|
1669
|
+
* · `buildWakeText` —— 「**到点**了」(登记过的未来时刻,人/agent 算好的)
|
|
1670
|
+
* · `buildSendText` —— 「**有人此刻给我发了一条消息**」
|
|
1671
|
+
* · 本函数 —— 「**我自己的程序**看情况判定该叫我了」(**没有到点、也没有人发消息**)
|
|
1672
|
+
*
|
|
1673
|
+
* 🔴 `reason` 的呈现(设计 §4.2):**程序没写就明说没写**,不留空白 ——
|
|
1674
|
+
* 改成程序判定后,"当时为什么叫了"**不再能从时间表重建**(原因在程序肚子里);
|
|
1675
|
+
* 留空白 = 连"丢在哪一环"都看不出来。写明"未提供"至少把缺口标出来了(CCE:重建 > 保存)。
|
|
1676
|
+
* @param dirName 目标轨迹目录名
|
|
1677
|
+
* @param mdPath 目标 SESSION.md 绝对路径
|
|
1678
|
+
* @param prompt 程序给的唤起提示词
|
|
1679
|
+
* @param reason 程序给的判定理由(可为 null)
|
|
1680
|
+
* @returns 投递正文
|
|
1681
|
+
*/
|
|
1682
|
+
function buildCroWakeText(dirName, mdPath, prompt, reason) {
|
|
1683
|
+
return [
|
|
1684
|
+
`[CRO 唤起] ${dirName} 自带的 continuous-re-occurrence.ts 判定:现在该唤起。`,
|
|
1685
|
+
`当前时间:${localHuman()}(当地时区)。`,
|
|
1686
|
+
"",
|
|
1687
|
+
`身份锚定:继续 ${dirName} 的 trajectory(SESSION.md: ${mdPath})。`,
|
|
1688
|
+
"",
|
|
1689
|
+
`判定理由:${reason ?? "(程序未提供 reason —— 见 CRO 编写指南 §7 坑 3)"}`,
|
|
1690
|
+
"",
|
|
1691
|
+
"唤起提示词:",
|
|
1692
|
+
prompt,
|
|
1693
|
+
"",
|
|
1694
|
+
"说明:这是由**本轨迹自己的 CRO 程序**按当时情况做的判定(**不是**定时唤醒,**也不是**别人发的消息)。处理完照常把进展写入 SESSION.md。"
|
|
1695
|
+
].join("\n");
|
|
1696
|
+
}
|
|
1697
|
+
/**
|
|
1698
|
+
* 投递一条 CRO 唤起(**fire-and-forget**,与 {@link deliverWake} 同层)。
|
|
1699
|
+
*
|
|
1700
|
+
* · **取用通路复用** {@link acquireWakeAgent}(live 优先 → 冷载入):这是调度器侧唯一的
|
|
1701
|
+
* "怎么把一句话送到一条轨迹"的知识 ⇒ 不另立第二条(设计 §9-2 的倾向:复用投递面)。
|
|
1702
|
+
* · 🔴 **不落唤醒表**(设计 §6.3):CRO 是"**持续判定**",不是"**某个未来时刻**"。
|
|
1703
|
+
* 落表会给 §0Q(投递即删、表只保在办)**增加一个新的写入源**。
|
|
1704
|
+
* · 用 `followup`(不用 `steer`):CRO 由**调度器**发起,与 `send-later` 同层;
|
|
1705
|
+
* `steer`(当场注入当前轮)是 `send-now` 的语义 —— 那是人类/agent 的**即时动作**。
|
|
1706
|
+
* · 🔴 **失败不回滚、不重试**(与条目投递不同):下一 tick 会**重新判定**——
|
|
1707
|
+
* CRO 的判据是"情况",不是"这条消息还没送出去" ⇒ 自愈来自**重判**,不来自重投。
|
|
1708
|
+
* @param ctx 插件上下文
|
|
1709
|
+
* @param root CCC 根
|
|
1710
|
+
* @param dirName 目标轨迹目录名(= CRO 只能唤起**自己**,设计 §9-8 的倾向)
|
|
1711
|
+
* @param prompt 唤起提示词
|
|
1712
|
+
* @param reason 判定理由
|
|
1713
|
+
* @returns 投递结果(进 tick 日志;**不写注册表**)
|
|
1714
|
+
*/
|
|
1715
|
+
async function deliverCroWake(ctx, root, dirName, prompt, reason) {
|
|
1716
|
+
const mdPath = join(sessionsRoot(root), dirName, "SESSION.md");
|
|
1717
|
+
const acquired = await acquireWakeAgent(ctx, root, dirName);
|
|
1718
|
+
if ("error" in acquired) return {
|
|
1719
|
+
ok: false,
|
|
1720
|
+
detail: acquired.error,
|
|
1721
|
+
notReady: acquired.notReady === true
|
|
1722
|
+
};
|
|
1723
|
+
try {
|
|
1724
|
+
acquired.agent.followup(createUserMessage({
|
|
1725
|
+
content: [{
|
|
1726
|
+
type: "text",
|
|
1727
|
+
text: buildCroWakeText(dirName, mdPath, prompt, reason)
|
|
1728
|
+
}],
|
|
1729
|
+
source: PLUGIN_SOURCE
|
|
1730
|
+
}));
|
|
1731
|
+
} catch (err) {
|
|
1732
|
+
return {
|
|
1733
|
+
ok: false,
|
|
1734
|
+
detail: `CRO 投递失败(${acquired.how}): ${String(err?.message ?? err)}`
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
return {
|
|
1738
|
+
ok: true,
|
|
1739
|
+
detail: `已投递(${acquired.how})`
|
|
1740
|
+
};
|
|
1741
|
+
}
|
|
1742
|
+
/**
|
|
1743
|
+
* CRO 阶段:本 tick 对**每条启用了 CRO 的轨迹**跑一次它自带的程序,按判定投递。
|
|
1744
|
+
*
|
|
1745
|
+
* ## 为什么放在既有投递**之后**(R↓)
|
|
1746
|
+
* · 既有条目(`send-later`)**有到点承诺** ⇒ 优先级天然更高;
|
|
1747
|
+
* · CRO 是"看着情况叫",**晚一拍无成本**(tick 粒度本就是 5min);
|
|
1748
|
+
* · ⇒ 铁律(设计 §5):**CRO 的任何失败都不影响既有链路** —— 整段由调用方兜住,
|
|
1749
|
+
* 且**每条轨迹各自 try/catch**(一条坏程序不得连累同 tick 的其它轨迹)。
|
|
1750
|
+
*
|
|
1751
|
+
* ## 日志口径(刻意与既有投递不同)
|
|
1752
|
+
* "每 tick × 每条轨迹"都写一行 ⇒ 对"**常态是不唤起**"的 CRO 就是刷屏。
|
|
1753
|
+
* ⇒ 逐条只记 **唤起**(`reason` 进日志 —— 设计 §4.2 要求可重建)与 **跳过**(异常,必须看见);
|
|
1754
|
+
* **不唤起只进汇总行**(`唤起 x,不唤起 y,跳过 z`)。
|
|
1755
|
+
* ⚠️ **诚实标注**:因此"某条轨迹当轮**为何没叫**"**不进 tick 日志**(它在程序肚子里)。
|
|
1756
|
+
* 要完整的逐次判定审计,须**程序自己**写状态文件(设计 §3.3)——ACC 给不了。
|
|
1757
|
+
* @param ctx 插件上下文
|
|
1758
|
+
* @param root CCC 根
|
|
1759
|
+
* @param registry 本 tick 已载入的唤醒注册表条目(供快照的 `pendingWakes`;读坏时传 `[]`)
|
|
1760
|
+
* @param log 本 tick 的人读摘要(就地追加)
|
|
1761
|
+
*/
|
|
1762
|
+
async function runCroPhase(ctx, root, registry, log) {
|
|
1763
|
+
let dirs;
|
|
1764
|
+
try {
|
|
1765
|
+
dirs = listCroTrajectories(root);
|
|
1766
|
+
} catch {
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
if (dirs.length === 0) return;
|
|
1770
|
+
const nowMs = Date.now();
|
|
1771
|
+
const s = wakeSchedulerState();
|
|
1772
|
+
const scheduler = {
|
|
1773
|
+
armed: s.armed,
|
|
1774
|
+
enabled: s.enabled,
|
|
1775
|
+
ticks: s.ticks,
|
|
1776
|
+
lastSkipReason: s.lastSkipReason
|
|
1777
|
+
};
|
|
1778
|
+
const agents = hostAgents(ctx);
|
|
1779
|
+
let wakes = 0;
|
|
1780
|
+
let quiet = 0;
|
|
1781
|
+
let skips = 0;
|
|
1782
|
+
for (const dirName of dirs) try {
|
|
1783
|
+
const bound = listBoundSessionIds(root, dirName);
|
|
1784
|
+
const running = runningCarriersOf(bound, nowMs);
|
|
1785
|
+
const outcome = await evaluateCro(root, dirName, readCroSnapshotInput(root, dirName, nowMs, {
|
|
1786
|
+
boundSessionIds: bound,
|
|
1787
|
+
liveSessionIds: bound.filter((id) => Boolean(agents?.get?.(id))),
|
|
1788
|
+
runningSessionIds: running,
|
|
1789
|
+
pendingWakes: registry.filter((e) => e.state === "pending" && resolveWakeTarget(root, e.target)?.dirName === dirName).map((e) => ({
|
|
1790
|
+
id: e.id,
|
|
1791
|
+
at: e.at,
|
|
1792
|
+
createdAt: e.createdAt,
|
|
1793
|
+
createdBy: e.createdBy
|
|
1794
|
+
})),
|
|
1795
|
+
scheduler
|
|
1796
|
+
}));
|
|
1797
|
+
if (outcome.status === "wake") {
|
|
1798
|
+
wakes += 1;
|
|
1799
|
+
const res = await deliverCroWake(ctx, root, dirName, outcome.prompt, outcome.decision.reason);
|
|
1800
|
+
log.push(`· ${root}: ${renderCroOutcome(dirName, outcome)} → ${res.ok ? "已投递" : `投递未成功:${res.detail}`}`);
|
|
1801
|
+
if (!res.notReady) {
|
|
1802
|
+
const rec = appendCroWakeLog(root, dirName, {
|
|
1803
|
+
ok: res.ok,
|
|
1804
|
+
detail: res.detail,
|
|
1805
|
+
tick: s.ticks,
|
|
1806
|
+
reason: outcome.decision.reason ?? null,
|
|
1807
|
+
prompt: outcome.prompt
|
|
1808
|
+
}, nowMs);
|
|
1809
|
+
if (!rec.ok) log.push(`· ${root}: ${dirName}: CRO 流水写入失败(已忽略,不影响投递): ${rec.error}`);
|
|
1810
|
+
}
|
|
1811
|
+
} else if (outcome.status === "skipped") {
|
|
1812
|
+
skips += 1;
|
|
1813
|
+
log.push(`· ${root}: ${renderCroOutcome(dirName, outcome)}`);
|
|
1814
|
+
} else if (outcome.status === "no-wake") quiet += 1;
|
|
1815
|
+
} catch (err) {
|
|
1816
|
+
skips += 1;
|
|
1817
|
+
log.push(`· ${root}: ${dirName}: CRO 内部异常(已忽略,不影响既有链路): ${String(err?.message ?? err)}`);
|
|
1818
|
+
}
|
|
1819
|
+
log.push(`· ${root}: CRO 评估 ${dirs.length} 条(唤起 ${wakes},不唤起 ${quiet},跳过 ${skips})`);
|
|
1820
|
+
}
|
|
896
1821
|
/** 一次调度 tick:遍历已知 CCC → 到期项串行投递 / 超窗项置 missed;返回人读摘要 */
|
|
897
1822
|
async function runWakeTick(ctx) {
|
|
898
1823
|
const log = [];
|
|
@@ -908,6 +1833,7 @@ async function runWakeTick(ctx) {
|
|
|
908
1833
|
const { registry, error } = loadWakeRegistry(root);
|
|
909
1834
|
if (error) {
|
|
910
1835
|
log.push(`✗ ${root}: ${error}`);
|
|
1836
|
+
await runCroPhase(ctx, root, [], log);
|
|
911
1837
|
continue;
|
|
912
1838
|
}
|
|
913
1839
|
const { due, expired } = splitDueWakes(registry, Date.now(), WAKE_CATCH_UP_MS);
|
|
@@ -933,6 +1859,7 @@ async function runWakeTick(ctx) {
|
|
|
933
1859
|
log.push(`· ${root}: ${e.id} → 重试(${res.detail})`);
|
|
934
1860
|
}
|
|
935
1861
|
}
|
|
1862
|
+
await runCroPhase(ctx, root, registry.entries, log);
|
|
936
1863
|
}
|
|
937
1864
|
return log;
|
|
938
1865
|
}
|
|
@@ -947,4 +1874,4 @@ function registerWakeScheduler(ctx) {
|
|
|
947
1874
|
registerDisposer(ctx, "trajectory 唤醒调度器", () => clock.dispose());
|
|
948
1875
|
}
|
|
949
1876
|
//#endregion
|
|
950
|
-
export {
|
|
1877
|
+
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 };
|