@shgroup/dsh-serenity-hooks 1.47.3 → 1.47.4
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/dsh.plugin.json +1 -1
- package/lib/api.d.ts +0 -10
- package/lib/clock-runtime.d.ts +7 -5
- package/lib/cro-turns.d.ts +0 -3
- package/lib/cro.d.ts +0 -2
- package/lib/index.js +2118 -50
- package/lib/{message-source-aR-FJ38L.js → message-source-yuCgrbU6.js} +45 -4
- package/lib/msm-ops.d.ts +3 -1
- package/lib/tools/trajectory.d.ts +0 -22
- package/lib/trajectory-assistant.d.ts +3 -8
- package/lib/trajectory-bound.d.ts +0 -17
- package/lib/trajectory-ops.d.ts +29 -2
- package/lib/trajectory-skills.d.ts +0 -2
- package/lib/wake-scheduler.d.ts +1 -1
- package/package.json +1 -1
- package/lib/session-cleanup-BtkSmry8.js +0 -213
- package/lib/wake-scheduler-BR4hD8Vj.js +0 -1888
package/lib/index.js
CHANGED
|
@@ -2,13 +2,11 @@ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
|
|
|
2
2
|
import { a as hostSessions, c as hostWeb, i as hostService, l as hostWebServer, n as hostInjected, o as hostSettings, r as hostLlm, s as hostSubagents, t as hostAgents } from "./access-CNccNtgf.js";
|
|
3
3
|
import { a as findSerenityRoot, c as matchBlacklist, d as readCccName$1, f as readExclusiveTools, g as resolveInside, i as findGitRoot, l as pathInside, m as readTrajectorySkills, n as SAFE_MODE_MARKER, o as isSafeModeOn, p as readHandymanConfig, r as classifyPath, s as loadSerenityConfig, t as DEFAULT_SERENITY_CONFIG_PATHS, u as readBlacklist } from "./ccc-DG4Oc7I1.js";
|
|
4
4
|
import { i as cccRootForExec, n as agentCwdFor, o as listCccs, r as cccRootForCwd, t as NO_CCC_FROM_AGENT_CWD } from "./ccc-roots-NpTWo0-c.js";
|
|
5
|
-
import { C as
|
|
5
|
+
import { A as localIdStamp, C as setActiveSessionInfo, D as isoLocal, E as useSession, O as localFileStamp, S as sessionsRoot, T as summarize, _ as namingTitleFor, a as pruneMissingBindings, b as resolveSessionByTitle, c as setBindingStore, d as clearActiveSessionInfo, f as createSession, g as listSessions, h as getActiveSessionInfo, i as listBoundSessionIds, k as localHuman, l as DEFAULT_SESSION_SCOPE, m as findSession, n as PLUGIN_SOURCE, o as readLastBound, p as extractSessionMdPathFromText, r as appendBound, s as resolveSessionTrajectoryLabel, t as ACC_MESSAGE_KIND, u as TRAJECTORY_ACTIONS, v as parseSessionContextFromEvents, w as showSession, x as sessionEvents$1, y as readActiveSessionMd } from "./message-source-yuCgrbU6.js";
|
|
6
6
|
import { a as readWeixinCredential, c as weixinInboundDir, d as LOCALSTORE_SCOPES, f as checkLocalstoreGitCompliance, g as runLocalStore, h as readStore, i as matchWeixinRoute, l as weixinSessionIdFor, m as readGitTrack, n as extractWeixinText, o as readWeixinSettings, p as localstorePath, r as hasVoiceItem, s as sanitizeFileName$1, t as extractWeixinMedia } from "./weixin-route-CWMRa36W.js";
|
|
7
7
|
import { a as unregisterSkiffSession$1, i as skiffSessionSnapshot$1, n as skiffRoleFor$1, o as ACC_VERSION, r as skiffSessionInfo$1, t as registerSkiffSession$1 } from "./skiff-registry-BjIX_KD1.js";
|
|
8
8
|
import { a as readSkiffRoles, c as roleMsmWhitelist, d as systemPromptSource, f as trajectorySubset, i as isSkiffSessionId, l as roleToolWhitelist, n as buildSkiffBasePrompt, o as resolveRoleSystemPrompt, r as createRolePromptReader, s as resolveSkiffKind } from "./skiff-role-KqQA6pkr.js";
|
|
9
9
|
import { a as FACE_PORTS, c as MECHANISM_PORTS, i as ACP_HTTP_PORT, l as SKIFF_DEBUG_PORT, n as registerSettingsSection, o as GATEWAY_PORT, s as MAIN_WEB_PORT, t as readSimpleSettings, u as WEIXIN_SEND_PORT } from "./settings-section-DxMwlCv9.js";
|
|
10
|
-
import { r as sessionsRootDir, t as hasSessionLogById } from "./session-cleanup-BtkSmry8.js";
|
|
11
|
-
import { a as registerDisposer, c as WAKE_CATCH_UP_MS, i as registerCroTurnTracking, l as addWake, n as sendToTrajectory, o as CRO_FILENAME, r as wakeSchedulerState, s as buildCroSnapshot, t as registerWakeScheduler, u as listWakes } from "./wake-scheduler-BR4hD8Vj.js";
|
|
12
10
|
import { a as markdownToPlainText, c as sendTyping, i as getUpdates, l as sniffImageExt, n as downloadMedia, o as sendFileMessage, r as getConfig, s as sendTextMessage, t as TypingStatus } from "./weixin-api-CtFYm53S.js";
|
|
13
11
|
import z from "@deepseek-ai/schemastery";
|
|
14
12
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
@@ -2085,7 +2083,9 @@ function truncateContent(content, maxChars) {
|
|
|
2085
2083
|
*/
|
|
2086
2084
|
/** 全文注入总长上限(字符数;规格 §3「缺省 32 KB」——防上下文爆炸) */
|
|
2087
2085
|
const TRAJECTORY_SKILLS_MAX_CHARS = 32768;
|
|
2088
|
-
/** 缺失标记(规格 §3/§6-3 的验收锚点:任何"拿不到全文"的情况都必须带它,绝不静默)
|
|
2086
|
+
/** 缺失标记(规格 §3/§6-3 的验收锚点:任何"拿不到全文"的情况都必须带它,绝不静默)
|
|
2087
|
+
* 🔴 2026-09-25(S142 分批清死代码·第 1 批):**降为模块内常量**——全仓唯一消费者在本文件,
|
|
2088
|
+
* 导出只服务测试(测试用的是字面量 `'[缺失]'`)⇒ 导出面无意义。 */
|
|
2089
2089
|
const SKILL_MISSING_MARK = "[缺失]";
|
|
2090
2090
|
/** 统一缺失提示(规格 §3 逐字约定:`[缺失] <name>(未找到该 skill)`) */
|
|
2091
2091
|
function missingNotice(name) {
|
|
@@ -3298,14 +3298,22 @@ function sessionBlock(root, scope = DEFAULT_SESSION_SCOPE) {
|
|
|
3298
3298
|
`Active session: ${active.sessionId} — ${active.dirName} (this session is the rebuildable carrier of the trajectory)`,
|
|
3299
3299
|
`SESSION.md path: ${active.mdPath} (the trajectory's persistent body — stays in place through rebuilds)`,
|
|
3300
3300
|
"",
|
|
3301
|
-
"Rules:",
|
|
3302
|
-
" •
|
|
3303
|
-
" •
|
|
3301
|
+
"Rules (SESSION.md is the trajectory log — keep its format & granularity fixed):",
|
|
3302
|
+
" • Write only when a MILESTONE landed or a DECISION was made — not every round",
|
|
3303
|
+
" • One entry = one abstraction level, at the highest one that can express it:",
|
|
3304
|
+
" identity/anchors (rare) › decision (one line + rationale/rejected) › milestone",
|
|
3305
|
+
" (one line + an evidence pointer, e.g. a commit id). Execution detail — test",
|
|
3306
|
+
" counts, coverage decimals, per-file readings, step narration — belongs in the",
|
|
3307
|
+
" COMMIT MESSAGE, not here",
|
|
3308
|
+
" • Never write a value that changes by the act of writing it (file size; the hash",
|
|
3309
|
+
" of the commit containing this edit)",
|
|
3310
|
+
" • Fixed sections, in this order: 身份与边界 / 权威锚点 / 工作纪律 / 决策 / 日志 /",
|
|
3311
|
+
" 当前状态 / 待 owner 裁决. The 日志 section appends only; 当前状态 and",
|
|
3312
|
+
" 待 owner 裁决 are OVERWRITTEN each round",
|
|
3304
3313
|
" • Reference this session in all subsequent messages",
|
|
3305
3314
|
"",
|
|
3306
|
-
"IMPORTANT: Read SESSION.md now.
|
|
3307
|
-
"
|
|
3308
|
-
"list. Keep todos in sync with SESSION.md as work progresses.",
|
|
3315
|
+
"IMPORTANT: Read SESSION.md now to RESUME — not to audit. Sync the todo list from",
|
|
3316
|
+
"the 当前状态 and 待 owner 裁决 sections only.",
|
|
3309
3317
|
"",
|
|
3310
3318
|
"CRITICAL: When calling todowrite, the first item in the todos array MUST",
|
|
3311
3319
|
"always be:",
|
|
@@ -3569,7 +3577,9 @@ function workspaceTrajectoryLine(mdPath) {
|
|
|
3569
3577
|
" 2. Record key decisions/progress/unresolved items INTO this SESSION.md using the write channel",
|
|
3570
3578
|
" your role is actually granted (use whatever write tool or MSM this role has — never assume",
|
|
3571
3579
|
" a specific write tool exists; if none is granted, report that instead of pretending to write).",
|
|
3572
|
-
" Sections:
|
|
3580
|
+
" Sections: 身份与边界 / 权威锚点 / 工作纪律 / 决策 / 日志 / 当前状态 / 待 owner 裁决.",
|
|
3581
|
+
" Keep entries at milestone level (one line + evidence pointer); execution detail",
|
|
3582
|
+
" belongs in commit messages. Keep it current — it is",
|
|
3573
3583
|
" what a rebuild resumes from.",
|
|
3574
3584
|
" 3. When context pressure is high, run container_trajectory rebuild — it auto-resumes from THIS SESSION.md",
|
|
3575
3585
|
" (no manual use; do not switch to or read other SESSIONs unless the user explicitly asks).",
|
|
@@ -4651,6 +4661,2095 @@ function createHandymanTool(ctx) {
|
|
|
4651
4661
|
});
|
|
4652
4662
|
}
|
|
4653
4663
|
//#endregion
|
|
4664
|
+
//#region src/session-cleanup.ts
|
|
4665
|
+
/**
|
|
4666
|
+
* session-cleanup.ts — DSH 平台旧会话自动清理(v1.29,S142 需求③)
|
|
4667
|
+
*
|
|
4668
|
+
* 背景(实证):DSH 会话物理存储 = `$DSH_HOME/sessions/`(缺省 ~/.dsh/sessions;
|
|
4669
|
+
* base bundle cordis.patch.yml root: dshHomePath('sessions'))。磁盘布局:
|
|
4670
|
+
* <root>/--<project-slug>--/<encoded-session-id>/session[.vN].jsonl(.zstd)
|
|
4671
|
+
* **世代(v1.31.13 补)**:宿主自 DSH 0.1.5 起按世代写文件,且迁移**保留源文件**
|
|
4672
|
+
* ⇒ 一个会话目录常见 v0 与 v3 两个文件并存。本模块用 `sessionLogArtifacts()` 统一枚举世代。
|
|
4673
|
+
* DSH **无删除会话 API**(PersistenceCoordinator 仅 create/append/load/inspect/borrow,
|
|
4674
|
+
* 无 delete/purge;workspace archiveSession 只加归档集不删文件)——物理删除只能直接
|
|
4675
|
+
* 删文件。删除后一致性(利好):sessionPersistence.list() = readdir 扫描磁盘现存 →
|
|
4676
|
+
* 物理删后 list 不再返回;WorkspaceRegistry 下次启动重建 header index 自动消失。
|
|
4677
|
+
*
|
|
4678
|
+
* 方案(S142 用户拍板):lastActive 基准 + 手动触发 + 不过滤不归档直接物理删;
|
|
4679
|
+
* 安全底线 = live 会话保护跳过(删正在运行的会话 = 灾难)。
|
|
4680
|
+
*
|
|
4681
|
+
* 本模块纯逻辑(零 ctx 依赖):传入 sessionsRoot + liveIds,可单测。
|
|
4682
|
+
*/
|
|
4683
|
+
var session_cleanup_exports = /* @__PURE__ */ __exportAll({
|
|
4684
|
+
collectEligibleSessions: () => collectEligibleSessions,
|
|
4685
|
+
cutoffDaysAgo: () => cutoffDaysAgo,
|
|
4686
|
+
findSessionLog: () => findSessionLog,
|
|
4687
|
+
hasSessionLogById: () => hasSessionLogById,
|
|
4688
|
+
performCleanup: () => performCleanup,
|
|
4689
|
+
sessionLogArtifacts: () => sessionLogArtifacts,
|
|
4690
|
+
sessionLogBytesById: () => sessionLogBytesById,
|
|
4691
|
+
sessionsRootDir: () => sessionsRootDir
|
|
4692
|
+
});
|
|
4693
|
+
/** DSH 会话 root:env DSH_HOME → ~/.dsh(config-ops globalConfigPath 同款推导) */
|
|
4694
|
+
function sessionsRootDir() {
|
|
4695
|
+
const dshHome = process.env.DSH_HOME ?? join(process.env.HOME ?? "", ".dsh");
|
|
4696
|
+
return join(dshHome, "sessions");
|
|
4697
|
+
}
|
|
4698
|
+
/** 一天的毫秒数 */
|
|
4699
|
+
const DAY_MS = 864e5;
|
|
4700
|
+
/**
|
|
4701
|
+
* 扫描 sessions root,列出可清理候选:
|
|
4702
|
+
* - 递归 `<root>/<project>/<session-id>/session.jsonl(.zstd)`(任意深度下找 *session* 文件)
|
|
4703
|
+
* - 过滤 live(liveIds 集合)——安全底线
|
|
4704
|
+
* - 过滤 lastActive >= cutoffMs(最后活动未达阈值 → 保留)
|
|
4705
|
+
* root 不存在/为空 → 返回 [](非错误)。
|
|
4706
|
+
*/
|
|
4707
|
+
function collectEligibleSessions(root, cutoffMs, liveIds = /* @__PURE__ */ new Set()) {
|
|
4708
|
+
if (!existsSync(root)) return [];
|
|
4709
|
+
const out = [];
|
|
4710
|
+
for (const project of readdirSync(root, { withFileTypes: true })) {
|
|
4711
|
+
if (!project.isDirectory()) continue;
|
|
4712
|
+
const projectPath = join(root, project.name);
|
|
4713
|
+
for (const entry of readdirSync(projectPath, { withFileTypes: true })) {
|
|
4714
|
+
if (!entry.isDirectory()) continue;
|
|
4715
|
+
const dirPath = join(projectPath, entry.name);
|
|
4716
|
+
const artifacts = sessionLogArtifacts(dirPath);
|
|
4717
|
+
if (artifacts.length === 0) continue;
|
|
4718
|
+
const log = findSessionLog(dirPath);
|
|
4719
|
+
if (log === null) continue;
|
|
4720
|
+
if (liveIds.has(entry.name)) continue;
|
|
4721
|
+
let mtime = Number.NEGATIVE_INFINITY;
|
|
4722
|
+
for (const artifact of artifacts) try {
|
|
4723
|
+
mtime = Math.max(mtime, statSync(artifact.path).mtimeMs);
|
|
4724
|
+
} catch {}
|
|
4725
|
+
if (!Number.isFinite(mtime)) continue;
|
|
4726
|
+
if (mtime >= cutoffMs) continue;
|
|
4727
|
+
out.push({
|
|
4728
|
+
id: entry.name,
|
|
4729
|
+
project: project.name,
|
|
4730
|
+
logPath: log,
|
|
4731
|
+
lastActiveMs: mtime
|
|
4732
|
+
});
|
|
4733
|
+
}
|
|
4734
|
+
}
|
|
4735
|
+
return out;
|
|
4736
|
+
}
|
|
4737
|
+
/**
|
|
4738
|
+
* 会话日志文件名识别:`session.jsonl` / `session.jsonl.zstd`(= **v0 世代**)/
|
|
4739
|
+
* `session.v<N>.jsonl[.zstd]`(= 第 N 世代,DSH 0.1.5 起按世代写文件)。
|
|
4740
|
+
* 出处:`@deepseek-ai/dsh-session-persistence-jsonl` 的 `parseSessionFormatLogFilename` 同款形态。
|
|
4741
|
+
*/
|
|
4742
|
+
const SESSION_LOG_RE = /^session(?:\.v(\d+))?\.jsonl(\.zstd)?$/;
|
|
4743
|
+
/**
|
|
4744
|
+
* 列出会话目录下的**全部日志世代**,按代递增排序(同代内 `.jsonl` 在前、`.zstd` 在后 = 后者更代表"当前书写格式")。
|
|
4745
|
+
*
|
|
4746
|
+
* 为什么需要(R↓,v1.31.13 修复):宿主自 0.1.5 起改成**按世代写文件**,且迁移**保留源文件**
|
|
4747
|
+
* ⇒ 同一会话常见 `session.jsonl.zstd`(v0)与 `session.v3.jsonl.zstd`(v3)**并存**。
|
|
4748
|
+
* 旧实现只认前两个名字 → 新格式会话对本模块**完全不可见**(静默 no-op:清理永远不生效、磁盘只涨)。
|
|
4749
|
+
*/
|
|
4750
|
+
function sessionLogArtifacts(sessionDirPath) {
|
|
4751
|
+
let names;
|
|
4752
|
+
try {
|
|
4753
|
+
names = readdirSync(sessionDirPath);
|
|
4754
|
+
} catch {
|
|
4755
|
+
return [];
|
|
4756
|
+
}
|
|
4757
|
+
const out = [];
|
|
4758
|
+
for (const name of names) {
|
|
4759
|
+
const m = SESSION_LOG_RE.exec(name);
|
|
4760
|
+
if (m === null) continue;
|
|
4761
|
+
out.push({
|
|
4762
|
+
name,
|
|
4763
|
+
generation: m[1] === void 0 ? 0 : Number(m[1]),
|
|
4764
|
+
path: join(sessionDirPath, name)
|
|
4765
|
+
});
|
|
4766
|
+
}
|
|
4767
|
+
return out.sort((a, b) => a.generation - b.generation || (a.name.endsWith(".zstd") ? 1 : 0) - (b.name.endsWith(".zstd") ? 1 : 0));
|
|
4768
|
+
}
|
|
4769
|
+
/**
|
|
4770
|
+
* 在会话目录下找 session 日志文件;无 → null。
|
|
4771
|
+
* 多世代并存时返回**最高世代**(= 宿主 `findLog` 选中的当前世代;旧行为对单文件目录完全一致)。
|
|
4772
|
+
*/
|
|
4773
|
+
function findSessionLog(sessionDirPath) {
|
|
4774
|
+
const artifacts = sessionLogArtifacts(sessionDirPath);
|
|
4775
|
+
const latest = artifacts[artifacts.length - 1];
|
|
4776
|
+
return latest === void 0 ? null : latest.path;
|
|
4777
|
+
}
|
|
4778
|
+
/**
|
|
4779
|
+
* 执行清理(用户拍板:直接物理删,不过滤不归档;dryRun 预览不删)。
|
|
4780
|
+
* 删除 = rmSync 整个会话目录(recursive + force)。失败逐个记 errors 不中断。
|
|
4781
|
+
*/
|
|
4782
|
+
function performCleanup(root, cutoffMs, liveIds = /* @__PURE__ */ new Set(), opts = {}) {
|
|
4783
|
+
const candidates = collectEligibleSessions(root, cutoffMs, liveIds);
|
|
4784
|
+
if (opts.dryRun) return {
|
|
4785
|
+
candidates,
|
|
4786
|
+
result: null
|
|
4787
|
+
};
|
|
4788
|
+
const result = {
|
|
4789
|
+
deleted: [],
|
|
4790
|
+
errors: []
|
|
4791
|
+
};
|
|
4792
|
+
for (const c of candidates) {
|
|
4793
|
+
const dir = dirOf(c.logPath);
|
|
4794
|
+
try {
|
|
4795
|
+
rmSync(dir, {
|
|
4796
|
+
recursive: true,
|
|
4797
|
+
force: true
|
|
4798
|
+
});
|
|
4799
|
+
result.deleted.push(c.id);
|
|
4800
|
+
} catch (e) {
|
|
4801
|
+
result.errors.push({
|
|
4802
|
+
id: c.id,
|
|
4803
|
+
reason: e.message
|
|
4804
|
+
});
|
|
4805
|
+
}
|
|
4806
|
+
}
|
|
4807
|
+
return {
|
|
4808
|
+
candidates,
|
|
4809
|
+
result
|
|
4810
|
+
};
|
|
4811
|
+
}
|
|
4812
|
+
/** 从日志路径取会话目录(父目录) */
|
|
4813
|
+
function dirOf(logPath) {
|
|
4814
|
+
return logPath.slice(0, logPath.lastIndexOf("/")) || logPath;
|
|
4815
|
+
}
|
|
4816
|
+
/**
|
|
4817
|
+
* 某一 dsh 会话(目录名 / 会话 id)的**当前世代日志体积**(字节)。
|
|
4818
|
+
*
|
|
4819
|
+
* 为什么要它(S142 §0y「旧会话累积 ⇒ dsh 内存崩溃」,所有者令):
|
|
4820
|
+
* · 宿主恢复/继续一条会话必须**重放全部事件**(`scanLog` → `materializeAppendBatch`)
|
|
4821
|
+
* ⇒ **代价 ∝ 日志体积**;
|
|
4822
|
+
* · 实测(`_tmp/measure-session-load.mjs`,照宿主 `scanZstdFrames` 复刻):
|
|
4823
|
+
* 一条 **72.2 MB**(zstd)的日志 = 35 559 个 frame / 252.6 MB 文本 / 62 680 条事件,
|
|
4824
|
+
* **光是读一遍峰值 RSS +1 175 MB**(探针什么都不保留);
|
|
4825
|
+
* · 而宿主的解析缓存 `COLD_LOG_MEMO_MAX_ENTRIES = 2` 是**按条数限、不按字节限**的
|
|
4826
|
+
* (`dsh-session-persistence-jsonl/lib/index.js:2175`)⇒ 两条巨日志 = 上限内。
|
|
4827
|
+
* 🔴 **在本函数之前,ACC 侧没有任何地方知道"绑定的这条会话日志多大"——这个信号根本不存在。**
|
|
4828
|
+
* 这是"靠 agent 自觉 rebuild 不够"的机制层原因(keeper 的 K 量的是**模型上下文**,压缩后回落;
|
|
4829
|
+
* 而**日志文件只增不减** ⇒ rebuild 提醒永远不会因为"日志 72 MB"触发)。
|
|
4830
|
+
*
|
|
4831
|
+
* 只认会话目录(含任意世代日志);返回**最高世代**的日志(= 宿主 `findLog` 选中的当前世代)。
|
|
4832
|
+
* 未命中 / 不可读 → null(非错误,调用方按"未知"处理)。
|
|
4833
|
+
* @param root - DSH 会话 root({@link sessionsRootDir})
|
|
4834
|
+
* @param sessionId - 会话目录名(`.bindings.json` 的键,含 `session-` 前缀)或裸 id
|
|
4835
|
+
* @returns 字节数 + 日志绝对路径;未命中 → null
|
|
4836
|
+
*/
|
|
4837
|
+
function sessionLogBytesById(root, sessionId) {
|
|
4838
|
+
if (sessionId === "" || !existsSync(root)) return null;
|
|
4839
|
+
const dirName = sessionId.startsWith("session-") ? sessionId : `session-${sessionId}`;
|
|
4840
|
+
for (const project of readdirSync(root, { withFileTypes: true })) {
|
|
4841
|
+
if (!project.isDirectory()) continue;
|
|
4842
|
+
const log = findSessionLog(join(root, project.name, dirName));
|
|
4843
|
+
if (log === null) continue;
|
|
4844
|
+
try {
|
|
4845
|
+
return {
|
|
4846
|
+
bytes: statSync(log).size,
|
|
4847
|
+
path: log
|
|
4848
|
+
};
|
|
4849
|
+
} catch {
|
|
4850
|
+
return null;
|
|
4851
|
+
}
|
|
4852
|
+
}
|
|
4853
|
+
return null;
|
|
4854
|
+
}
|
|
4855
|
+
/**
|
|
4856
|
+
* **(c) 绑定一致性兜底**的判据:某会话的日志**是否仍在磁盘上**。
|
|
4857
|
+
*
|
|
4858
|
+
* 🔴 **fail-closed**:sessions root 本身不存在时**一律返回 true(当作"在")**——
|
|
4859
|
+
* 否则换 `DSH_HOME` / 换机器会让"全部绑定看起来都悬空",进而误清整张绑定表。
|
|
4860
|
+
* 只有"root 在、而这个会话目录确实不在"才算悬空。
|
|
4861
|
+
* @param sessionRoot - DSH 会话 root({@link sessionsRootDir})
|
|
4862
|
+
* @param sessionId - 会话目录名(`.bindings.json` 的键)或裸 id
|
|
4863
|
+
*/
|
|
4864
|
+
function hasSessionLogById(sessionRoot, sessionId) {
|
|
4865
|
+
if (!existsSync(sessionRoot)) return true;
|
|
4866
|
+
return sessionLogBytesById(sessionRoot, sessionId) !== null;
|
|
4867
|
+
}
|
|
4868
|
+
/** 便捷:默认 N 天前为 cutoff(供 API/调用方) */
|
|
4869
|
+
function cutoffDaysAgo(days, nowMs = Date.now()) {
|
|
4870
|
+
return nowMs - days * DAY_MS;
|
|
4871
|
+
}
|
|
4872
|
+
//#endregion
|
|
4873
|
+
//#region src/wake-registry.ts
|
|
4874
|
+
/**
|
|
4875
|
+
* wake-registry.ts — trajectory 唤醒注册表(D58,S142 2026-09-13)
|
|
4876
|
+
*
|
|
4877
|
+
* 需求(用户原话):一个 CCC 内允许多个 trajectory 任意形态并行;某个 trajectory 可以
|
|
4878
|
+
* **预定未来唤醒自己或别人**,本质上是一次「未来时刻 + 一条 message」的投递;「周期自唤醒」
|
|
4879
|
+
* 曾是它的特例(原 ACC autopilot / D59,该机制已于 2026-09-15 整段退场)。协作边界:
|
|
4880
|
+
* 互相可见、**无阻塞无等待**。
|
|
4881
|
+
*
|
|
4882
|
+
* 本模块只负责**注册表本身**(纯数据层,无宿主依赖,可单测):
|
|
4883
|
+
* - 落点:CCC 内 `AGENT_SESSIONS/wake-registry.json`(随 CCC git,人可读可审计)
|
|
4884
|
+
* - 状态机:`pending` →(投递成功)`delivered`|(超补跑窗口)`missed`|(`wake rm` 物理移除)
|
|
4885
|
+
* - 投递即终结:一次性,不重投(无回执语义——发起方不 await,见设计文档 §2)
|
|
4886
|
+
*
|
|
4887
|
+
* 调度与投递在 `wake-scheduler.ts`(需要 ctx),本模块保持纯净。
|
|
4888
|
+
*/
|
|
4889
|
+
/** 注册表相对 CCC 根的路径(与 `.bindings.json` 同居 AGENT_SESSIONS/) */
|
|
4890
|
+
const WAKE_REGISTRY_REL_PATH = "AGENT_SESSIONS/wake-registry.json";
|
|
4891
|
+
/** 注册表格式版本(结构变更时 +1;读取端只接受本版本) */
|
|
4892
|
+
const WAKE_REGISTRY_VERSION = 1;
|
|
4893
|
+
/**
|
|
4894
|
+
* 补跑窗口(缺省 2h):dsh 停机期间到期的条目,若迟到不超过本窗口则补投,
|
|
4895
|
+
* 超过则置 `missed` 留痕(不补投——避免"半夜该做的事早上突然连环跑")。
|
|
4896
|
+
*/
|
|
4897
|
+
const WAKE_CATCH_UP_MS = 72e5;
|
|
4898
|
+
/** 注册表绝对路径 */
|
|
4899
|
+
function wakeRegistryPath(root) {
|
|
4900
|
+
return join(root, WAKE_REGISTRY_REL_PATH);
|
|
4901
|
+
}
|
|
4902
|
+
/** 空注册表 */
|
|
4903
|
+
function emptyRegistry() {
|
|
4904
|
+
return {
|
|
4905
|
+
version: WAKE_REGISTRY_VERSION,
|
|
4906
|
+
entries: []
|
|
4907
|
+
};
|
|
4908
|
+
}
|
|
4909
|
+
/** 条目形状校验(容忍手改:字段缺失/类型不符 → 丢弃该条,不阻断整表) */
|
|
4910
|
+
function asEntry$2(value) {
|
|
4911
|
+
const e = value;
|
|
4912
|
+
if (!e || typeof e.id !== "string" || typeof e.target !== "string") return null;
|
|
4913
|
+
if (typeof e.at !== "string" || typeof e.message !== "string") return null;
|
|
4914
|
+
const state = e.state === "delivered" || e.state === "missed" || e.state === "cancelled" ? e.state : "pending";
|
|
4915
|
+
return {
|
|
4916
|
+
id: e.id,
|
|
4917
|
+
target: e.target,
|
|
4918
|
+
at: e.at,
|
|
4919
|
+
message: e.message,
|
|
4920
|
+
state,
|
|
4921
|
+
createdBy: typeof e.createdBy === "string" ? e.createdBy : "",
|
|
4922
|
+
createdAt: typeof e.createdAt === "string" ? e.createdAt : "",
|
|
4923
|
+
attempts: typeof e.attempts === "number" && Number.isFinite(e.attempts) ? e.attempts : 0,
|
|
4924
|
+
lastResult: typeof e.lastResult === "string" ? e.lastResult : null,
|
|
4925
|
+
deliveredAt: typeof e.deliveredAt === "string" ? e.deliveredAt : null
|
|
4926
|
+
};
|
|
4927
|
+
}
|
|
4928
|
+
/**
|
|
4929
|
+
* 读注册表。文件缺失 → 空表;损坏 → 空表 + error(不抛错,调用方决定是否告警)。
|
|
4930
|
+
* @param root CCC 根
|
|
4931
|
+
* @returns 注册表与读取错误(无错为 null)
|
|
4932
|
+
*/
|
|
4933
|
+
function loadWakeRegistry(root) {
|
|
4934
|
+
const path = wakeRegistryPath(root);
|
|
4935
|
+
if (!existsSync(path)) return {
|
|
4936
|
+
registry: emptyRegistry(),
|
|
4937
|
+
error: null
|
|
4938
|
+
};
|
|
4939
|
+
try {
|
|
4940
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
4941
|
+
const raw = Array.isArray(parsed.entries) ? parsed.entries : [];
|
|
4942
|
+
const entries = [];
|
|
4943
|
+
for (const item of raw) {
|
|
4944
|
+
const e = asEntry$2(item);
|
|
4945
|
+
if (e) entries.push(e);
|
|
4946
|
+
}
|
|
4947
|
+
return {
|
|
4948
|
+
registry: {
|
|
4949
|
+
version: WAKE_REGISTRY_VERSION,
|
|
4950
|
+
entries
|
|
4951
|
+
},
|
|
4952
|
+
error: null
|
|
4953
|
+
};
|
|
4954
|
+
} catch (err) {
|
|
4955
|
+
return {
|
|
4956
|
+
registry: emptyRegistry(),
|
|
4957
|
+
error: `唤醒注册表解析失败(${String(err?.message ?? err)})`
|
|
4958
|
+
};
|
|
4959
|
+
}
|
|
4960
|
+
}
|
|
4961
|
+
/** 原子写(tmp + rename;与 .bindings.json 同款形态) */
|
|
4962
|
+
function saveWakeRegistry(root, registry) {
|
|
4963
|
+
const path = wakeRegistryPath(root);
|
|
4964
|
+
try {
|
|
4965
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
4966
|
+
const tmp = `${path}.tmp`;
|
|
4967
|
+
writeFileSync(tmp, `${JSON.stringify({
|
|
4968
|
+
version: WAKE_REGISTRY_VERSION,
|
|
4969
|
+
entries: registry.entries
|
|
4970
|
+
}, null, 2)}\n`, "utf-8");
|
|
4971
|
+
renameSync(tmp, path);
|
|
4972
|
+
return {
|
|
4973
|
+
ok: true,
|
|
4974
|
+
error: null
|
|
4975
|
+
};
|
|
4976
|
+
} catch (err) {
|
|
4977
|
+
return {
|
|
4978
|
+
ok: false,
|
|
4979
|
+
error: `唤醒注册表写入失败(${String(err?.message ?? err)})`
|
|
4980
|
+
};
|
|
4981
|
+
}
|
|
4982
|
+
}
|
|
4983
|
+
/** 条目 id:`w-<当地到分钟>-<4 hex>`(可读 + 低碰撞;不复用由"物理移除"保证) */
|
|
4984
|
+
function newWakeId(nowMs, rand = Math.random) {
|
|
4985
|
+
return `w-${localIdStamp(nowMs)}-${Math.floor(rand() * 65536).toString(16).padStart(4, "0")}`;
|
|
4986
|
+
}
|
|
4987
|
+
/**
|
|
4988
|
+
* 解析唤醒时刻。接受两种写法(设计文档 §7):
|
|
4989
|
+
* - 绝对:RFC3339(含时区偏移,如 `2026-09-14T09:00:00+08:00`)
|
|
4990
|
+
* - 相对:`+30m` / `+2h`(便于 agent 直接登记"半小时后")
|
|
4991
|
+
* @param input 用户/agent 传入的时刻串
|
|
4992
|
+
* @param nowMs 当前时刻(相对写法基准)
|
|
4993
|
+
* @returns `{ ok, ms }` 或 `{ ok: false, error }`
|
|
4994
|
+
*/
|
|
4995
|
+
function parseWakeAt(input, nowMs) {
|
|
4996
|
+
const raw = input.trim();
|
|
4997
|
+
if (raw === "") return {
|
|
4998
|
+
ok: false,
|
|
4999
|
+
error: "时刻为空"
|
|
5000
|
+
};
|
|
5001
|
+
const rel = /^\+(\d+(?:\.\d+)?)([mhd])$/.exec(raw);
|
|
5002
|
+
if (rel) {
|
|
5003
|
+
const n = Number(rel[1]);
|
|
5004
|
+
const unit = rel[2];
|
|
5005
|
+
return {
|
|
5006
|
+
ok: true,
|
|
5007
|
+
ms: nowMs + n * (unit === "m" ? 6e4 : unit === "h" ? 36e5 : 864e5)
|
|
5008
|
+
};
|
|
5009
|
+
}
|
|
5010
|
+
const ms = Date.parse(raw);
|
|
5011
|
+
if (!Number.isFinite(ms)) return {
|
|
5012
|
+
ok: false,
|
|
5013
|
+
error: `时刻无法解析(须 RFC3339 或 +30m/+2h): ${raw}`
|
|
5014
|
+
};
|
|
5015
|
+
return {
|
|
5016
|
+
ok: true,
|
|
5017
|
+
ms
|
|
5018
|
+
};
|
|
5019
|
+
}
|
|
5020
|
+
/**
|
|
5021
|
+
* 登记一条唤醒。
|
|
5022
|
+
* @param root CCC 根
|
|
5023
|
+
* @param input 目标 trajectory / 时刻串 / message / 发起者 / 当前时刻
|
|
5024
|
+
* @returns 成功返回条目,失败返回稳定错误文案(不抛错)
|
|
5025
|
+
*/
|
|
5026
|
+
function addWake(root, input) {
|
|
5027
|
+
const target = input.target.trim();
|
|
5028
|
+
if (target === "") return {
|
|
5029
|
+
ok: false,
|
|
5030
|
+
error: "target 为空(须 S### 或 AGENT_SESSIONS 目录名)"
|
|
5031
|
+
};
|
|
5032
|
+
const message = input.message.trim();
|
|
5033
|
+
if (message === "") return {
|
|
5034
|
+
ok: false,
|
|
5035
|
+
error: "message 为空(唤醒必须携带一条信息)"
|
|
5036
|
+
};
|
|
5037
|
+
const parsed = parseWakeAt(input.at, input.nowMs);
|
|
5038
|
+
if (!parsed.ok) return {
|
|
5039
|
+
ok: false,
|
|
5040
|
+
error: parsed.error
|
|
5041
|
+
};
|
|
5042
|
+
if (parsed.ms <= input.nowMs) return {
|
|
5043
|
+
ok: false,
|
|
5044
|
+
error: "时刻必须在未来(唤醒只允许可预期的未来)"
|
|
5045
|
+
};
|
|
5046
|
+
const { registry, error } = loadWakeRegistry(root);
|
|
5047
|
+
if (error) return {
|
|
5048
|
+
ok: false,
|
|
5049
|
+
error
|
|
5050
|
+
};
|
|
5051
|
+
const entry = {
|
|
5052
|
+
id: newWakeId(input.nowMs),
|
|
5053
|
+
target,
|
|
5054
|
+
at: isoLocal(parsed.ms),
|
|
5055
|
+
message,
|
|
5056
|
+
state: "pending",
|
|
5057
|
+
createdBy: input.createdBy,
|
|
5058
|
+
createdAt: isoLocal(input.nowMs),
|
|
5059
|
+
attempts: 0,
|
|
5060
|
+
lastResult: null,
|
|
5061
|
+
deliveredAt: null
|
|
5062
|
+
};
|
|
5063
|
+
registry.entries.push(entry);
|
|
5064
|
+
const saved = saveWakeRegistry(root, registry);
|
|
5065
|
+
if (!saved.ok) return {
|
|
5066
|
+
ok: false,
|
|
5067
|
+
error: saved.error ?? "写入失败"
|
|
5068
|
+
};
|
|
5069
|
+
return {
|
|
5070
|
+
ok: true,
|
|
5071
|
+
entry
|
|
5072
|
+
};
|
|
5073
|
+
}
|
|
5074
|
+
/**
|
|
5075
|
+
* 到期切分(纯函数,可单测):
|
|
5076
|
+
* - `due`:`state === 'pending'` 且 `now >= at`(在补跑窗口内)→ 应投递
|
|
5077
|
+
* - `expired`:`state === 'pending'` 且 `now - at > catchUpMs` → 应置 missed(不投递)
|
|
5078
|
+
* @param registry 注册表
|
|
5079
|
+
* @param nowMs 当前时刻
|
|
5080
|
+
* @param catchUpMs 补跑窗口
|
|
5081
|
+
*/
|
|
5082
|
+
function splitDueWakes(registry, nowMs, catchUpMs = WAKE_CATCH_UP_MS) {
|
|
5083
|
+
const due = [];
|
|
5084
|
+
const expired = [];
|
|
5085
|
+
for (const e of registry.entries) {
|
|
5086
|
+
if (e.state !== "pending") continue;
|
|
5087
|
+
const at = Date.parse(e.at);
|
|
5088
|
+
if (!Number.isFinite(at) || nowMs < at) continue;
|
|
5089
|
+
if (nowMs - at > catchUpMs) expired.push(e);
|
|
5090
|
+
else due.push(e);
|
|
5091
|
+
}
|
|
5092
|
+
return {
|
|
5093
|
+
due,
|
|
5094
|
+
expired
|
|
5095
|
+
};
|
|
5096
|
+
}
|
|
5097
|
+
/**
|
|
5098
|
+
* 🔴 结案并**移除**一条(owner 2026-09-19 裁定「**直接删**」,S142 §0Q 甲案)。
|
|
5099
|
+
*
|
|
5100
|
+
* ## 为什么删(R↓)
|
|
5101
|
+
*
|
|
5102
|
+
* 本模块原本是「`pending` → 终态」但**终态条目永不移除** ⇒ 文件**只增**(实测 308 KB / 143+ 条)。
|
|
5103
|
+
* owner 明令「应当**唤醒后就清理**的」⇒ 终态即删。
|
|
5104
|
+
*
|
|
5105
|
+
* **审计不丢**:由 **SESSION.md 与工具输出**承担——这正是本文件 `removeWake` 的既有注释承诺
|
|
5106
|
+
* (*"审计由 SESSION.md 与工具输出承担——注册表只保留在办项"*)。
|
|
5107
|
+
* ⚠️ 此前该承诺**并未兑现**(`updateWake` 只改 state 从不删行)⇒ 本次是**把承诺做实**。
|
|
5108
|
+
*
|
|
5109
|
+
* ## 与 `updateWake` 的分工(**别混用**)
|
|
5110
|
+
*
|
|
5111
|
+
* - `updateWake`:**仍在办**的推进(失败重试 `state:'pending'` + `attempts+1`)⇒ **保留行**;
|
|
5112
|
+
* - `finalizeWake`:**不再办**的归宿(投递成功 / 超窗 missed)⇒ 写终态后**同一笔事务里删掉**。
|
|
5113
|
+
*
|
|
5114
|
+
* 🔴 **为什么两个动作要同一次写盘**:若先 `updateWake(delivered)` 再 `removeWake`,
|
|
5115
|
+
* 中间被打断会留下一条"已终结但没删"的条目 ⇒ 又回到只增。
|
|
5116
|
+
* 故本函数**读一次、改一次、删一次、写一次**(单次原子落盘)。
|
|
5117
|
+
*
|
|
5118
|
+
* @param root CCC 根
|
|
5119
|
+
* @param id 条目 id
|
|
5120
|
+
* @param terminal 终态(`delivered` / `missed` / `cancelled`)——写入仅用于**日志文案**,
|
|
5121
|
+
* 随即整行移除;保留该参数是为了让调用点语义自明("它归到哪一档了")。
|
|
5122
|
+
* @param result 人读结果(进日志,不进表)
|
|
5123
|
+
* @returns `removed` = 被移除的条目(供调用方打印"它是什么");不存在 → 稳定错误码
|
|
5124
|
+
*/
|
|
5125
|
+
function finalizeWake(root, id, terminal, result) {
|
|
5126
|
+
const { registry, error } = loadWakeRegistry(root);
|
|
5127
|
+
if (error) return {
|
|
5128
|
+
ok: false,
|
|
5129
|
+
error,
|
|
5130
|
+
removed: null
|
|
5131
|
+
};
|
|
5132
|
+
const idx = registry.entries.findIndex((e) => e.id === id);
|
|
5133
|
+
if (idx < 0) return {
|
|
5134
|
+
ok: false,
|
|
5135
|
+
error: `wake_not_found: ${id}`,
|
|
5136
|
+
removed: null
|
|
5137
|
+
};
|
|
5138
|
+
const [removed] = registry.entries.splice(idx, 1);
|
|
5139
|
+
const saved = saveWakeRegistry(root, registry);
|
|
5140
|
+
if (!saved.ok) return {
|
|
5141
|
+
ok: false,
|
|
5142
|
+
error: saved.error,
|
|
5143
|
+
removed: null
|
|
5144
|
+
};
|
|
5145
|
+
return {
|
|
5146
|
+
ok: true,
|
|
5147
|
+
error: null,
|
|
5148
|
+
removed: {
|
|
5149
|
+
...removed,
|
|
5150
|
+
state: terminal,
|
|
5151
|
+
lastResult: result
|
|
5152
|
+
}
|
|
5153
|
+
};
|
|
5154
|
+
}
|
|
5155
|
+
/**
|
|
5156
|
+
* 一次性清除**存量终态条目**(`delivered` / `missed` / `cancelled`)。
|
|
5157
|
+
*
|
|
5158
|
+
* ## 为什么需要(不是可选)
|
|
5159
|
+
*
|
|
5160
|
+
* 本函数上线前,表里已积压 143+ 条终态记录(308 KB)⇒ **只改行为不改存量 = 文件不再涨但仍很大**。
|
|
5161
|
+
* 故首次运行须清一次。**幂等**(无终态条目时空转,不写盘)。
|
|
5162
|
+
*
|
|
5163
|
+
* 审计不丢:与 `finalizeWake` 同理(终态本就属性"已结案",其史由 SESSION.md 承担)。
|
|
5164
|
+
* @param root CCC 根
|
|
5165
|
+
* @returns 清掉几条(0 = 本来就没有)
|
|
5166
|
+
*/
|
|
5167
|
+
function purgeFinalizedWakes(root) {
|
|
5168
|
+
const { registry, error } = loadWakeRegistry(root);
|
|
5169
|
+
if (error) return {
|
|
5170
|
+
ok: false,
|
|
5171
|
+
error,
|
|
5172
|
+
purged: 0
|
|
5173
|
+
};
|
|
5174
|
+
const kept = registry.entries.filter((e) => e.state === "pending");
|
|
5175
|
+
const purged = registry.entries.length - kept.length;
|
|
5176
|
+
if (purged === 0) return {
|
|
5177
|
+
ok: true,
|
|
5178
|
+
error: null,
|
|
5179
|
+
purged: 0
|
|
5180
|
+
};
|
|
5181
|
+
const saved = saveWakeRegistry(root, {
|
|
5182
|
+
...registry,
|
|
5183
|
+
entries: kept
|
|
5184
|
+
});
|
|
5185
|
+
if (!saved.ok) return {
|
|
5186
|
+
ok: false,
|
|
5187
|
+
error: saved.error,
|
|
5188
|
+
purged: 0
|
|
5189
|
+
};
|
|
5190
|
+
return {
|
|
5191
|
+
ok: true,
|
|
5192
|
+
error: null,
|
|
5193
|
+
purged
|
|
5194
|
+
};
|
|
5195
|
+
}
|
|
5196
|
+
/**
|
|
5197
|
+
* 更新一条(状态推进;调用方给最终状态与结果文案)。
|
|
5198
|
+
*
|
|
5199
|
+
* ⚠️ **只用于"仍在办"的推进**(主要是失败重试)。**终态请用 `finalizeWake`**——
|
|
5200
|
+
* 否则该行会永远留下(这正是本表过去只增的根因,见 `finalizeWake` 注释)。
|
|
5201
|
+
* @param root CCC 根
|
|
5202
|
+
* @param id 条目 id
|
|
5203
|
+
* @param patch 要写入的字段
|
|
5204
|
+
* @returns 写入结果
|
|
5205
|
+
*/
|
|
5206
|
+
function updateWake(root, id, patch) {
|
|
5207
|
+
const { registry, error } = loadWakeRegistry(root);
|
|
5208
|
+
if (error) return {
|
|
5209
|
+
ok: false,
|
|
5210
|
+
error
|
|
5211
|
+
};
|
|
5212
|
+
const entry = registry.entries.find((e) => e.id === id);
|
|
5213
|
+
if (!entry) return {
|
|
5214
|
+
ok: false,
|
|
5215
|
+
error: `wake_not_found: ${id}`
|
|
5216
|
+
};
|
|
5217
|
+
Object.assign(entry, patch);
|
|
5218
|
+
return saveWakeRegistry(root, registry);
|
|
5219
|
+
}
|
|
5220
|
+
/** 列条目(默认全量;`pendingOnly` 只列在办项——面板/工具用) */
|
|
5221
|
+
function listWakes(root, opts = {}) {
|
|
5222
|
+
const { registry, error } = loadWakeRegistry(root);
|
|
5223
|
+
return {
|
|
5224
|
+
entries: [...opts.pendingOnly ? registry.entries.filter((e) => e.state === "pending") : registry.entries].sort((a, b) => Date.parse(a.at) - Date.parse(b.at)),
|
|
5225
|
+
error
|
|
5226
|
+
};
|
|
5227
|
+
}
|
|
5228
|
+
//#endregion
|
|
5229
|
+
//#region src/cro.ts
|
|
5230
|
+
/**
|
|
5231
|
+
* cro.ts — CRO(Continuous Re-Occurrence · 持续再发生)机制层(S142 §7.8,2026-09-19)
|
|
5232
|
+
*
|
|
5233
|
+
* ## 这东西用来干什么(白话)
|
|
5234
|
+
*
|
|
5235
|
+
* 今天叫醒一条轨迹的方式是「几点几分叫我」。但**该不该醒,往往不是时间说了算**:
|
|
5236
|
+
* 比如 S185 应该「天亮 + 家里有人 + 非高峰」才醒;已经在干活就不该再叫;
|
|
5237
|
+
* 日志太大了,下次叫它时该让它先整理。
|
|
5238
|
+
*
|
|
5239
|
+
* CRO 让**一条轨迹自己带一段程序**,由 ACC 在每次检查时跑它,
|
|
5240
|
+
* **由这段程序决定「现在该不该叫我、叫我的时候说什么」**。
|
|
5241
|
+
*
|
|
5242
|
+
* ## 归属(所有者 2026-09-19 定:**机制属 ACC,CCC 是用户**)
|
|
5243
|
+
*
|
|
5244
|
+
* | 面 | 归谁 | 内容 |
|
|
5245
|
+
* |---|---|---|
|
|
5246
|
+
* | **机制** | 🔴 **ACC(本模块)** | 契约 / 调度 / 执行 / 状态暴露 / 容错 |
|
|
5247
|
+
* | **程序** | 🔴 **CCC(用户)** | 那段判定的 TS 代码,放**轨迹自己的目录**里 |
|
|
5248
|
+
*
|
|
5249
|
+
* ⇒ 与 `send-later` 同构:**工具在 ACC,用它在 CCC**。
|
|
5250
|
+
*
|
|
5251
|
+
* ## 命名(防混淆,写死)
|
|
5252
|
+
*
|
|
5253
|
+
* **`CRO` = 那段程序**(实体名),**不是** ACC 标准 §0.1 三环节(发生/存储/**再发生**)的第三环本身。
|
|
5254
|
+
* 命名来源 = §0.3「**Trajectory 在寻找 Agent**」——CRO 是「再发生」这一环的自动化。
|
|
5255
|
+
*
|
|
5256
|
+
* ## 位置与形态(设计 §2)
|
|
5257
|
+
*
|
|
5258
|
+
* `<CCC 根>/AGENT_SESSIONS/<轨迹目录>/continuous-re-occurrence.ts`
|
|
5259
|
+
* · **文件名全写**(所有者令);· **程序只有一个**:**文件在 = 启用,不在 = 禁用**(无 enabled 字段、无注册表)。
|
|
5260
|
+
*
|
|
5261
|
+
* ## 🔴 运行契约 = B 案(**ACC 只 spawn,从不 import**)
|
|
5262
|
+
*
|
|
5263
|
+
* 所有者 2026-09-19:「**B 是设计,A 只是文档友好**」。
|
|
5264
|
+
* 若 ACC **import** 那段 TS,会出现两件事:
|
|
5265
|
+
* ① ACC 依赖 CCC 的**源码路径**(npm 装机版在别处 ⇒ 两条路径都要活 = 两个真相源);
|
|
5266
|
+
* ② **用户程序语法错会让 ACC 启动失败** ⇒ **一个用户程序的错误放倒整个容器**。
|
|
5267
|
+
* ⇒ 进程边界把这两件事同时挡掉:**路径只需一个(文件系统),错误被隔离在子进程里**。
|
|
5268
|
+
*
|
|
5269
|
+
* ## 🔴 铁律:CRO 的任何失败,绝不影响既有机制(设计 §5)
|
|
5270
|
+
*
|
|
5271
|
+
* `send-later` / `send-now` / 唤醒表的投递**照常工作**。
|
|
5272
|
+
* 先例 = `weixin-hook.ts` 的**旁路容忍**("超时 kill / 非 0 退出 / spawn 失败 → 仅日志返回,绝不抛")。
|
|
5273
|
+
* ⇒ 本模块的**每一个**导出函数**都不抛错**(返回结构化失败)。
|
|
5274
|
+
*/
|
|
5275
|
+
/** 入口文件名(**全写**,所有者令 2026-09-19;不改缩写) */
|
|
5276
|
+
const CRO_FILENAME = "continuous-re-occurrence.ts";
|
|
5277
|
+
/** 硬超时(沿用 `biasProvider` 先例的 60s;设计 §5 情形 2) */
|
|
5278
|
+
const CRO_TIMEOUT_MS = 6e4;
|
|
5279
|
+
/** stdout 上限(防一个坏程序把日志撑爆;沿用 hook 截断思路) */
|
|
5280
|
+
const CRO_STDOUT_MAX = 65536;
|
|
5281
|
+
/** SESSION.md 文件名(与 `trajectory-ops.ts` 同值;此处本地常量避免反向依赖) */
|
|
5282
|
+
const SESSION_MD = "SESSION.md";
|
|
5283
|
+
/**
|
|
5284
|
+
* 展示码派生(**只用于展示**,不作识别依据 —— §0I U4:锚永远是 `dirName`)。
|
|
5285
|
+
*
|
|
5286
|
+
* 判据(设计 §1):**编号不是固定格式,不同 CCC 格式不同**(本容器 `S###`,别处可能是 issue 号
|
|
5287
|
+
* 或自定义前缀)⇒ 这里只做**尽力而为的展示性提取**,取不到就返回空串。
|
|
5288
|
+
* 🔴 **绝不**因为取不到就拒绝服务——`dirName` 才是硬锚。
|
|
5289
|
+
* @param dirName 轨迹目录名
|
|
5290
|
+
* @returns `S###` 形态的展示码;取不到 ⇒ `''`
|
|
5291
|
+
*/
|
|
5292
|
+
function deriveCode(dirName) {
|
|
5293
|
+
const m = /(?:^|--)S(\d{2,})(?:--|$)/.exec(dirName);
|
|
5294
|
+
return m ? `S${m[1]}` : "";
|
|
5295
|
+
}
|
|
5296
|
+
/** epoch ms → 当地 ISO(不可解析 ⇒ null) */
|
|
5297
|
+
function isoOrNull(ms) {
|
|
5298
|
+
return typeof ms === "number" && Number.isFinite(ms) ? isoLocal(ms) : null;
|
|
5299
|
+
}
|
|
5300
|
+
/**
|
|
5301
|
+
* 装配快照(**纯函数**:只做数据整形,不读 fs、不看 ctx)⇒ 可穷举测试。
|
|
5302
|
+
* @param input 已读好的原始数据
|
|
5303
|
+
* @returns 喂给 CRO 程序的快照
|
|
5304
|
+
*/
|
|
5305
|
+
function buildCroSnapshot(input) {
|
|
5306
|
+
return {
|
|
5307
|
+
version: 1,
|
|
5308
|
+
identity: {
|
|
5309
|
+
dirName: input.dirName,
|
|
5310
|
+
code: deriveCode(input.dirName),
|
|
5311
|
+
cccRoot: input.cccRoot
|
|
5312
|
+
},
|
|
5313
|
+
time: {
|
|
5314
|
+
now: isoLocal(input.nowMs),
|
|
5315
|
+
nowMs: input.nowMs,
|
|
5316
|
+
nowLocal: new Date(input.nowMs).toString()
|
|
5317
|
+
},
|
|
5318
|
+
body: {
|
|
5319
|
+
sessionMdPath: input.sessionMdPath,
|
|
5320
|
+
sessionMdBytes: input.sessionMdBytes,
|
|
5321
|
+
sessionMdMtime: isoOrNull(input.sessionMdMtimeMs),
|
|
5322
|
+
references: input.references.map((r) => ({
|
|
5323
|
+
name: r.name,
|
|
5324
|
+
bytes: r.bytes,
|
|
5325
|
+
mtime: isoLocal(r.mtimeMs)
|
|
5326
|
+
}))
|
|
5327
|
+
},
|
|
5328
|
+
binding: {
|
|
5329
|
+
boundSessionIds: input.boundSessionIds,
|
|
5330
|
+
liveSessionIds: input.liveSessionIds,
|
|
5331
|
+
runningSessionIds: input.runningSessionIds
|
|
5332
|
+
},
|
|
5333
|
+
scheduling: {
|
|
5334
|
+
pendingWakes: input.pendingWakes,
|
|
5335
|
+
scheduler: input.scheduler
|
|
5336
|
+
}
|
|
5337
|
+
};
|
|
5338
|
+
}
|
|
5339
|
+
/**
|
|
5340
|
+
* 解析 CRO 程序 stdout(**纯函数** ⇒ 可穷举测试)。
|
|
5341
|
+
*
|
|
5342
|
+
* ## 边界(设计 §4.1)
|
|
5343
|
+
* · `wake` 缺省 / `false` ⇒ **不唤起**(**这是常态**)——`prompt` 可省;
|
|
5344
|
+
* · `wake: true` 但 `prompt` 空 / 非串 ⇒ 🔴 **非法** ⇒ 返回错误(调用方**跳过本轮**,不投递空消息);
|
|
5345
|
+
* · 非 JSON / 非对象 ⇒ 错误。
|
|
5346
|
+
*
|
|
5347
|
+
* ## 🔴 为什么 `reason` 重要(CCE:重建 > 保存)
|
|
5348
|
+
* 改成程序判定后,**「当时为什么叫了」不再能从时间表重建**(原因在程序肚子里:
|
|
5349
|
+
* 可能有随机、可能看了外部数据)⇒ 要求程序自报理由,把「决策依据」重新变成**可重建的**。
|
|
5350
|
+
* ⇒ **强烈建议但不强制**(强制会让简单程序难写;设计 §9-6 待裁,本版取"建议")。
|
|
5351
|
+
*
|
|
5352
|
+
* @param stdout 程序标准输出(可含前后空白;允许多行 JSON 文本)
|
|
5353
|
+
* @returns 决策 或 结构化错误
|
|
5354
|
+
*/
|
|
5355
|
+
function parseCroOutput(stdout) {
|
|
5356
|
+
const text = stdout.trim();
|
|
5357
|
+
if (text === "") return {
|
|
5358
|
+
ok: false,
|
|
5359
|
+
error: "cro-empty-output",
|
|
5360
|
+
detail: "程序没有输出(期望 stdout 一行 JSON)"
|
|
5361
|
+
};
|
|
5362
|
+
let raw;
|
|
5363
|
+
try {
|
|
5364
|
+
raw = JSON.parse(text);
|
|
5365
|
+
} catch (err) {
|
|
5366
|
+
return {
|
|
5367
|
+
ok: false,
|
|
5368
|
+
error: "cro-bad-json",
|
|
5369
|
+
detail: `输出不是合法 JSON: ${String(err?.message ?? err)}`
|
|
5370
|
+
};
|
|
5371
|
+
}
|
|
5372
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {
|
|
5373
|
+
ok: false,
|
|
5374
|
+
error: "cro-bad-json",
|
|
5375
|
+
detail: "输出必须是 JSON 对象"
|
|
5376
|
+
};
|
|
5377
|
+
const obj = raw;
|
|
5378
|
+
const wake = obj.wake === true;
|
|
5379
|
+
const reason = typeof obj.reason === "string" && obj.reason.trim() !== "" ? obj.reason.trim() : null;
|
|
5380
|
+
if (!wake) return {
|
|
5381
|
+
ok: true,
|
|
5382
|
+
decision: {
|
|
5383
|
+
wake: false,
|
|
5384
|
+
prompt: null,
|
|
5385
|
+
reason
|
|
5386
|
+
}
|
|
5387
|
+
};
|
|
5388
|
+
if (typeof obj.prompt !== "string" || obj.prompt.trim() === "") return {
|
|
5389
|
+
ok: false,
|
|
5390
|
+
error: "cro-missing-prompt",
|
|
5391
|
+
detail: "wake=true 但 prompt 缺失或为空"
|
|
5392
|
+
};
|
|
5393
|
+
return {
|
|
5394
|
+
ok: true,
|
|
5395
|
+
decision: {
|
|
5396
|
+
wake: true,
|
|
5397
|
+
prompt: obj.prompt.trim(),
|
|
5398
|
+
reason
|
|
5399
|
+
}
|
|
5400
|
+
};
|
|
5401
|
+
}
|
|
5402
|
+
/**
|
|
5403
|
+
* CRO 程序路径:`<CCC 根>/AGENT_SESSIONS/<dirName>/continuous-re-occurrence.ts`。
|
|
5404
|
+
*
|
|
5405
|
+
* ⚠️ 路径逃逸校验(`resolveInside`):`dirName` 来自配置/工具入参,
|
|
5406
|
+
* 必须确保解析后仍在 CCC 根内(同 `weixin-hook.ts:160` 手法)。**抛错由调用方吞**。
|
|
5407
|
+
* @param root CCC 根
|
|
5408
|
+
* @param dirName 轨迹目录名
|
|
5409
|
+
* @returns 绝对路径
|
|
5410
|
+
*/
|
|
5411
|
+
function croScriptPath(root, dirName) {
|
|
5412
|
+
return resolveInside(root, join("AGENT_SESSIONS", dirName, CRO_FILENAME));
|
|
5413
|
+
}
|
|
5414
|
+
/**
|
|
5415
|
+
* 列出**启用了 CRO 的轨迹目录名**(供调度器每 tick 扫描,设计 §6.1)。
|
|
5416
|
+
*
|
|
5417
|
+
* ## 判据与形态(R↓)
|
|
5418
|
+
* · 判据 = **文件在不在**(同 §2.1)——**没有注册表**,所以"谁启用了"只能靠**扫目录**:
|
|
5419
|
+
* `AGENT_SESSIONS/` 下每个目录查一次 `<目录>/continuous-re-occurrence.ts`。
|
|
5420
|
+
* · **一次 readdir + 每个目录一次 existsSync**:N 条轨迹的代价是 O(N) 次 `stat`,
|
|
5421
|
+
* 每 5min 一次 —— 与既有 `listSessions` 同量级,可接受。
|
|
5422
|
+
* · 跳过 **`_` 前缀**(`_archived` / `_skiff-logs` / `_weixin-logs` = 系统与日志目录)
|
|
5423
|
+
* 与 **`.` 前缀**(隐藏)——它们**不是轨迹**,且扫它们纯属浪费。
|
|
5424
|
+
* · 🔴 **本函数绝不抛错**(返回 `[]`)——它跑在调度器 tick 内,任何异常都可能影响既有链路
|
|
5425
|
+
* (设计 §5 铁律)。目录读不到(不存在 / 权限)⇒ `[]` = "没有轨迹启用 CRO",语义正确。
|
|
5426
|
+
*
|
|
5427
|
+
* ⚠️ **为什么不做缓存**:缓存会引入"文件删了但缓存还在"的失效模式(本容器栽过的"第二真相源"),
|
|
5428
|
+
* 而这里省下的只是一次 `readdir`——**不值当**。
|
|
5429
|
+
*
|
|
5430
|
+
* @param root CCC 根
|
|
5431
|
+
* @returns 启用了 CRO 的轨迹目录名(**排序后**,保证同 tick 顺序稳定、便于日志比对)
|
|
5432
|
+
*/
|
|
5433
|
+
function listCroTrajectories(root) {
|
|
5434
|
+
const rootDir = sessionsRoot(root);
|
|
5435
|
+
let entries;
|
|
5436
|
+
try {
|
|
5437
|
+
entries = readdirSync(rootDir, { withFileTypes: true });
|
|
5438
|
+
} catch {
|
|
5439
|
+
return [];
|
|
5440
|
+
}
|
|
5441
|
+
const out = [];
|
|
5442
|
+
for (const e of entries) {
|
|
5443
|
+
if (!e.isDirectory()) continue;
|
|
5444
|
+
if (e.name.startsWith("_") || e.name.startsWith(".")) continue;
|
|
5445
|
+
try {
|
|
5446
|
+
if (existsSync(join(rootDir, e.name, "continuous-re-occurrence.ts"))) out.push(e.name);
|
|
5447
|
+
} catch {}
|
|
5448
|
+
}
|
|
5449
|
+
return out.sort();
|
|
5450
|
+
}
|
|
5451
|
+
/**
|
|
5452
|
+
* 执行一次 CRO 程序(**永不抛** —— 旁路容忍铁律)。
|
|
5453
|
+
*
|
|
5454
|
+
* runner 顺序(照 `weixin-hook.ts:171-174` 的 bun 优先 / node 兜底):
|
|
5455
|
+
* `bun` → `process.execPath`(同运行时)。
|
|
5456
|
+
* 判据:`ENOENT`(二进制不存在)⇒ 试下一个;**其余失败视为最终结果**
|
|
5457
|
+
* (程序自己报错就是报错,不该用另一个 runner 掩盖——与 hook 一致)。
|
|
5458
|
+
* @param scriptAbs 程序绝对路径
|
|
5459
|
+
* @param stdinJson 喂给程序的快照 JSON
|
|
5460
|
+
* @param timeoutMs 硬超时
|
|
5461
|
+
* @returns 执行结果(含输出的解析交由 `parseCroOutput`)
|
|
5462
|
+
*/
|
|
5463
|
+
async function runCroProcess(scriptAbs, stdinJson, timeoutMs = CRO_TIMEOUT_MS) {
|
|
5464
|
+
const runners = [["bun", [scriptAbs]], [process.execPath, [scriptAbs]]];
|
|
5465
|
+
for (const [cmd, args] of runners) {
|
|
5466
|
+
const res = await runCroOnce(cmd, args, stdinJson, timeoutMs);
|
|
5467
|
+
if (!res.ok && res.code === "ENOENT") continue;
|
|
5468
|
+
return res;
|
|
5469
|
+
}
|
|
5470
|
+
return {
|
|
5471
|
+
ok: false,
|
|
5472
|
+
stdout: "",
|
|
5473
|
+
error: "CRO 执行失败(bun 与 node 均不可用)",
|
|
5474
|
+
code: "cro-no-runner"
|
|
5475
|
+
};
|
|
5476
|
+
}
|
|
5477
|
+
/** 单次 spawn(Promise 化 + 超时 kill + 输出截断 + **绝不抛**) */
|
|
5478
|
+
function runCroOnce(cmd, args, stdinJson, timeoutMs) {
|
|
5479
|
+
return new Promise((resolvePromise) => {
|
|
5480
|
+
let settled = false;
|
|
5481
|
+
let stdout = "";
|
|
5482
|
+
let stderr = "";
|
|
5483
|
+
let child = null;
|
|
5484
|
+
try {
|
|
5485
|
+
child = spawn(cmd, args, {
|
|
5486
|
+
stdio: [
|
|
5487
|
+
"pipe",
|
|
5488
|
+
"pipe",
|
|
5489
|
+
"pipe"
|
|
5490
|
+
],
|
|
5491
|
+
env: { ...process.env }
|
|
5492
|
+
});
|
|
5493
|
+
} catch (err) {
|
|
5494
|
+
return resolvePromise({
|
|
5495
|
+
ok: false,
|
|
5496
|
+
stdout: "",
|
|
5497
|
+
error: String(err),
|
|
5498
|
+
code: "SPAWN_ERR"
|
|
5499
|
+
});
|
|
5500
|
+
}
|
|
5501
|
+
const timer = setTimeout(() => {
|
|
5502
|
+
if (settled) return;
|
|
5503
|
+
settled = true;
|
|
5504
|
+
child?.kill("SIGKILL");
|
|
5505
|
+
resolvePromise({
|
|
5506
|
+
ok: false,
|
|
5507
|
+
stdout,
|
|
5508
|
+
error: `CRO 执行超时(>${Math.round(timeoutMs / 1e3)}s,已 kill)`,
|
|
5509
|
+
code: "cro-timeout"
|
|
5510
|
+
});
|
|
5511
|
+
}, timeoutMs);
|
|
5512
|
+
child.stdin?.on("error", () => {});
|
|
5513
|
+
try {
|
|
5514
|
+
child.stdin?.write(stdinJson);
|
|
5515
|
+
child.stdin?.end();
|
|
5516
|
+
} catch {}
|
|
5517
|
+
child.stdout?.on("data", (d) => {
|
|
5518
|
+
stdout = (stdout + d.toString()).slice(0, CRO_STDOUT_MAX);
|
|
5519
|
+
});
|
|
5520
|
+
child.stderr?.on("data", (d) => {
|
|
5521
|
+
stderr = (stderr + d.toString()).slice(0, CRO_STDOUT_MAX);
|
|
5522
|
+
});
|
|
5523
|
+
child.on("error", (err) => {
|
|
5524
|
+
if (settled) return;
|
|
5525
|
+
settled = true;
|
|
5526
|
+
clearTimeout(timer);
|
|
5527
|
+
resolvePromise({
|
|
5528
|
+
ok: false,
|
|
5529
|
+
stdout,
|
|
5530
|
+
error: String(err.message ?? err),
|
|
5531
|
+
code: err.code
|
|
5532
|
+
});
|
|
5533
|
+
});
|
|
5534
|
+
child.on("close", (code) => {
|
|
5535
|
+
if (settled) return;
|
|
5536
|
+
settled = true;
|
|
5537
|
+
clearTimeout(timer);
|
|
5538
|
+
if (code === 0) resolvePromise({
|
|
5539
|
+
ok: true,
|
|
5540
|
+
stdout,
|
|
5541
|
+
error: null
|
|
5542
|
+
});
|
|
5543
|
+
else {
|
|
5544
|
+
const detail = stderr.trim().split("\n")[0] ?? "";
|
|
5545
|
+
resolvePromise({
|
|
5546
|
+
ok: false,
|
|
5547
|
+
stdout,
|
|
5548
|
+
error: `CRO 程序退出码 ${code}${detail ? `:${detail}` : ""}`,
|
|
5549
|
+
code: "cro-exit-nonzero"
|
|
5550
|
+
});
|
|
5551
|
+
}
|
|
5552
|
+
});
|
|
5553
|
+
});
|
|
5554
|
+
}
|
|
5555
|
+
/** 读目录清单(不存在/读失败 ⇒ 空数组;**不回退抛错**) */
|
|
5556
|
+
function readDirEntries(dir) {
|
|
5557
|
+
try {
|
|
5558
|
+
return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile()).map((e) => {
|
|
5559
|
+
const p = join(dir, e.name);
|
|
5560
|
+
try {
|
|
5561
|
+
const st = statSync(p);
|
|
5562
|
+
return {
|
|
5563
|
+
name: e.name,
|
|
5564
|
+
bytes: st.size,
|
|
5565
|
+
mtimeMs: st.mtimeMs
|
|
5566
|
+
};
|
|
5567
|
+
} catch {
|
|
5568
|
+
return null;
|
|
5569
|
+
}
|
|
5570
|
+
}).filter((x) => x !== null);
|
|
5571
|
+
} catch {
|
|
5572
|
+
return [];
|
|
5573
|
+
}
|
|
5574
|
+
}
|
|
5575
|
+
/** 读 SESSION.md 体积与 mtime(不存在/读失败 ⇒ null) */
|
|
5576
|
+
function readMdStat(mdPath) {
|
|
5577
|
+
try {
|
|
5578
|
+
const st = statSync(mdPath);
|
|
5579
|
+
return {
|
|
5580
|
+
bytes: st.size,
|
|
5581
|
+
mtimeMs: st.mtimeMs
|
|
5582
|
+
};
|
|
5583
|
+
} catch {
|
|
5584
|
+
return {
|
|
5585
|
+
bytes: null,
|
|
5586
|
+
mtimeMs: null
|
|
5587
|
+
};
|
|
5588
|
+
}
|
|
5589
|
+
}
|
|
5590
|
+
/** 读取快照所需的 fs 侧数据(**纯读取,不抛**) */
|
|
5591
|
+
function readCroSnapshotInput(root, dirName, nowMs, extras) {
|
|
5592
|
+
const dir = join(sessionsRoot(root), dirName);
|
|
5593
|
+
const mdPath = join(dir, SESSION_MD);
|
|
5594
|
+
const md = readMdStat(mdPath);
|
|
5595
|
+
return {
|
|
5596
|
+
dirName,
|
|
5597
|
+
cccRoot: root,
|
|
5598
|
+
nowMs,
|
|
5599
|
+
sessionMdPath: mdPath,
|
|
5600
|
+
sessionMdBytes: md.bytes,
|
|
5601
|
+
sessionMdMtimeMs: md.mtimeMs,
|
|
5602
|
+
references: readDirEntries(join(dir, "references")),
|
|
5603
|
+
boundSessionIds: extras.boundSessionIds,
|
|
5604
|
+
liveSessionIds: extras.liveSessionIds,
|
|
5605
|
+
runningSessionIds: extras.runningSessionIds,
|
|
5606
|
+
pendingWakes: extras.pendingWakes,
|
|
5607
|
+
scheduler: extras.scheduler
|
|
5608
|
+
};
|
|
5609
|
+
}
|
|
5610
|
+
/**
|
|
5611
|
+
* 评估一条轨迹的 CRO 程序:**文件在 ⇒ 跑它;不在 ⇒ 未启用**(设计 §2.1)。
|
|
5612
|
+
*
|
|
5613
|
+
* 🔴 **本函数永不抛错**(旁路容忍铁律):任何失败都返回 `{status:'skipped'}`,
|
|
5614
|
+
* 调用方据此**跳过本轮**、**不影响既有投递链路**。
|
|
5615
|
+
*
|
|
5616
|
+
* @param root CCC 根
|
|
5617
|
+
* @param dirName 轨迹目录名
|
|
5618
|
+
* @param snapshotInput 已读好的快照输入
|
|
5619
|
+
* @param deps 可注入依赖(测试用)
|
|
5620
|
+
* @returns 四态结果
|
|
5621
|
+
*/
|
|
5622
|
+
async function evaluateCro(root, dirName, snapshotInput, deps = {}) {
|
|
5623
|
+
let scriptAbs;
|
|
5624
|
+
try {
|
|
5625
|
+
scriptAbs = croScriptPath(root, dirName);
|
|
5626
|
+
} catch (err) {
|
|
5627
|
+
return {
|
|
5628
|
+
status: "disabled",
|
|
5629
|
+
detail: `CRO 路径非法(须在 CCC 根内):${String(err?.message ?? err)}`
|
|
5630
|
+
};
|
|
5631
|
+
}
|
|
5632
|
+
if (!existsSync(scriptAbs)) return {
|
|
5633
|
+
status: "disabled",
|
|
5634
|
+
detail: `未启用(无 ${CRO_FILENAME})`
|
|
5635
|
+
};
|
|
5636
|
+
const snapshot = buildCroSnapshot(snapshotInput);
|
|
5637
|
+
let stdinJson;
|
|
5638
|
+
try {
|
|
5639
|
+
stdinJson = JSON.stringify(snapshot);
|
|
5640
|
+
} catch (err) {
|
|
5641
|
+
return {
|
|
5642
|
+
status: "skipped",
|
|
5643
|
+
detail: `快照序列化失败:${String(err?.message ?? err)}`,
|
|
5644
|
+
code: "cro-snapshot"
|
|
5645
|
+
};
|
|
5646
|
+
}
|
|
5647
|
+
const run = await (deps.runner ?? runCroProcess)(scriptAbs, stdinJson, deps.timeoutMs ?? 6e4);
|
|
5648
|
+
if (!run.ok) return {
|
|
5649
|
+
status: "skipped",
|
|
5650
|
+
detail: run.error ?? "CRO 执行失败",
|
|
5651
|
+
...run.code ? { code: run.code } : {}
|
|
5652
|
+
};
|
|
5653
|
+
const parsed = parseCroOutput(run.stdout);
|
|
5654
|
+
if (!parsed.ok) return {
|
|
5655
|
+
status: "skipped",
|
|
5656
|
+
detail: parsed.detail ?? parsed.error,
|
|
5657
|
+
code: parsed.error
|
|
5658
|
+
};
|
|
5659
|
+
const decision = parsed.decision;
|
|
5660
|
+
if (!decision.wake) return {
|
|
5661
|
+
status: "no-wake",
|
|
5662
|
+
decision,
|
|
5663
|
+
detail: decision.reason ?? "程序判定:不唤起"
|
|
5664
|
+
};
|
|
5665
|
+
return {
|
|
5666
|
+
status: "wake",
|
|
5667
|
+
decision,
|
|
5668
|
+
prompt: decision.prompt,
|
|
5669
|
+
detail: decision.reason ?? "程序判定:唤起"
|
|
5670
|
+
};
|
|
5671
|
+
}
|
|
5672
|
+
/** 人读一行(日志用;`evaluateCro` 结果的稳定摘要) */
|
|
5673
|
+
function renderCroOutcome(dirName, outcome) {
|
|
5674
|
+
switch (outcome.status) {
|
|
5675
|
+
case "disabled": return `${dirName}: CRO ${outcome.detail}`;
|
|
5676
|
+
case "skipped": return `${dirName}: CRO 跳过本轮(${outcome.detail})`;
|
|
5677
|
+
case "no-wake": return `${dirName}: CRO 不唤起(${outcome.detail})`;
|
|
5678
|
+
case "wake": return `${dirName}: CRO 唤起(${outcome.detail})`;
|
|
5679
|
+
}
|
|
5680
|
+
}
|
|
5681
|
+
//#endregion
|
|
5682
|
+
//#region src/host/effect.ts
|
|
5683
|
+
/** 已告警的标签(进程级去重:重复装配/多测试替身不刷屏,但绝不静默) */
|
|
5684
|
+
const warnedLabels = /* @__PURE__ */ new Set();
|
|
5685
|
+
/**
|
|
5686
|
+
* 登记一个卸载时执行的清理。
|
|
5687
|
+
* @param ctx 宿主插件上下文
|
|
5688
|
+
* @param label 日志标签(定位是哪个资源未装配)
|
|
5689
|
+
* @param cleanup 卸载时执行;抛错被吞掉(不阻断其它清理)
|
|
5690
|
+
* @returns 是否成功装配(false = 宿主无 `ctx.effect`,已告警)
|
|
5691
|
+
*/
|
|
5692
|
+
function registerDisposer(ctx, label, cleanup) {
|
|
5693
|
+
const effect = ctx?.effect;
|
|
5694
|
+
if (typeof effect !== "function") {
|
|
5695
|
+
if (!warnedLabels.has(label)) {
|
|
5696
|
+
warnedLabels.add(label);
|
|
5697
|
+
console.warn(`[serenity-hooks] ✗ 资源拆卸未装配(${label}):ctx.effect 不可用(宿主契约缺失)`);
|
|
5698
|
+
}
|
|
5699
|
+
return false;
|
|
5700
|
+
}
|
|
5701
|
+
try {
|
|
5702
|
+
effect.call(ctx, () => () => {
|
|
5703
|
+
try {
|
|
5704
|
+
cleanup();
|
|
5705
|
+
} catch (err) {
|
|
5706
|
+
console.warn(`[serenity-hooks] ✗ 资源清理失败(${label}): ${String(err?.message ?? err)}`);
|
|
5707
|
+
}
|
|
5708
|
+
}, `dsh-serenity-hooks: ${label}`);
|
|
5709
|
+
return true;
|
|
5710
|
+
} catch (err) {
|
|
5711
|
+
console.warn(`[serenity-hooks] ✗ 资源拆卸注册失败(${label}): ${String(err?.message ?? err)}`);
|
|
5712
|
+
return false;
|
|
5713
|
+
}
|
|
5714
|
+
}
|
|
5715
|
+
//#endregion
|
|
5716
|
+
//#region src/cro-turns.ts
|
|
5717
|
+
/** 一次「在跑」记录的 TTL(见文件头的方向性判据) */
|
|
5718
|
+
const TURN_TTL_MS = 18e5;
|
|
5719
|
+
/**
|
|
5720
|
+
* 进程内的「正在跑」表(键 = 载体 dsh 会话 id)。
|
|
5721
|
+
*
|
|
5722
|
+
* ⚠️ **仅内存**:进程重启即空 —— 这是**可接受的**,因为重启后宿主会重发
|
|
5723
|
+
* `agent/created`(或至少下一次 `turn-stopping` 会清掉残留),且空表 = "没有在跑",
|
|
5724
|
+
* 落到上面那条**便宜**的误判方向。
|
|
5725
|
+
*/
|
|
5726
|
+
const runningBySession = /* @__PURE__ */ new Map();
|
|
5727
|
+
/** 取载体 id(事件 payload 的 `agent` 面;取不到 ⇒ null) */
|
|
5728
|
+
function sessionIdOf$3(agent) {
|
|
5729
|
+
const id = agent?.session?.id;
|
|
5730
|
+
return typeof id === "string" && id !== "" ? id : null;
|
|
5731
|
+
}
|
|
5732
|
+
/** 标记「在跑」(`agent/created` + `agent/status=running` 调用) */
|
|
5733
|
+
function markTurnRunning(sessionId, nowMs, turn = null) {
|
|
5734
|
+
if (!sessionId) return;
|
|
5735
|
+
runningBySession.set(sessionId, {
|
|
5736
|
+
at: nowMs,
|
|
5737
|
+
turn
|
|
5738
|
+
});
|
|
5739
|
+
}
|
|
5740
|
+
/** 清除「在跑」(`turn-stopping` / disposed 调用) */
|
|
5741
|
+
function clearTurnRunning(sessionId) {
|
|
5742
|
+
if (!sessionId) return;
|
|
5743
|
+
runningBySession.delete(sessionId);
|
|
5744
|
+
}
|
|
5745
|
+
/**
|
|
5746
|
+
* 列出**当前在跑**的载体 id(按 TTL 过滤 + **顺手清除过期项**)。
|
|
5747
|
+
*
|
|
5748
|
+
* 🔴 **本函数带副作用(清理)是刻意的**:它是"异常路径兜底"的唯一执行点——
|
|
5749
|
+
* 只过滤不清除会让表留下永不过期的僵尸项(正是"只增不清"病)。
|
|
5750
|
+
* @param nowMs 当前时刻
|
|
5751
|
+
* @param ttlMs TTL(缺省 {@link TURN_TTL_MS})
|
|
5752
|
+
* @returns 在跑的载体 id 列表(顺序不保证)
|
|
5753
|
+
*/
|
|
5754
|
+
function listRunningSessionIds(nowMs, ttlMs = TURN_TTL_MS) {
|
|
5755
|
+
const out = [];
|
|
5756
|
+
for (const [id, rec] of runningBySession) {
|
|
5757
|
+
if (nowMs - rec.at > ttlMs) {
|
|
5758
|
+
runningBySession.delete(id);
|
|
5759
|
+
continue;
|
|
5760
|
+
}
|
|
5761
|
+
out.push(id);
|
|
5762
|
+
}
|
|
5763
|
+
return out;
|
|
5764
|
+
}
|
|
5765
|
+
/**
|
|
5766
|
+
* 按轨迹聚合:该轨迹**是否有载体在跑**(`listBoundSessionIds` 给候选,再与在跑集求交)。
|
|
5767
|
+
* @param boundSessionIds 绑定该轨迹的载体 id
|
|
5768
|
+
* @param nowMs 当前时刻
|
|
5769
|
+
* @param ttlMs TTL
|
|
5770
|
+
* @returns 在跑的载体 id(**可能多条**;空数组 = 没有在跑)
|
|
5771
|
+
*/
|
|
5772
|
+
function runningCarriersOf(boundSessionIds, nowMs, ttlMs = TURN_TTL_MS) {
|
|
5773
|
+
const running = new Set(listRunningSessionIds(nowMs, ttlMs));
|
|
5774
|
+
return boundSessionIds.filter((id) => running.has(id));
|
|
5775
|
+
}
|
|
5776
|
+
/**
|
|
5777
|
+
* 装配追踪(`index.ts` apply 调用):订阅四个既有事件 + 拆卸。
|
|
5778
|
+
*
|
|
5779
|
+
* **为什么订阅四个而不是两个**(R↓):
|
|
5780
|
+
* · 前两个(`agent/created` / `turn-stopping`)负责**语义**——"在不在跑";
|
|
5781
|
+
* · 后两个(`agent/disposed` / `session/disposed`)负责**生命周期**——
|
|
5782
|
+
* 载体没了必须清项,否则表按载体只增(本模块的头号风险)。
|
|
5783
|
+
*
|
|
5784
|
+
* ⚠️ **失败不抛**:任一事件通道缺失 ⇒ 响亮日志 + 该订阅跳过(**apply 不可成为启动单点**,
|
|
5785
|
+
* 与 `registerDeepseekVisionPatch` 等既有装配同款纪律)。
|
|
5786
|
+
* @param ctx 插件上下文
|
|
5787
|
+
*/
|
|
5788
|
+
function registerCroTurnTracking(ctx) {
|
|
5789
|
+
const on = (event, handler) => {
|
|
5790
|
+
try {
|
|
5791
|
+
ctx.on(event, handler);
|
|
5792
|
+
} catch (err) {
|
|
5793
|
+
console.log(`[serenity-hooks] ✗ CRO turn 追踪未装配(${event} 不可用): ${String(err?.message ?? err)}`);
|
|
5794
|
+
}
|
|
5795
|
+
};
|
|
5796
|
+
on("agent/created", (payload) => {
|
|
5797
|
+
const id = sessionIdOf$3(payload?.agent);
|
|
5798
|
+
if (id) markTurnRunning(id, Date.now());
|
|
5799
|
+
});
|
|
5800
|
+
on("agent/status", (payload) => {
|
|
5801
|
+
const p = payload;
|
|
5802
|
+
const id = sessionIdOf$3(p?.agent);
|
|
5803
|
+
if (!id) return;
|
|
5804
|
+
const turn = typeof p.turn === "number" ? p.turn : null;
|
|
5805
|
+
if (p.status === "running") markTurnRunning(id, Date.now(), turn);
|
|
5806
|
+
else if (p.status === "idle") clearTurnRunning(id);
|
|
5807
|
+
});
|
|
5808
|
+
on("agent/turn-stopping", (payload) => {
|
|
5809
|
+
const id = sessionIdOf$3(payload?.agent);
|
|
5810
|
+
if (id) clearTurnRunning(id);
|
|
5811
|
+
});
|
|
5812
|
+
on("agent/disposed", (payload) => {
|
|
5813
|
+
const id = sessionIdOf$3(payload?.agent);
|
|
5814
|
+
if (id) clearTurnRunning(id);
|
|
5815
|
+
});
|
|
5816
|
+
on("session/disposed", (payload) => {
|
|
5817
|
+
const id = payload?.id;
|
|
5818
|
+
if (typeof id === "string" && id !== "") clearTurnRunning(id);
|
|
5819
|
+
});
|
|
5820
|
+
registerDisposer(ctx, "cro turn tracking", () => {
|
|
5821
|
+
runningBySession.clear();
|
|
5822
|
+
});
|
|
5823
|
+
}
|
|
5824
|
+
//#endregion
|
|
5825
|
+
//#region src/cro-log.ts
|
|
5826
|
+
/**
|
|
5827
|
+
* cro-log.ts — CRO 唤起日志(**ACC 侧**,每轨迹一份固定名文件,2026-09-20 S142 §7.16)
|
|
5828
|
+
*
|
|
5829
|
+
* ## 所有者令(本模块的存在理由)
|
|
5830
|
+
*
|
|
5831
|
+
* 「S151 和 S185 的验证都说明,trajectory 倾向于自己做个结构化记录来记录自身 CRO 的执行情况,
|
|
5832
|
+
* 这是个结构化流水账;**我们 ACC 有必要进行 CRO 唤醒的日志记录**,当 CRO 存在时,每次**成功唤醒**
|
|
5833
|
+
* 写相关信息,但是保留数量要少,**只保留仅两日**的记录即可,**这个时间是程序化的**」
|
|
5834
|
+
* +「**这个日志写结构化文件到 trajectory 目录,文件名固定即可**」
|
|
5835
|
+
* +「**失败成功都记录**」。
|
|
5836
|
+
*
|
|
5837
|
+
* ## 它解决什么麻烦(白话)
|
|
5838
|
+
*
|
|
5839
|
+
* 在此之前「ACC 到底叫过谁、叫了几次、哪次**没叫成**」**ACC 自己一个字都不留** ——
|
|
5840
|
+
* 要查只能去读**被叫的那个程序**写的记录;程序没写 / 写坏 / 被换掉 ⇒ **这段历史就是空白**。
|
|
5841
|
+
* ⇒ 本模块让 ACC **自己那一侧**留一份只记两天的流水,使「唤起历史」**不再依赖被观测者是否老实**。
|
|
5842
|
+
*
|
|
5843
|
+
* ## 🔴 为什么**不是**第二真相源(本容器铁律,勿删此论证)
|
|
5844
|
+
*
|
|
5845
|
+
* 轨迹目录里因此**并排两个文件、主语各一**:
|
|
5846
|
+
*
|
|
5847
|
+
* | 文件 | 谁写 | 主语 | 回答 |
|
|
5848
|
+
* |---|---|---|---|
|
|
5849
|
+
* | `<轨迹目录>/cro-state.json`(CCC 侧程序自建,名字自定) | **CCC 的 CRO 程序** | 程序 | 「**我判了什么**」(含"没叫"的判定、心跳阈值、环境谓词) |
|
|
5850
|
+
* | `<轨迹目录>/cro-wake-log.json`(**本模块**) | **ACC** | ACC | 「**我把什么送出去了、送成没有**」 |
|
|
5851
|
+
*
|
|
5852
|
+
* **内容不重叠**:本模块**不复制**判定细节(不存 `lastDecision` / 阈值 / 谓词);
|
|
5853
|
+
* 而程序**不可能**知道投递结果(CRO 投递是 fire-and-forget)。
|
|
5854
|
+
* ⇒ 两者**互补**:完整审计 = 两者合看(本模块 = 投递面|程序状态 = 判定面)。
|
|
5855
|
+
*
|
|
5856
|
+
* ## 🔴 轮转(所有者明示「程序化的」)
|
|
5857
|
+
*
|
|
5858
|
+
* **写入时按窗裁剪**:每次落盘只保留 `at >= now - CRO_LOG_RETENTION_MS` 的条目,
|
|
5859
|
+
* 随后**原子写**(`tmp + rename`,与 `wake-registry.ts` 同款形态)。
|
|
5860
|
+
* ⇒ 清理与写入**是同一笔操作** ⇒ **结构上不存在"忘了清"的可能**(比"另设一个每 tick 的清理步"更硬:
|
|
5861
|
+
* 那多一个"可能忘了跑"的面)。窗宽是**常量**,**无配置键、无人工清理动作**。
|
|
5862
|
+
*
|
|
5863
|
+
* ## 🔴 铁律(与 `cro.ts` 同族)
|
|
5864
|
+
*
|
|
5865
|
+
* **本模块的每个导出函数都不抛错**:写盘 / 裁剪 / 解析失败一律返回结构化失败,
|
|
5866
|
+
* 由调用方只记一行 tick 日志 ⇒ **CRO 流水的问题绝不影响投递与既有链路**(设计 §5)。
|
|
5867
|
+
*
|
|
5868
|
+
* ## 保留量级(实测,非估计)
|
|
5869
|
+
*
|
|
5870
|
+
* 实测最高 = S185 的 40min 心跳(≈36 条/天);两天窗 ⇒ **≤ ~80 条、< 20 KB**。
|
|
5871
|
+
* 最坏(某程序每 5min 都叫)⇒ 288 条/天 ⇒ 两天 **≤ ~600 条、< 100 KB** —— 轮转**自带上限**。
|
|
5872
|
+
*/
|
|
5873
|
+
/** 固定文件名(所有者令:「文件名固定即可」)——与程序自建的 `cro-state.json` 并排、主语分明 */
|
|
5874
|
+
const CRO_LOG_FILENAME = "cro-wake-log.json";
|
|
5875
|
+
/** 保留窗 = 2 日(所有者令「只保留仅两日」);**常量** ⇒ 「这个时间是程序化的」 */
|
|
5876
|
+
const CRO_LOG_RETENTION_MS = 1728e5;
|
|
5877
|
+
const CRO_LOG_VERSION = 1;
|
|
5878
|
+
/** 日志绝对路径 */
|
|
5879
|
+
function croWakeLogPath(root, dirName) {
|
|
5880
|
+
return join(sessionsRoot(root), dirName, CRO_LOG_FILENAME);
|
|
5881
|
+
}
|
|
5882
|
+
function emptyLog$1() {
|
|
5883
|
+
return {
|
|
5884
|
+
version: CRO_LOG_VERSION,
|
|
5885
|
+
entries: []
|
|
5886
|
+
};
|
|
5887
|
+
}
|
|
5888
|
+
/** 条目形状校验(容忍手改:字段缺失/类型不符 → 丢弃该条,不阻断整档) */
|
|
5889
|
+
function asEntry$1(value) {
|
|
5890
|
+
const e = value;
|
|
5891
|
+
if (!e || typeof e !== "object") return null;
|
|
5892
|
+
if (typeof e.at !== "string" || e.at === "") return null;
|
|
5893
|
+
if (typeof e.ok !== "boolean") return null;
|
|
5894
|
+
if (typeof e.detail !== "string") return null;
|
|
5895
|
+
const digest = e.promptDigest ?? {};
|
|
5896
|
+
return {
|
|
5897
|
+
at: e.at,
|
|
5898
|
+
ok: e.ok,
|
|
5899
|
+
detail: e.detail,
|
|
5900
|
+
tick: typeof e.tick === "number" ? e.tick : 0,
|
|
5901
|
+
reason: typeof e.reason === "string" ? e.reason : null,
|
|
5902
|
+
promptDigest: {
|
|
5903
|
+
length: typeof digest.length === "number" ? digest.length : 0,
|
|
5904
|
+
head: typeof digest.head === "string" ? digest.head : ""
|
|
5905
|
+
}
|
|
5906
|
+
};
|
|
5907
|
+
}
|
|
5908
|
+
/** 提示词摘要(长度 + 前 N 字;超长不截断标记——长度字段本身就是信号) */
|
|
5909
|
+
function summarizeCroPrompt(prompt) {
|
|
5910
|
+
const text = typeof prompt === "string" ? prompt : "";
|
|
5911
|
+
return {
|
|
5912
|
+
length: [...text].length,
|
|
5913
|
+
head: [...text].slice(0, 80).join("")
|
|
5914
|
+
};
|
|
5915
|
+
}
|
|
5916
|
+
/**
|
|
5917
|
+
* 读取日志(**永不抛**;读坏 ⇒ 当作空档 + 返回 error 文本)。
|
|
5918
|
+
* @param root CCC 根
|
|
5919
|
+
* @param dirName 轨迹目录名
|
|
5920
|
+
*/
|
|
5921
|
+
function loadCroWakeLog(root, dirName) {
|
|
5922
|
+
const path = croWakeLogPath(root, dirName);
|
|
5923
|
+
if (!existsSync(path)) return {
|
|
5924
|
+
log: emptyLog$1(),
|
|
5925
|
+
error: null
|
|
5926
|
+
};
|
|
5927
|
+
try {
|
|
5928
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
5929
|
+
const raw = Array.isArray(parsed.entries) ? parsed.entries : [];
|
|
5930
|
+
const entries = [];
|
|
5931
|
+
for (const item of raw) {
|
|
5932
|
+
const e = asEntry$1(item);
|
|
5933
|
+
if (e) entries.push(e);
|
|
5934
|
+
}
|
|
5935
|
+
return {
|
|
5936
|
+
log: {
|
|
5937
|
+
version: CRO_LOG_VERSION,
|
|
5938
|
+
entries
|
|
5939
|
+
},
|
|
5940
|
+
error: null
|
|
5941
|
+
};
|
|
5942
|
+
} catch (err) {
|
|
5943
|
+
return {
|
|
5944
|
+
log: emptyLog$1(),
|
|
5945
|
+
error: `CRO 流水解析失败(${String(err?.message ?? err)})`
|
|
5946
|
+
};
|
|
5947
|
+
}
|
|
5948
|
+
}
|
|
5949
|
+
/**
|
|
5950
|
+
* 按窗裁剪(**纯函数** ⇒ 可穷举测试)。
|
|
5951
|
+
*
|
|
5952
|
+
* 判据:`Date.parse(at) >= nowMs - windowMs` 保留;反之丢弃。
|
|
5953
|
+
* 🔴 **`at` 不可解析者一律丢弃**(本模块只写 `isoLocal()` 产物 ⇒ 不可解析 = 被手改或损坏),
|
|
5954
|
+
* **并在返回值里报数**(`dropped`)——**不静默吞掉**(本容器反复栽的"安静失败"纪律)。
|
|
5955
|
+
*/
|
|
5956
|
+
function pruneCroLogEntries(entries, nowMs, windowMs = CRO_LOG_RETENTION_MS) {
|
|
5957
|
+
const floor = nowMs - windowMs;
|
|
5958
|
+
const kept = [];
|
|
5959
|
+
let pruned = 0;
|
|
5960
|
+
let dropped = 0;
|
|
5961
|
+
for (const e of entries) {
|
|
5962
|
+
const t = Date.parse(e.at);
|
|
5963
|
+
if (Number.isNaN(t)) {
|
|
5964
|
+
dropped += 1;
|
|
5965
|
+
continue;
|
|
5966
|
+
}
|
|
5967
|
+
if (t >= floor) kept.push(e);
|
|
5968
|
+
else pruned += 1;
|
|
5969
|
+
}
|
|
5970
|
+
return {
|
|
5971
|
+
kept,
|
|
5972
|
+
pruned,
|
|
5973
|
+
dropped
|
|
5974
|
+
};
|
|
5975
|
+
}
|
|
5976
|
+
/**
|
|
5977
|
+
* 追加一条唤起记录并按窗裁剪(**本模块主入口;永不抛**)。
|
|
5978
|
+
*
|
|
5979
|
+
* 顺序 = 读 → 追加 → 裁剪 → **原子写**。任一步失败 ⇒ `{ok:false, error}`,
|
|
5980
|
+
* **不抛**(调用方只记一行 tick 日志;**绝不影响投递**)。
|
|
5981
|
+
*
|
|
5982
|
+
* 无 CRO 的轨迹**不会**因本函数产生文件:只有真的发生过一次唤起尝试才会建/写该档
|
|
5983
|
+
* (所有者令「**当 CRO 存在时**」)。
|
|
5984
|
+
*
|
|
5985
|
+
* @param root CCC 根
|
|
5986
|
+
* @param dirName 轨迹目录名
|
|
5987
|
+
* @param input 本次唤起尝试(成败都记)
|
|
5988
|
+
* @param nowMs 当前毫秒(可注入 ⇒ 便于测裁剪边界)
|
|
5989
|
+
* @returns 写入结果与裁剪计数
|
|
5990
|
+
*/
|
|
5991
|
+
function appendCroWakeLog(root, dirName, input, nowMs = Date.now()) {
|
|
5992
|
+
const path = croWakeLogPath(root, dirName);
|
|
5993
|
+
try {
|
|
5994
|
+
const loaded = loadCroWakeLog(root, dirName);
|
|
5995
|
+
const entry = {
|
|
5996
|
+
at: isoLocal(nowMs),
|
|
5997
|
+
ok: input.ok,
|
|
5998
|
+
detail: input.detail,
|
|
5999
|
+
tick: input.tick,
|
|
6000
|
+
reason: input.reason,
|
|
6001
|
+
promptDigest: summarizeCroPrompt(input.prompt)
|
|
6002
|
+
};
|
|
6003
|
+
const { kept, pruned, dropped } = pruneCroLogEntries([...loaded.log.entries, entry], nowMs);
|
|
6004
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
6005
|
+
const tmp = `${path}.tmp`;
|
|
6006
|
+
writeFileSync(tmp, `${JSON.stringify({
|
|
6007
|
+
version: CRO_LOG_VERSION,
|
|
6008
|
+
entries: kept
|
|
6009
|
+
}, null, 2)}\n`, "utf-8");
|
|
6010
|
+
renameSync(tmp, path);
|
|
6011
|
+
return {
|
|
6012
|
+
ok: true,
|
|
6013
|
+
error: null,
|
|
6014
|
+
kept: kept.length,
|
|
6015
|
+
pruned,
|
|
6016
|
+
dropped
|
|
6017
|
+
};
|
|
6018
|
+
} catch (err) {
|
|
6019
|
+
return {
|
|
6020
|
+
ok: false,
|
|
6021
|
+
error: `CRO 流水写入失败(${String(err?.message ?? err)})`,
|
|
6022
|
+
kept: 0,
|
|
6023
|
+
pruned: 0,
|
|
6024
|
+
dropped: 0
|
|
6025
|
+
};
|
|
6026
|
+
}
|
|
6027
|
+
}
|
|
6028
|
+
//#endregion
|
|
6029
|
+
//#region src/clock-runtime.ts
|
|
6030
|
+
/**
|
|
6031
|
+
* clock-runtime.ts — **时钟运行时工厂**(S142 §38 复审候选 C6b,路径 **P2**)
|
|
6032
|
+
*
|
|
6033
|
+
* 🔴 2026-09-25(S142 分批清死代码·第 1 批)**更正**:本文件当初是为**两条**独立 5min 时钟抽出的公共引擎:
|
|
6034
|
+
* · 唤醒调度器(`wake-scheduler.ts`,一次性唤醒,D58)—— **现存**
|
|
6035
|
+
* · autopilot 时钟(`autopilot-trajectory.ts`,周期自唤醒,D59)—— 🔴 **已随 v1.35.0 整段退场**
|
|
6036
|
+
* 二者的**业务判据完全不同**(谁来扫 / 扫什么 / 投递给谁 / 按什么周期),但**引擎骨架逐字同形**
|
|
6037
|
+
* ——进程态 + 快照 + 复位 + `startTimer` + tick 外壳 + 事件接线 + disposer + `timer.unref()`。
|
|
6038
|
+
* 本条把**引擎**抽到本工厂,**各钟仍各持自己的定时器与自己的串行链**(现存的那一钟 = 唤醒调度器)。
|
|
6039
|
+
*
|
|
6040
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
6041
|
+
* 🔴 硬约束一:**两条时钟绝不可共用"排队执行"的链**(这是 P2 与 P1 的分界判据)
|
|
6042
|
+
*
|
|
6043
|
+
* autopilot 每轮要跑该 CCC 的**偏见脚本**(`fetchBiasContent`,超时 60s),可能阻塞数十秒;
|
|
6044
|
+
* 若与唤醒调度器共用一条串行链,会把另一条一起**卡住**(一次性唤醒被无关的偏见脚本拖住)。
|
|
6045
|
+
* ⇒ 本工厂把 `chain` 作为**每实例私有状态**:`createClock()` 调用一次 = 一条独立的串行域。
|
|
6046
|
+
* 实例之间**零共享**(无模块级单例、无共享 Promise、无共享 map)。
|
|
6047
|
+
* ⚠️ 若日后有人把 `chain` 提到模块级"省一次分配",**两条时钟的隔离即刻失效**——不许。
|
|
6048
|
+
*
|
|
6049
|
+
* 🔴 硬约束二:**武装门只判全局闸**(`gate()`),**不判"本次有无目标"**
|
|
6050
|
+
*
|
|
6051
|
+
* 2026-09-14 F 段实测缺陷:旧实现把"有 live CCC"当**武装前置条件**,而宿主刚重启时 live 会话
|
|
6052
|
+
* 必为空,且**"恢复旧会话"不触发 `session/created`**(只有新建会)⇒ 启动瞬间判定落空 =
|
|
6053
|
+
* **时钟永久不武装**(实测 6.6h 零 tick)。⇒ 现在「**全局闸开即武装**」,"有无目标"降级为
|
|
6054
|
+
* tick 内的廉价判定。**武装与"本次有无目标"是两件事,本工厂不得把二者耦合。**
|
|
6055
|
+
*
|
|
6056
|
+
* 🔴 硬约束三:**闸是可选能力,工厂不得替调用方假设"必须有闸"**(v1.34 S-1 解耦 → 2026-09-21 收窄)
|
|
6057
|
+
*
|
|
6058
|
+
* 🔴 2026-09-25(S142 分批清死代码·第 1 批)**更正:当前零条闸在用** —— 现存唯一那一钟
|
|
6059
|
+
* (唤醒调度器)本已无闸,而"曾只余的那一条" `autopilotWakeEnabled` 已随 **v1.35.0 autopilot
|
|
6060
|
+
* 整段退场**一并消失 ⇒ `gate` **缺省即常态,不是特例**。**唤醒调度器已无闸**
|
|
6061
|
+
* (原 `wakeSchedulerEnabled` 于 2026-09-21 所有者令砍掉 ⇒ 调用方**不传** `gate`
|
|
6062
|
+
* = 恒武装、永不因闸跳过、`enabled` 恒 `true`)。工厂取 `gate` **回调**(每次现读),
|
|
6063
|
+
* **不做**任何合并或缓存;**缺省无闸是合法形态,不是漏配**。
|
|
6064
|
+
*
|
|
6065
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
6066
|
+
* 两条时钟**逐项语义差**(`ClockOptions` 的每个可配项都对应下表一行,不是随意的灵活性):
|
|
6067
|
+
*
|
|
6068
|
+
* | 项 | 唤醒调度器 | autopilot | 工厂表达 |
|
|
6069
|
+
* |----|-----------|-----------|---------|
|
|
6070
|
+
* | `chain` | 有(全局串行) | 有(全局串行) | **总是私有**(硬约束一) |
|
|
6071
|
+
* | `lastTickLog` | 有(逐行摘要) | **无** | `logFrom` 不给 = 不记 |
|
|
6072
|
+
* | `ticks` 记账 | 工厂在 body **完成后**记 | **本钟**在同步段记 | `bodyCountsTick` + `begin` |
|
|
6073
|
+
* | body 返回类型 | `string[]`(人读摘要) | `void` | 泛型 `T` |
|
|
6074
|
+
* | 启动日志 | 固定串 | 含启用 CCC 计数 | `startLog: () => string` |
|
|
6075
|
+
* | body 异常兜底 | 留痕 + `console.warn` | 同 | 工厂统一(**吞异常不改语义**) |
|
|
6076
|
+
*
|
|
6077
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
6078
|
+
* **本工厂是"加法式"重构**(P2 的性质):不新增任何判定逻辑,只把同形骨架收成一份代码;
|
|
6079
|
+
* 两个模块仍各自导出 `wakeSchedulerState()` / `autopilotClockState()` 与各自的
|
|
6080
|
+
* `__reset…ForTest()`,**对外可观测面(字段名与语义)零变化**——`acc-diag` ①b 段、
|
|
6081
|
+
* `container-status.ts:containerClocks()` 与面板都在读它们(见该文件 :225–250 的设计红线)。
|
|
6082
|
+
*
|
|
6083
|
+
* 为什么**不**取 P1(真合并成一个 `setInterval` + 两条策略)——2026-09-15 所有者裁决:
|
|
6084
|
+
* P1 只多省 ~20 行,却要把 `armed` 拆成"宿主 + 每策略"两层、并强行分开串行域,
|
|
6085
|
+
* 即**动两条正在跑的时钟的骨架**;**不值当**(裁决原文见 SESSION §12.8②)。
|
|
6086
|
+
*/
|
|
6087
|
+
/**
|
|
6088
|
+
* tick 周期(**5 分钟**)。
|
|
6089
|
+
*
|
|
6090
|
+
* 2026-09-15 S142「ACC 侧 autopilot 退场」:本值原从 `autopilot-core.ts` 导入(`TICK_MS`),
|
|
6091
|
+
* 但该模块随 autopilot 退场,而**本模块的唯一消费者是唤醒调度器**(`wake-scheduler`)——
|
|
6092
|
+
* 值本身与 autopilot 无关 ⇒ 随消费者归位到本模块(不再依赖 autopilot 判据层)。
|
|
6093
|
+
*
|
|
6094
|
+
* ⚠️ 语义不得变:该值同时是**报告文案的来源**("等下个 Nmin tick"),历史上曾因文案写死
|
|
6095
|
+
* 10min 与实现 5min 不符而误导排查(§12.7 文案纠错)。
|
|
6096
|
+
*/
|
|
6097
|
+
const TICK_MS = 3e5;
|
|
6098
|
+
/**
|
|
6099
|
+
* 挂事件 → 触发 `start()`(**每个事件一次 try/catch 吞异常**:事件通道缺失不阻断时钟)。
|
|
6100
|
+
*
|
|
6101
|
+
* 为什么吞异常:宿主(headless profile 等)可能没有事件通道;**启动时已 `start()` 过一次**,
|
|
6102
|
+
* 事件只是"跟上面板开关 / 新会话"的热启动补丁,缺它不该让整个装配抛错。
|
|
6103
|
+
* @param on 宿主的 `on`(可能不存在)
|
|
6104
|
+
* @param ctx `on` 的 `this`(宿主 API 常要求绑定)
|
|
6105
|
+
* @param clock 目标时钟
|
|
6106
|
+
* @param events 事件名列表(回调恒为 `() => clock.start()`)
|
|
6107
|
+
*/
|
|
6108
|
+
function attachClockEvents(ctx, clock, events) {
|
|
6109
|
+
const on = ctx?.on;
|
|
6110
|
+
if (typeof on !== "function") return;
|
|
6111
|
+
for (const name of events) try {
|
|
6112
|
+
on.call(ctx, name, () => clock.start());
|
|
6113
|
+
} catch {}
|
|
6114
|
+
}
|
|
6115
|
+
/**
|
|
6116
|
+
* 建一条时钟(**每钟各调一次**——本工厂**不是**单例,调用次数 = 时钟条数)。
|
|
6117
|
+
*
|
|
6118
|
+
* 顺序忠实于既有实现:`start()` 先武装并立即跑一次 tick → 再挂事件。
|
|
6119
|
+
* 拆卸由调用方经 `registerDisposer(ctx, label, clock.dispose)` 落到宿主的 `ctx.effect`。
|
|
6120
|
+
* @param opts 见 {@link ClockOptions}(每项对应一条既有的语义差)
|
|
6121
|
+
* @returns 该钟的 `{start, dispose, snapshot, reset}`
|
|
6122
|
+
*/
|
|
6123
|
+
function createClock(opts) {
|
|
6124
|
+
const runtime = {
|
|
6125
|
+
armed: false,
|
|
6126
|
+
armedAt: null,
|
|
6127
|
+
lastTickAt: null,
|
|
6128
|
+
ticks: 0,
|
|
6129
|
+
lastSkipReason: null
|
|
6130
|
+
};
|
|
6131
|
+
if (opts.logFrom) runtime.lastTickLog = [];
|
|
6132
|
+
const st = {
|
|
6133
|
+
timer: null,
|
|
6134
|
+
chain: Promise.resolve(),
|
|
6135
|
+
runtime,
|
|
6136
|
+
wired: false,
|
|
6137
|
+
opts
|
|
6138
|
+
};
|
|
6139
|
+
/** 记一次 tick(工厂内部与 body 共用同一实现——只有一处会计逻辑) */
|
|
6140
|
+
const countTick = () => {
|
|
6141
|
+
runtime.lastTickAt = Date.now();
|
|
6142
|
+
runtime.ticks += 1;
|
|
6143
|
+
};
|
|
6144
|
+
/** 逐行打印(`logFrom` 缺省 ⇒ 本钟无 tick 日志,整段为死代码路径) */
|
|
6145
|
+
const emit = (lines) => {
|
|
6146
|
+
if (!opts.logFrom) return;
|
|
6147
|
+
for (const line of lines) console.log(`${opts.logFrom.prefix}${line}`);
|
|
6148
|
+
};
|
|
6149
|
+
const tick = () => {
|
|
6150
|
+
if (opts.gate !== void 0 && !opts.gate()) {
|
|
6151
|
+
runtime.lastSkipReason = opts.gateOffReason ?? null;
|
|
6152
|
+
return;
|
|
6153
|
+
}
|
|
6154
|
+
if (opts.begin) try {
|
|
6155
|
+
opts.begin();
|
|
6156
|
+
} catch (err) {
|
|
6157
|
+
console.warn(`[serenity-hooks] ✗ tick 前置异常: ${String(err?.message ?? err)}(${opts.label})`);
|
|
6158
|
+
return;
|
|
6159
|
+
}
|
|
6160
|
+
st.chain = st.chain.then(async () => {
|
|
6161
|
+
try {
|
|
6162
|
+
const value = await opts.body();
|
|
6163
|
+
if (!opts.bodyCountsTick) countTick();
|
|
6164
|
+
if (opts.logFrom) {
|
|
6165
|
+
runtime.lastTickLog = opts.logFrom.lines(value);
|
|
6166
|
+
emit(runtime.lastTickLog);
|
|
6167
|
+
}
|
|
6168
|
+
} catch (err) {
|
|
6169
|
+
const msg = `✗ tick 异常: ${String(err?.message ?? err)}`;
|
|
6170
|
+
if (opts.logFrom) runtime.lastTickLog = [msg];
|
|
6171
|
+
console.warn(`[serenity-hooks] ${msg}(${opts.label})`);
|
|
6172
|
+
}
|
|
6173
|
+
});
|
|
6174
|
+
};
|
|
6175
|
+
/**
|
|
6176
|
+
* 幂等启动 + 武装门:**已武装 → 原样返回**(防重复起定时器);
|
|
6177
|
+
* **闸关 → 不武装**(零资源占用);闸开则**无条件**武装(硬约束二:不判"有无目标")。
|
|
6178
|
+
*/
|
|
6179
|
+
const start = () => {
|
|
6180
|
+
if (st.timer) return;
|
|
6181
|
+
if (opts.events && !st.wired) {
|
|
6182
|
+
st.wired = true;
|
|
6183
|
+
attachClockEvents(opts.ctx, api, opts.events);
|
|
6184
|
+
}
|
|
6185
|
+
if (opts.gate !== void 0 && !opts.gate()) return;
|
|
6186
|
+
st.timer = setInterval(tick, TICK_MS);
|
|
6187
|
+
st.timer.unref();
|
|
6188
|
+
runtime.armed = true;
|
|
6189
|
+
runtime.armedAt = Date.now();
|
|
6190
|
+
console.log(opts.startLog());
|
|
6191
|
+
if (opts.immediate !== false) tick();
|
|
6192
|
+
};
|
|
6193
|
+
const dispose = () => {
|
|
6194
|
+
if (st.timer) {
|
|
6195
|
+
clearInterval(st.timer);
|
|
6196
|
+
st.timer = null;
|
|
6197
|
+
}
|
|
6198
|
+
runtime.armed = false;
|
|
6199
|
+
runtime.armedAt = null;
|
|
6200
|
+
opts.onDispose?.();
|
|
6201
|
+
};
|
|
6202
|
+
const api = {
|
|
6203
|
+
start,
|
|
6204
|
+
dispose,
|
|
6205
|
+
snapshot: () => ({
|
|
6206
|
+
enabled: opts.gate === void 0 ? true : opts.gate(),
|
|
6207
|
+
...runtime,
|
|
6208
|
+
...runtime.lastTickLog ? { lastTickLog: [...runtime.lastTickLog] } : {}
|
|
6209
|
+
}),
|
|
6210
|
+
noteSkipReason: (reason) => {
|
|
6211
|
+
runtime.lastSkipReason = reason;
|
|
6212
|
+
},
|
|
6213
|
+
countTick,
|
|
6214
|
+
enqueue: (work) => {
|
|
6215
|
+
st.chain = st.chain.then(work, () => void 0).then(() => void 0, (err) => {
|
|
6216
|
+
console.warn(`[serenity-hooks] ✗ 串行任务异常(${opts.label}): ${String(err?.message ?? err)}`);
|
|
6217
|
+
});
|
|
6218
|
+
},
|
|
6219
|
+
reset: () => {
|
|
6220
|
+
if (st.timer) {
|
|
6221
|
+
clearInterval(st.timer);
|
|
6222
|
+
st.timer = null;
|
|
6223
|
+
}
|
|
6224
|
+
runtime.armed = false;
|
|
6225
|
+
runtime.armedAt = null;
|
|
6226
|
+
runtime.lastTickAt = null;
|
|
6227
|
+
runtime.ticks = 0;
|
|
6228
|
+
runtime.lastSkipReason = null;
|
|
6229
|
+
if (opts.logFrom) runtime.lastTickLog = [];
|
|
6230
|
+
st.wired = false;
|
|
6231
|
+
opts.onReset?.();
|
|
6232
|
+
}
|
|
6233
|
+
};
|
|
6234
|
+
return api;
|
|
6235
|
+
}
|
|
6236
|
+
//#endregion
|
|
6237
|
+
//#region src/wake-scheduler.ts
|
|
6238
|
+
/**
|
|
6239
|
+
* 解析条目的 target(`S###` 或完整目录名)→ 目录名 + SESSION.md 路径。
|
|
6240
|
+
* @param root CCC 根
|
|
6241
|
+
* @param target 条目里的目标标识
|
|
6242
|
+
* @returns 命中返回目录信息,未命中 null
|
|
6243
|
+
*/
|
|
6244
|
+
function resolveWakeTarget(root, target) {
|
|
6245
|
+
const found = findSession(sessionsRoot(root), target);
|
|
6246
|
+
if (!found) return null;
|
|
6247
|
+
const mdPath = join(found.path, "SESSION.md");
|
|
6248
|
+
if (!existsSync(mdPath)) return null;
|
|
6249
|
+
return {
|
|
6250
|
+
dirName: basename(found.path),
|
|
6251
|
+
mdPath
|
|
6252
|
+
};
|
|
6253
|
+
}
|
|
6254
|
+
/**
|
|
6255
|
+
* 🔴 **本钟的全局闸已于 2026-09-21 砍掉**(所有者令逐字:「**send-later 的开关不再重要了,砍掉**」)。
|
|
6256
|
+
*
|
|
6257
|
+
* **为什么砍**(R↓):唤醒已从"可选实验能力"长成**全容器的续接绳**(🔵 **术语说明,2026-09-23 补**:
|
|
6258
|
+
* **"绳" = 我们对自己用 `send-later` 给自己排的"定时叫醒"条目的俗称** —— 一条链每轮给自己排下一根,
|
|
6259
|
+
* 链才不断。本仓注释与 SESSION.md 沿用此说法,**它不是产品名、也不是宿主概念**)——S142/S151/S185 的链、
|
|
6260
|
+
* 跨轨迹投递、`send-now`/`send-later` 都走它。留一个"能被误关的开关"的**代价**是
|
|
6261
|
+
* "整条链静默停摆且无人告警"(本仓已实测过同族事故:闸关 6.6h 零 tick、断链 1 天 14 小时);
|
|
6262
|
+
* 而**收益**只剩"关掉一个 5min unref 空检查"。⇒ **收益 < 代价,故删闸**(不是改为缺省开——是**没有这个键了**)。
|
|
6263
|
+
*
|
|
6264
|
+
* ⚠️ **兼容**:旧配置文件里残留的 `wakeSchedulerEnabled` **静默忽略**(无回退键、无告警噪声;
|
|
6265
|
+
* 与旧 `bootstrap` 段的处置先例一致)。**机制本身一行未动**(所有者:"我说的只是开关")。
|
|
6266
|
+
*
|
|
6267
|
+
* 历史(勿误读):v1.34 S-1 曾把两闸解耦(周期自唤醒 `autopilotWakeEnabled` / 本钟 `wakeSchedulerEnabled`),
|
|
6268
|
+
* 理由是"关 auto-trajectory 不得连带关一次性唤醒";随 ACC 侧 autopilot 于 v1.35.0 整段退场,
|
|
6269
|
+
* 两闸只剩本钟一座 ⇒ 现已整座移除。详见 `docs/trajectory-scheduling.md` §S-1 与 v1.44 CHANGELOG。
|
|
6270
|
+
*/
|
|
6271
|
+
/**
|
|
6272
|
+
* 🔴 **本钟的串行域**(C6b 硬约束):`clock.chain` 是**本实例私有**的——
|
|
6273
|
+
* 与 autopilot 的串行链**零共享**。autopilot 每轮跑 CCC 偏见脚本可能阻塞数十秒,
|
|
6274
|
+
* 共用链会把一次性唤醒一起卡住(这正是 P1 被否、取 P2 的理由,见 SESSION §12.8②)。
|
|
6275
|
+
*
|
|
6276
|
+
* **为什么这里是模块级常量**(而不是 `registerWakeScheduler` 的局部量):本钟的进程态是
|
|
6277
|
+
* **进程级可观测面**(`acc-diag` ①b / `containerClocks` 在读),且 `wakeSchedulerState()` /
|
|
6278
|
+
* `__resetWakeSchedulerStateForTest()` 是**公开导出**、可在未装配时被调用 ⇒ 实例必须**恒在**
|
|
6279
|
+
* (旧实现的 `schedulerRuntime` 也正是模块级常量)。装配只做 `clock.start()`。
|
|
6280
|
+
*/
|
|
6281
|
+
/** 本钟的装配项(`ctx` 由 {@link registerWakeScheduler} 在装配点填入) */
|
|
6282
|
+
const clockOpts = {
|
|
6283
|
+
label: "trajectory 唤醒调度器",
|
|
6284
|
+
ctx: void 0,
|
|
6285
|
+
events: ["session/created", "serenity/settings-changed"],
|
|
6286
|
+
body: () => runWakeTick(clockOpts.ctx),
|
|
6287
|
+
logFrom: {
|
|
6288
|
+
prefix: "[serenity-hooks] trajectory 唤醒 ",
|
|
6289
|
+
lines: (lines) => lines
|
|
6290
|
+
},
|
|
6291
|
+
startLog: () => "[serenity-hooks] ✓ trajectory 唤醒调度器启动(5min tick)"
|
|
6292
|
+
};
|
|
6293
|
+
const clock = createClock(clockOpts);
|
|
6294
|
+
/** 调度器进程态快照(只读)。
|
|
6295
|
+
* ⚠️ `enabled` 对**本钟已无意义**(2026-09-21 起无闸 ⇒ 恒 `true`);保留字段只为沿用工厂快照形状。
|
|
6296
|
+
* "这轮为什么没被唤起"现在看 `armed` / `lastSkipReason`(tick 内真实原因)+ **CRO 闸**(见 `croEnabled`)。 */
|
|
6297
|
+
function wakeSchedulerState() {
|
|
6298
|
+
return clock.snapshot();
|
|
6299
|
+
}
|
|
6300
|
+
/**
|
|
6301
|
+
* 取目标 agent:live 优先 → 冷会话走 sessionController.resolveAgent → 标题回退。
|
|
6302
|
+
* @param ctx 插件上下文
|
|
6303
|
+
* @param root CCC 根
|
|
6304
|
+
* @param dirName 目标 trajectory 目录名
|
|
6305
|
+
* @returns 命中返回 agent 与取得方式(how 进 lastResult,便于诊断);未命中返回原因
|
|
6306
|
+
* (原因带 `notReady` = **环境未就绪**而非投递失败,调用方**不得**据此记一次失败)
|
|
6307
|
+
*
|
|
6308
|
+
* 🔴 **前置条件:本轨迹名下必须有一条「未取代」的绑定记录**(2026-09-20 回源码答复 S151 实测):
|
|
6309
|
+
* 选人是**遍历 `listBoundSessionIds(root, dirName)` 给出的 id**(按绑定时间倒序),**live 只是这条
|
|
6310
|
+
* 记录的一个加分项**——「绑定里的那条恰好 live ⇒ 直接注入」,否则走冷载入。
|
|
6311
|
+
* ⇒ **「有 live 会话」≠「有绑定」**:会话活着但该轨迹**零条未取代绑定**时,本函数**直接返回
|
|
6312
|
+
* `无绑定会话记录…`**(`send-now` / `send-later` 到点投递同此判据)。
|
|
6313
|
+
* 实证(2026-09-20):S142 三条旧绑定于 **08:03** 被 `supersededAt` 取代(D69「同轨迹只留一条」),
|
|
6314
|
+
* 而当前载体的绑定**直到 17:05 那次 rebuild 才写入**(`queueRebuild` 内的 `appendBound(action:'rebuild')`)
|
|
6315
|
+
* ⇒ **10:42 的 `send-now` 落在这个窗口里** ⇒ 报"无绑定会话记录"(当时确有 live 会话)。
|
|
6316
|
+
* ⚠️ 已知**注释漂移**:本行上方"→ 标题回退"在函数体内**没有对应分支**(标题回退属 v1.29.2 R2,
|
|
6317
|
+
* 现行实现已只认绑定);**仅登记,未改**(改它属 ACC 代码改动,须具名令)。
|
|
6318
|
+
*/
|
|
6319
|
+
async function acquireWakeAgent(ctx, root, dirName) {
|
|
6320
|
+
const agents = hostAgents(ctx);
|
|
6321
|
+
const ids = listBoundSessionIds(root, dirName);
|
|
6322
|
+
for (const id of ids) {
|
|
6323
|
+
const live = agents?.get?.(id);
|
|
6324
|
+
if (live) return {
|
|
6325
|
+
agent: live,
|
|
6326
|
+
how: `live(bound ${id})`
|
|
6327
|
+
};
|
|
6328
|
+
}
|
|
6329
|
+
if (ids.length > 0) {
|
|
6330
|
+
const sc = hostService(ctx, "sessionController");
|
|
6331
|
+
const resolved = sc?.resolveAgent;
|
|
6332
|
+
if (typeof resolved !== "function") return {
|
|
6333
|
+
error: `宿主尚未就绪:目标会话未加载,且 sessionController 服务此刻不可用(重启后的启动窗口,或本进程确实无该服务)——绑定 id: ${ids[0]}`,
|
|
6334
|
+
notReady: true
|
|
6335
|
+
};
|
|
6336
|
+
const id = ids[0];
|
|
6337
|
+
try {
|
|
6338
|
+
const out = await resolved.call(sc, id);
|
|
6339
|
+
if (out?.agent) return {
|
|
6340
|
+
agent: out.agent,
|
|
6341
|
+
how: `cold-resume(${id})`
|
|
6342
|
+
};
|
|
6343
|
+
return { error: `sessionController.resolveAgent 未返回 agent(${id}): ${String(out?.error ?? "未知原因")}` };
|
|
6344
|
+
} catch (err) {
|
|
6345
|
+
return { error: `冷会话载入失败(${id}): ${String(err?.message ?? err)}` };
|
|
6346
|
+
}
|
|
6347
|
+
}
|
|
6348
|
+
return { error: `无绑定会话记录(AGENT_SESSIONS/.bindings.json 中无 ${dirName})——目标轨迹需先被 container_trajectory use 激活过` };
|
|
6349
|
+
}
|
|
6350
|
+
/** 唤醒消息(唤起即投递正文:身份锚定 + 唤醒信息 + 任务) */
|
|
6351
|
+
function buildWakeText(entry, target) {
|
|
6352
|
+
return [
|
|
6353
|
+
`[trajectory 唤醒] 到点(登记于 ${entry.createdAt},发起者 ${entry.createdBy || "未知"},id ${entry.id})。`,
|
|
6354
|
+
`当前时间:${localHuman()}(当地时区)。`,
|
|
6355
|
+
"",
|
|
6356
|
+
`身份锚定:继续 ${target.dirName} 的 trajectory(SESSION.md: ${target.mdPath})。`,
|
|
6357
|
+
"",
|
|
6358
|
+
"唤醒信息:",
|
|
6359
|
+
entry.message,
|
|
6360
|
+
"",
|
|
6361
|
+
"任务:按上述唤醒信息继续本轨迹的工作;完成后把进展写入 SESSION.md。"
|
|
6362
|
+
].join("\n");
|
|
6363
|
+
}
|
|
6364
|
+
/**
|
|
6365
|
+
* 投递一条唤醒(fire-and-forget:`followup` 入队即返回,不等模型结果)。
|
|
6366
|
+
* @param ctx 插件上下文
|
|
6367
|
+
* @param root CCC 根
|
|
6368
|
+
* @param entry 唤醒条目
|
|
6369
|
+
* @returns 投递结果(写回 lastResult)
|
|
6370
|
+
*/
|
|
6371
|
+
async function deliverWake(ctx, root, entry) {
|
|
6372
|
+
const target = resolveWakeTarget(root, entry.target);
|
|
6373
|
+
if (!target) return {
|
|
6374
|
+
ok: false,
|
|
6375
|
+
detail: `目标 trajectory 未命中(${entry.target})`
|
|
6376
|
+
};
|
|
6377
|
+
const acquired = await acquireWakeAgent(ctx, root, target.dirName);
|
|
6378
|
+
if ("error" in acquired) return {
|
|
6379
|
+
ok: false,
|
|
6380
|
+
detail: acquired.error,
|
|
6381
|
+
notReady: acquired.notReady === true
|
|
6382
|
+
};
|
|
6383
|
+
try {
|
|
6384
|
+
acquired.agent.followup(createUserMessage({
|
|
6385
|
+
content: [{
|
|
6386
|
+
type: "text",
|
|
6387
|
+
text: buildWakeText(entry, target)
|
|
6388
|
+
}],
|
|
6389
|
+
source: PLUGIN_SOURCE
|
|
6390
|
+
}));
|
|
6391
|
+
} catch (err) {
|
|
6392
|
+
return {
|
|
6393
|
+
ok: false,
|
|
6394
|
+
detail: `投递失败(${acquired.how}): ${String(err?.message ?? err)}`
|
|
6395
|
+
};
|
|
6396
|
+
}
|
|
6397
|
+
return {
|
|
6398
|
+
ok: true,
|
|
6399
|
+
detail: `已投递 ${target.dirName}(${acquired.how})`
|
|
6400
|
+
};
|
|
6401
|
+
}
|
|
6402
|
+
/**
|
|
6403
|
+
* 即时消息正文(**刻意不复用** {@link buildWakeText}):唤醒文本以「**到点**(登记于…)」开头,
|
|
6404
|
+
* 对即时投递那是**错的事实**。本函数显式区分"这是即时消息、不是到点唤醒"。
|
|
6405
|
+
*
|
|
6406
|
+
* ⚠️ **v1.39.2 起"已入队"这句话必须删掉**(S142 §0r,所有者令「不排队、直接即时注入」):
|
|
6407
|
+
* 旧文案写"消息已进入你的队列;你此前若正在跑轮次,它在本轮结束后生效"——
|
|
6408
|
+
* 那是**旧的 followup 语义**;现在在跑的目标走 `steer`(当场注入当前轮),
|
|
6409
|
+
* 留着这句话就是**注入一条假陈述**(比没有更坏:目标会误判自己看到它的时机)。
|
|
6410
|
+
* @param target 目标轨迹(目录名 + SESSION.md)
|
|
6411
|
+
* @param message 递送正文
|
|
6412
|
+
* @param sender 发起者轨迹标识(供目标与人类识别来源)
|
|
6413
|
+
*/
|
|
6414
|
+
function buildSendText(target, message, sender) {
|
|
6415
|
+
return [
|
|
6416
|
+
`[即时消息] 来自 ${sender || "未知轨迹"}。当前时间:${localHuman()}(当地时区)。`,
|
|
6417
|
+
"",
|
|
6418
|
+
`身份锚定:继续 ${target.dirName} 的 trajectory(SESSION.md: ${target.mdPath})。`,
|
|
6419
|
+
"",
|
|
6420
|
+
"消息:",
|
|
6421
|
+
message,
|
|
6422
|
+
"",
|
|
6423
|
+
"说明:这是**即时投递**(不是到点唤醒)——它**不排队**:目标正在跑轮次就**当场注入当前轮**,目标空闲则立即起一轮。处理完照常把进展写入 SESSION.md。"
|
|
6424
|
+
].join("\n");
|
|
6425
|
+
}
|
|
6426
|
+
/**
|
|
6427
|
+
* 即时投递(`container_trajectory send-now`)——与 `send-later`(预约)**共用取用通路**(`acquireWakeAgent`),
|
|
6428
|
+
* 区别只在**时刻**(现在 vs 未来)与**回执**(有 vs 无):
|
|
6429
|
+
* · `send-later`(原 `wake-later`):未来时刻 + 一条 message → 落注册表 → 本文件的调度器到点投递(**fire-and-forget,无回执**)
|
|
6430
|
+
* · `send-now`(原 `send-message`):**此刻**把一条 message 递给目标 → **同步回执** → **不落注册表**
|
|
6431
|
+
*
|
|
6432
|
+
* **为什么冷路径落在这里(R↓,2026-09-16 所有者裁决)**:`addWake` 显式拒绝 `at ≤ now`
|
|
6433
|
+
* ("时刻必须在未来"——那是"**预约**"语义的地基,不许放宽)⇒ 即时投递**不能**靠一条
|
|
6434
|
+
* `at=now` 的注册表条目实现(那还会让调度器把一次性即时投递当 `pending` **每 tick 重试**)。
|
|
6435
|
+
* 必须直接走 {@link acquireWakeAgent}(live 优先 → `sessionController.resolveAgent` 冷载入)。
|
|
6436
|
+
* 这正对应所有者要的「**会话不活跃则等效于直接 wake**」:**同一条冷载入通路**,只是**不等 tick**。
|
|
6437
|
+
*
|
|
6438
|
+
* ⚠️ **承诺边界(不许含糊)**:回执只到「**已注入 / 已起轮**」(宿主调用未抛错),
|
|
6439
|
+
* **到不了**"目标已执行/已答复" —— 与 `deliverWake` 同一层。
|
|
6440
|
+
* 判"目标真的动了"必须用**文件级判据**(它自己的 SESSION.md),
|
|
6441
|
+
* **不得凭回执结案**(§12.B 的 `delivered ≠ 跑了一轮`)。
|
|
6442
|
+
*
|
|
6443
|
+
* 🔴 **v1.39.2 语义变更(S142 §0r,所有者 2026-09-17 令:「希望 send message 时不排队,
|
|
6444
|
+
* 可以直接作为提示词即时注入进去」)**:
|
|
6445
|
+
* 旧实现一律 `followup`(宿主注释:*Queue an ordinary follow-up turn*)⇒ 目标**正在跑轮次**时
|
|
6446
|
+
* 消息**排在轮次边界**(即旧文案的"不打断当前轮")。现按宿主 `agent.status` 分流:
|
|
6447
|
+
* · `status === 'idle'` ⇒ `followup`(宿主**同步**把 status 置 running ⇒ **立即起轮**,本就不排队)
|
|
6448
|
+
* · 否则(在跑) ⇒ **`steer`** —— **当场注入当前轮**,这才是"不排队"的关键路径
|
|
6449
|
+
* ⚠️ **fail-safe**:`steer` 抛错 ⇒ **退回 `followup`**:**宁可排队,不可静默丢消息**。
|
|
6450
|
+
* ⚠️ **只改 `send-now`**:`send-later`({@link deliverWake})**保持 `followup`** ——
|
|
6451
|
+
* 预约的本职是"到点唤起",且在跑时排队不打断是它被实证验收过的行为,不顺手动它。
|
|
6452
|
+
*
|
|
6453
|
+
* **归属**:本函数放在调度器侧而非工具侧 —— "怎么把一句话送到一条轨迹"是**调度器的知识**,
|
|
6454
|
+
* 工具只该知道"有这个动作"。这样 `trajectory.ts` 完全不碰调度器内部(v1.35.0 硬约束①:
|
|
6455
|
+
* `send-later` 链路**一行为不动**,本次改动 = **纯新增**)。
|
|
6456
|
+
*
|
|
6457
|
+
* @param ctx 插件上下文
|
|
6458
|
+
* @param root CCC 根
|
|
6459
|
+
* @param target `S###` 或 AGENT_SESSIONS 目录名
|
|
6460
|
+
* @param message 递送正文
|
|
6461
|
+
* @param sender 发起者轨迹标识
|
|
6462
|
+
* @returns 投递结果(**同步**返回给调用方)
|
|
6463
|
+
*/
|
|
6464
|
+
async function sendToTrajectory(ctx, root, target, message, sender) {
|
|
6465
|
+
const t = resolveWakeTarget(root, target);
|
|
6466
|
+
if (!t) return {
|
|
6467
|
+
ok: false,
|
|
6468
|
+
detail: `目标 trajectory 未命中(${target})`
|
|
6469
|
+
};
|
|
6470
|
+
const acquired = await acquireWakeAgent(ctx, root, t.dirName);
|
|
6471
|
+
if ("error" in acquired) return {
|
|
6472
|
+
ok: false,
|
|
6473
|
+
detail: acquired.error
|
|
6474
|
+
};
|
|
6475
|
+
const agent = acquired.agent;
|
|
6476
|
+
const make = () => createUserMessage({
|
|
6477
|
+
content: [{
|
|
6478
|
+
type: "text",
|
|
6479
|
+
text: buildSendText(t, message, sender)
|
|
6480
|
+
}],
|
|
6481
|
+
source: PLUGIN_SOURCE
|
|
6482
|
+
});
|
|
6483
|
+
if (agent.status === "idle") {
|
|
6484
|
+
try {
|
|
6485
|
+
agent.followup(make());
|
|
6486
|
+
} catch (err) {
|
|
6487
|
+
return {
|
|
6488
|
+
ok: false,
|
|
6489
|
+
detail: `投递失败(${acquired.how}): ${String(err?.message ?? err)}`
|
|
6490
|
+
};
|
|
6491
|
+
}
|
|
6492
|
+
return {
|
|
6493
|
+
ok: true,
|
|
6494
|
+
detail: `已即时投递 ${t.dirName}(${acquired.how};目标空闲 ⇒ followup 立即起轮)`
|
|
6495
|
+
};
|
|
6496
|
+
}
|
|
6497
|
+
try {
|
|
6498
|
+
agent.steer(make());
|
|
6499
|
+
return {
|
|
6500
|
+
ok: true,
|
|
6501
|
+
detail: `已即时注入 ${t.dirName}(${acquired.how};目标在跑 ⇒ steer 当场注入当前轮)`
|
|
6502
|
+
};
|
|
6503
|
+
} catch (err) {
|
|
6504
|
+
try {
|
|
6505
|
+
agent.followup(make());
|
|
6506
|
+
} catch (err2) {
|
|
6507
|
+
return {
|
|
6508
|
+
ok: false,
|
|
6509
|
+
detail: `投递失败(${acquired.how}): steer 与 followup 均失败: ${String(err?.message ?? err)} / ${String(err2?.message ?? err2)}`
|
|
6510
|
+
};
|
|
6511
|
+
}
|
|
6512
|
+
return {
|
|
6513
|
+
ok: true,
|
|
6514
|
+
detail: `已投递 ${t.dirName}(${acquired.how};steer 不可用 ⇒ 退回 followup): ${String(err?.message ?? err)}`
|
|
6515
|
+
};
|
|
6516
|
+
}
|
|
6517
|
+
}
|
|
6518
|
+
/**
|
|
6519
|
+
* 本 tick 要扫的 CCC 根集合(**并集**)。
|
|
6520
|
+
*
|
|
6521
|
+
* C2(S142 2026-09-15 Q5):原实现是 `collectLiveCccs`——**只认 live 会话** ⇒
|
|
6522
|
+
* "完全冷掉的 CCC 不能自唤醒自己"(已知边界)。现改为 `ccc-roots.listCccs` 的并集枚举
|
|
6523
|
+
* (工作区注册表 ∪ 持久化会话 ∪ live 会话):**没有 live 会话的 CCC 也会被扫到**。
|
|
6524
|
+
*
|
|
6525
|
+
* ⚠️ 边界(本步**只**改"枚举哪些 CCC",**不改**"怎么找到要交付的 agent"):
|
|
6526
|
+
* 把 message 交给某条 trajectory 仍必须靠 **live 会话**解析(见本文件
|
|
6527
|
+
* `acquireWakeAgent`);无 live 会话时该 CCC 只是**被扫到、
|
|
6528
|
+
* 无交付对象** ⇒ `deliverWake` 返回 `无绑定会话记录…`,条目回 `pending` 并留痕。
|
|
6529
|
+
* 因此本函数**不得**让 tick 抛错(异常一律由调用方 tick 兜住,见 `runWakeTick` 的 try)。
|
|
6530
|
+
* @param ctx 插件上下文
|
|
6531
|
+
* @returns CCC 根列表(无任何来源 → `[]`)
|
|
6532
|
+
*/
|
|
6533
|
+
async function collectWakeCccs(ctx) {
|
|
6534
|
+
return (await listCccs(ctx)).map((e) => e.root);
|
|
6535
|
+
}
|
|
6536
|
+
/**
|
|
6537
|
+
* CRO 唤起消息正文(**刻意不复用** {@link buildWakeText} / {@link buildSendText})。
|
|
6538
|
+
*
|
|
6539
|
+
* 三种唤起各有自己的**事实**;混用 = 注入一条假陈述(本容器栽过:`buildSendText` 里
|
|
6540
|
+
* 那句"消息已入队"就是一条假陈述,见该函数注释):
|
|
6541
|
+
* · `buildWakeText` —— 「**到点**了」(登记过的未来时刻,人/agent 算好的)
|
|
6542
|
+
* · `buildSendText` —— 「**有人此刻给我发了一条消息**」
|
|
6543
|
+
* · 本函数 —— 「**我自己的程序**看情况判定该叫我了」(**没有到点、也没有人发消息**)
|
|
6544
|
+
*
|
|
6545
|
+
* 🔴 `reason` 的呈现(设计 §4.2):**程序没写就明说没写**,不留空白 ——
|
|
6546
|
+
* 改成程序判定后,"当时为什么叫了"**不再能从时间表重建**(原因在程序肚子里);
|
|
6547
|
+
* 留空白 = 连"丢在哪一环"都看不出来。写明"未提供"至少把缺口标出来了(CCE:重建 > 保存)。
|
|
6548
|
+
* @param dirName 目标轨迹目录名
|
|
6549
|
+
* @param mdPath 目标 SESSION.md 绝对路径
|
|
6550
|
+
* @param prompt 程序给的唤起提示词
|
|
6551
|
+
* @param reason 程序给的判定理由(可为 null)
|
|
6552
|
+
* @returns 投递正文
|
|
6553
|
+
*/
|
|
6554
|
+
function buildCroWakeText(dirName, mdPath, prompt, reason) {
|
|
6555
|
+
return [
|
|
6556
|
+
`[CRO 唤起] ${dirName} 自带的 continuous-re-occurrence.ts 判定:现在该唤起。`,
|
|
6557
|
+
`当前时间:${localHuman()}(当地时区)。`,
|
|
6558
|
+
"",
|
|
6559
|
+
`身份锚定:继续 ${dirName} 的 trajectory(SESSION.md: ${mdPath})。`,
|
|
6560
|
+
"",
|
|
6561
|
+
`判定理由:${reason ?? "(程序未提供 reason —— 见 CRO 编写指南 §7 坑 3)"}`,
|
|
6562
|
+
"",
|
|
6563
|
+
"唤起提示词:",
|
|
6564
|
+
prompt,
|
|
6565
|
+
"",
|
|
6566
|
+
"说明:这是由**本轨迹自己的 CRO 程序**按当时情况做的判定(**不是**定时唤醒,**也不是**别人发的消息)。处理完照常把进展写入 SESSION.md。"
|
|
6567
|
+
].join("\n");
|
|
6568
|
+
}
|
|
6569
|
+
/**
|
|
6570
|
+
* 投递一条 CRO 唤起(**fire-and-forget**,与 {@link deliverWake} 同层)。
|
|
6571
|
+
*
|
|
6572
|
+
* · **取用通路复用** {@link acquireWakeAgent}(live 优先 → 冷载入):这是调度器侧唯一的
|
|
6573
|
+
* "怎么把一句话送到一条轨迹"的知识 ⇒ 不另立第二条(设计 §9-2 的倾向:复用投递面)。
|
|
6574
|
+
* · 🔴 **不落唤醒表**(设计 §6.3):CRO 是"**持续判定**",不是"**某个未来时刻**"。
|
|
6575
|
+
* 落表会给 §0Q(投递即删、表只保在办)**增加一个新的写入源**。
|
|
6576
|
+
* · 用 `followup`(不用 `steer`):CRO 由**调度器**发起,与 `send-later` 同层;
|
|
6577
|
+
* `steer`(当场注入当前轮)是 `send-now` 的语义 —— 那是人类/agent 的**即时动作**。
|
|
6578
|
+
* · 🔴 **失败不回滚、不重试**(与条目投递不同):下一 tick 会**重新判定**——
|
|
6579
|
+
* CRO 的判据是"情况",不是"这条消息还没送出去" ⇒ 自愈来自**重判**,不来自重投。
|
|
6580
|
+
* @param ctx 插件上下文
|
|
6581
|
+
* @param root CCC 根
|
|
6582
|
+
* @param dirName 目标轨迹目录名(= CRO 只能唤起**自己**,设计 §9-8 的倾向)
|
|
6583
|
+
* @param prompt 唤起提示词
|
|
6584
|
+
* @param reason 判定理由
|
|
6585
|
+
* @returns 投递结果(进 tick 日志;**不写注册表**)
|
|
6586
|
+
*/
|
|
6587
|
+
async function deliverCroWake(ctx, root, dirName, prompt, reason) {
|
|
6588
|
+
const mdPath = join(sessionsRoot(root), dirName, "SESSION.md");
|
|
6589
|
+
const acquired = await acquireWakeAgent(ctx, root, dirName);
|
|
6590
|
+
if ("error" in acquired) return {
|
|
6591
|
+
ok: false,
|
|
6592
|
+
detail: acquired.error,
|
|
6593
|
+
notReady: acquired.notReady === true
|
|
6594
|
+
};
|
|
6595
|
+
try {
|
|
6596
|
+
acquired.agent.followup(createUserMessage({
|
|
6597
|
+
content: [{
|
|
6598
|
+
type: "text",
|
|
6599
|
+
text: buildCroWakeText(dirName, mdPath, prompt, reason)
|
|
6600
|
+
}],
|
|
6601
|
+
source: PLUGIN_SOURCE
|
|
6602
|
+
}));
|
|
6603
|
+
} catch (err) {
|
|
6604
|
+
return {
|
|
6605
|
+
ok: false,
|
|
6606
|
+
detail: `CRO 投递失败(${acquired.how}): ${String(err?.message ?? err)}`
|
|
6607
|
+
};
|
|
6608
|
+
}
|
|
6609
|
+
return {
|
|
6610
|
+
ok: true,
|
|
6611
|
+
detail: `已投递(${acquired.how})`
|
|
6612
|
+
};
|
|
6613
|
+
}
|
|
6614
|
+
/**
|
|
6615
|
+
* CRO 阶段:本 tick 对**每条启用了 CRO 的轨迹**跑一次它自带的程序,按判定投递。
|
|
6616
|
+
*
|
|
6617
|
+
* ## 为什么放在既有投递**之后**(R↓)
|
|
6618
|
+
* · 既有条目(`send-later`)**有到点承诺** ⇒ 优先级天然更高;
|
|
6619
|
+
* · CRO 是"看着情况叫",**晚一拍无成本**(tick 粒度本就是 5min);
|
|
6620
|
+
* · ⇒ 铁律(设计 §5):**CRO 的任何失败都不影响既有链路** —— 整段由调用方兜住,
|
|
6621
|
+
* 且**每条轨迹各自 try/catch**(一条坏程序不得连累同 tick 的其它轨迹)。
|
|
6622
|
+
*
|
|
6623
|
+
* ## 日志口径(刻意与既有投递不同)
|
|
6624
|
+
* "每 tick × 每条轨迹"都写一行 ⇒ 对"**常态是不唤起**"的 CRO 就是刷屏。
|
|
6625
|
+
* ⇒ 逐条只记 **唤起**(`reason` 进日志 —— 设计 §4.2 要求可重建)与 **跳过**(异常,必须看见);
|
|
6626
|
+
* **不唤起只进汇总行**(`唤起 x,不唤起 y,跳过 z`)。
|
|
6627
|
+
* ⚠️ **诚实标注**:因此"某条轨迹当轮**为何没叫**"**不进 tick 日志**(它在程序肚子里)。
|
|
6628
|
+
* 要完整的逐次判定审计,须**程序自己**写状态文件(设计 §3.3)——ACC 给不了。
|
|
6629
|
+
* @param ctx 插件上下文
|
|
6630
|
+
* @param root CCC 根
|
|
6631
|
+
* @param registry 本 tick 已载入的唤醒注册表条目(供快照的 `pendingWakes`;读坏时传 `[]`)
|
|
6632
|
+
* @param log 本 tick 的人读摘要(就地追加)
|
|
6633
|
+
*/
|
|
6634
|
+
async function runCroPhase(ctx, root, registry, log) {
|
|
6635
|
+
if (readSimpleSettings().croEnabled === false) {
|
|
6636
|
+
log.push("CRO 阶段:已关闭(croEnabled=false)——既有唤醒投递不受影响");
|
|
6637
|
+
return;
|
|
6638
|
+
}
|
|
6639
|
+
let dirs;
|
|
6640
|
+
try {
|
|
6641
|
+
dirs = listCroTrajectories(root);
|
|
6642
|
+
} catch {
|
|
6643
|
+
return;
|
|
6644
|
+
}
|
|
6645
|
+
if (dirs.length === 0) return;
|
|
6646
|
+
const nowMs = Date.now();
|
|
6647
|
+
const s = wakeSchedulerState();
|
|
6648
|
+
const scheduler = {
|
|
6649
|
+
armed: s.armed,
|
|
6650
|
+
enabled: s.enabled,
|
|
6651
|
+
ticks: s.ticks,
|
|
6652
|
+
lastSkipReason: s.lastSkipReason
|
|
6653
|
+
};
|
|
6654
|
+
const agents = hostAgents(ctx);
|
|
6655
|
+
let wakes = 0;
|
|
6656
|
+
let quiet = 0;
|
|
6657
|
+
let skips = 0;
|
|
6658
|
+
for (const dirName of dirs) try {
|
|
6659
|
+
const bound = listBoundSessionIds(root, dirName);
|
|
6660
|
+
const running = runningCarriersOf(bound, nowMs);
|
|
6661
|
+
const outcome = await evaluateCro(root, dirName, readCroSnapshotInput(root, dirName, nowMs, {
|
|
6662
|
+
boundSessionIds: bound,
|
|
6663
|
+
liveSessionIds: bound.filter((id) => Boolean(agents?.get?.(id))),
|
|
6664
|
+
runningSessionIds: running,
|
|
6665
|
+
pendingWakes: registry.filter((e) => e.state === "pending" && resolveWakeTarget(root, e.target)?.dirName === dirName).map((e) => ({
|
|
6666
|
+
id: e.id,
|
|
6667
|
+
at: e.at,
|
|
6668
|
+
createdAt: e.createdAt,
|
|
6669
|
+
createdBy: e.createdBy
|
|
6670
|
+
})),
|
|
6671
|
+
scheduler
|
|
6672
|
+
}));
|
|
6673
|
+
if (outcome.status === "wake") {
|
|
6674
|
+
wakes += 1;
|
|
6675
|
+
const res = await deliverCroWake(ctx, root, dirName, outcome.prompt, outcome.decision.reason);
|
|
6676
|
+
log.push(`· ${root}: ${renderCroOutcome(dirName, outcome)} → ${res.ok ? "已投递" : `投递未成功:${res.detail}`}`);
|
|
6677
|
+
if (!res.notReady) {
|
|
6678
|
+
const rec = appendCroWakeLog(root, dirName, {
|
|
6679
|
+
ok: res.ok,
|
|
6680
|
+
detail: res.detail,
|
|
6681
|
+
tick: s.ticks,
|
|
6682
|
+
reason: outcome.decision.reason ?? null,
|
|
6683
|
+
prompt: outcome.prompt
|
|
6684
|
+
}, nowMs);
|
|
6685
|
+
if (!rec.ok) log.push(`· ${root}: ${dirName}: CRO 流水写入失败(已忽略,不影响投递): ${rec.error}`);
|
|
6686
|
+
}
|
|
6687
|
+
} else if (outcome.status === "skipped") {
|
|
6688
|
+
skips += 1;
|
|
6689
|
+
log.push(`· ${root}: ${renderCroOutcome(dirName, outcome)}`);
|
|
6690
|
+
} else if (outcome.status === "no-wake") quiet += 1;
|
|
6691
|
+
} catch (err) {
|
|
6692
|
+
skips += 1;
|
|
6693
|
+
log.push(`· ${root}: ${dirName}: CRO 内部异常(已忽略,不影响既有链路): ${String(err?.message ?? err)}`);
|
|
6694
|
+
}
|
|
6695
|
+
log.push(`· ${root}: CRO 评估 ${dirs.length} 条(唤起 ${wakes},不唤起 ${quiet},跳过 ${skips})`);
|
|
6696
|
+
}
|
|
6697
|
+
/** 一次调度 tick:遍历已知 CCC → 到期项串行投递 / 超窗项置 missed;返回人读摘要 */
|
|
6698
|
+
async function runWakeTick(ctx) {
|
|
6699
|
+
const log = [];
|
|
6700
|
+
const roots = await collectWakeCccs(ctx).catch(() => []);
|
|
6701
|
+
if (roots.length === 0) {
|
|
6702
|
+
clock.noteSkipReason("无已知 CCC 可扫(等某个会话/工作区出现)");
|
|
6703
|
+
return log;
|
|
6704
|
+
}
|
|
6705
|
+
clock.noteSkipReason(null);
|
|
6706
|
+
for (const root of roots) {
|
|
6707
|
+
const purged = purgeFinalizedWakes(root);
|
|
6708
|
+
if (purged.ok && purged.purged > 0) log.push(`· ${root}: 清除 ${purged.purged} 条已结案记录(存量首清)`);
|
|
6709
|
+
const { registry, error } = loadWakeRegistry(root);
|
|
6710
|
+
if (error) {
|
|
6711
|
+
log.push(`✗ ${root}: ${error}`);
|
|
6712
|
+
await runCroPhase(ctx, root, [], log);
|
|
6713
|
+
continue;
|
|
6714
|
+
}
|
|
6715
|
+
const { due, expired } = splitDueWakes(registry, Date.now(), WAKE_CATCH_UP_MS);
|
|
6716
|
+
for (const e of expired) {
|
|
6717
|
+
finalizeWake(root, e.id, "missed", `超过补跑窗口(${WAKE_CATCH_UP_MS / 36e5}h)未投递`);
|
|
6718
|
+
log.push(`· ${root}: ${e.id} → missed(超补跑窗口,已清除)`);
|
|
6719
|
+
}
|
|
6720
|
+
for (const e of due) {
|
|
6721
|
+
const res = await deliverWake(ctx, root, e);
|
|
6722
|
+
if (res.notReady) {
|
|
6723
|
+
log.push(`· ${root}: ${e.id} → 跳过(宿主服务未就绪,下个 tick 再试;不计失败)`);
|
|
6724
|
+
continue;
|
|
6725
|
+
}
|
|
6726
|
+
if (res.ok) {
|
|
6727
|
+
finalizeWake(root, e.id, "delivered", res.detail);
|
|
6728
|
+
log.push(`· ${root}: ${e.id} → delivered(已清除)`);
|
|
6729
|
+
} else {
|
|
6730
|
+
updateWake(root, e.id, {
|
|
6731
|
+
state: "pending",
|
|
6732
|
+
attempts: e.attempts + 1,
|
|
6733
|
+
lastResult: res.detail
|
|
6734
|
+
});
|
|
6735
|
+
log.push(`· ${root}: ${e.id} → 重试(${res.detail})`);
|
|
6736
|
+
}
|
|
6737
|
+
}
|
|
6738
|
+
await runCroPhase(ctx, root, registry.entries, log);
|
|
6739
|
+
}
|
|
6740
|
+
return log;
|
|
6741
|
+
}
|
|
6742
|
+
/**
|
|
6743
|
+
* 装配调度器(index.ts apply 调用):5min tick + 生命周期 disposer +
|
|
6744
|
+
* 会话出现/settings 变化时热启动(与 autopilot 同款触发面,互不干扰)。
|
|
6745
|
+
* @param ctx 插件上下文
|
|
6746
|
+
*/
|
|
6747
|
+
function registerWakeScheduler(ctx) {
|
|
6748
|
+
clockOpts.ctx = ctx;
|
|
6749
|
+
clock.start();
|
|
6750
|
+
registerDisposer(ctx, "trajectory 唤醒调度器", () => clock.dispose());
|
|
6751
|
+
}
|
|
6752
|
+
//#endregion
|
|
4654
6753
|
//#region src/cro-guide.ts
|
|
4655
6754
|
/**
|
|
4656
6755
|
* cro-guide.ts — CRO 编写指南(**ACC 内置**,S142 §7.8,2026-09-19)
|
|
@@ -4928,39 +7027,6 @@ function currentBoundDirName(exec, scope) {
|
|
|
4928
7027
|
return getActiveSessionInfo(scope)?.dirName ?? null;
|
|
4929
7028
|
}
|
|
4930
7029
|
/**
|
|
4931
|
-
* 清洗 + 截断会话概括(需求② S142 用户拍板:编号日期后加 ≤20 字内容概括)。
|
|
4932
|
-
* 规则(服务端统一,不信任 LLM 输入):
|
|
4933
|
-
* - 去控制字符/换行/回车/制表(防标题注入/多行污染)
|
|
4934
|
-
* - trim(去首尾空白)
|
|
4935
|
-
* - 截断 ≤20 字符(按 Unicode 码点——中英混排统一;emoji 等代理对按码点保留)
|
|
4936
|
-
* - 去 `/`(防标题被误读为路径分隔)
|
|
4937
|
-
* @returns 清洗后的概括(空输入 → 空串;调用方决定是否允许空)
|
|
4938
|
-
*/
|
|
4939
|
-
function sanitizeSessionSummary(summary) {
|
|
4940
|
-
return [...summary.replace(/[\u0000-\u001f\u007f]/g, "").replace(/\//g, "").trim()].slice(0, 20).join("");
|
|
4941
|
-
}
|
|
4942
|
-
/**
|
|
4943
|
-
* 从激活会话派生命名标题(v1.22.9 格式修正 + 需求② 概括):
|
|
4944
|
-
* F3 原始需求是 **`S###-日期`**(如 `S143-2026-08-26`)——从 `sessionId` 派生,
|
|
4945
|
-
* 而非完整目录名(`2026-08-24--S142--...` 超长 + 中文,不符合用户拍板格式)。
|
|
4946
|
-
* 需求②(S142 用户拍板):编号日期后加 ≤20 字内容概括 → `S###-YYYY-MM-DD-<概括>`
|
|
4947
|
-
* ——概括来自显式 summary 参数(服务端截断/清洗,可靠不靠猜);编号日期仍固定派生。
|
|
4948
|
-
* 无 S### 编号(issue 会话等)→ 回退目录名。
|
|
4949
|
-
* @param active 激活会话信息(sessionId + dirName)
|
|
4950
|
-
* @param summary 内容概括(≤20 字,服务端清洗截断;空 → 不带概括的 `S###-日期`)
|
|
4951
|
-
* @returns `S143-2026-08-26-概括` / `S143-2026-08-26` / 原目录名
|
|
4952
|
-
*/
|
|
4953
|
-
function namingTitleFor(active, summary) {
|
|
4954
|
-
const sid = active.sessionId;
|
|
4955
|
-
if (typeof sid === "string" && /^S\d+$/.test(sid)) {
|
|
4956
|
-
const date = active.dirName.match(/^(\d{4}-\d{2}-\d{2})--/)?.[1] ?? "";
|
|
4957
|
-
const cleaned = summary ? sanitizeSessionSummary(summary) : "";
|
|
4958
|
-
const base = date ? `${sid}-${date}` : sid;
|
|
4959
|
-
return cleaned ? `${base}-${cleaned}` : base;
|
|
4960
|
-
}
|
|
4961
|
-
return active.dirName;
|
|
4962
|
-
}
|
|
4963
|
-
/**
|
|
4964
7030
|
* use 激活宁静号会话后,把当前 dsh 会话重命名为命名标题(`S###-日期[-概括]`)。
|
|
4965
7031
|
* v1.27.1:**永远开启**(不再有 naming.enabled 门控)——仅 sessionTitle 服务
|
|
4966
7032
|
* 存在性守卫;失败不静默——返回结果对象而非 null(v1.22.9),调用方决定可见性。
|
|
@@ -6928,8 +8994,9 @@ function registerBootstrap(ctx) {
|
|
|
6928
8994
|
* L6 结算)仅在本模块注释与内部文档出现,永不浮现在提示词文本。
|
|
6929
8995
|
*
|
|
6930
8996
|
* 结算(settlement)挂点:D6 用户拍板——若 CCC 有标准 SESSION 复盘仪式,
|
|
6931
|
-
* 结算视图归 trajectory-assistant。当前 close/archive 无复盘摘要
|
|
6932
|
-
*
|
|
8997
|
+
* 结算视图归 trajectory-assistant。当前 close/archive 无复盘摘要 ⇒ 机制延后。
|
|
8998
|
+
* 🔴 2026-09-25(S142 分批清死代码·第 1 批):原先那个 `onSettlement(cb)` **空 seam 已删**
|
|
8999
|
+
* (零调用者、无触发器、空实现)——仪式真落地时按当时的信号重新设计,**不再靠空占位**。
|
|
6933
9000
|
*/
|
|
6934
9001
|
/** 可见提示词前缀用词(跨领域自然词,D8:无游戏黑话) */
|
|
6935
9002
|
const EVENT_LABEL = {
|
|
@@ -8110,13 +10177,13 @@ function registerStatusApi(ctx, opts = {}) {
|
|
|
8110
10177
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
8111
10178
|
const daysParam = Number(url.searchParams.get("olderThanDays") ?? "30");
|
|
8112
10179
|
const days = Number.isFinite(daysParam) && daysParam >= 1 ? daysParam : 30;
|
|
8113
|
-
const sessions = ctx
|
|
10180
|
+
const sessions = hostSessions(ctx);
|
|
8114
10181
|
const liveIds = /* @__PURE__ */ new Set();
|
|
8115
10182
|
if (sessions?.list) for (const s of sessions.list()) {
|
|
8116
10183
|
const id = s.id ?? s.header?.id;
|
|
8117
10184
|
if (id) liveIds.add(id);
|
|
8118
10185
|
}
|
|
8119
|
-
const { sessionsRootDir, collectEligibleSessions, performCleanup, cutoffDaysAgo } = await
|
|
10186
|
+
const { sessionsRootDir, collectEligibleSessions, performCleanup, cutoffDaysAgo } = await Promise.resolve().then(() => session_cleanup_exports);
|
|
8120
10187
|
const root = sessionsRootDir();
|
|
8121
10188
|
if (req.method === "GET") {
|
|
8122
10189
|
const { candidates } = performCleanup(root, cutoffDaysAgo(days), liveIds, { dryRun: true });
|
|
@@ -10125,11 +12192,12 @@ function renameAfterRebuild(ctx, agent, pending) {
|
|
|
10125
12192
|
const mdPath = pending.mdPath;
|
|
10126
12193
|
const dirName = basename(dirname(mdPath));
|
|
10127
12194
|
const idMatch = dirName.match(/--S(\d{3,})--/);
|
|
10128
|
-
const
|
|
12195
|
+
const info = {
|
|
10129
12196
|
sessionId: idMatch ? `S${idMatch[1]}` : dirName.replace(/^\d{4}-\d{2}-\d{2}--/, ""),
|
|
10130
12197
|
dirName,
|
|
10131
12198
|
mdPath
|
|
10132
|
-
}
|
|
12199
|
+
};
|
|
12200
|
+
const title = namingTitleFor(info, pending.summary);
|
|
10133
12201
|
const titles = hostService(ctx, "sessionTitle");
|
|
10134
12202
|
if (!titles || typeof titles.rename !== "function") {
|
|
10135
12203
|
console.warn("[serenity-hooks] rebuild 后重命名跳过: sessionTitle 服务不可用");
|