@shgroup/dsh-serenity-hooks 1.88.0 → 1.89.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dsh.plugin.json +1 -1
- package/lib/cro/log.d.ts +16 -0
- package/lib/index.js +722 -634
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -8631,7 +8631,7 @@ function formatCroLogLine(entry) {
|
|
|
8631
8631
|
const head = oneline(entry.promptDigest.head);
|
|
8632
8632
|
return [
|
|
8633
8633
|
oneline(entry.at),
|
|
8634
|
-
entry.ok ? "ok" : entry.notReady === true ? "not-ready" : "fail",
|
|
8634
|
+
entry.ok ? "ok" : entry.notReady === true ? "not-ready" : entry.noWake === true ? "no-wake" : "fail",
|
|
8635
8635
|
`tick=${entry.tick}`,
|
|
8636
8636
|
oneline(entry.detail),
|
|
8637
8637
|
`reason=${reason}`,
|
|
@@ -8652,7 +8652,7 @@ function parseCroLogLine(line) {
|
|
|
8652
8652
|
const at = parts[0];
|
|
8653
8653
|
if (Number.isNaN(Date.parse(at))) return null;
|
|
8654
8654
|
const mark = parts[1];
|
|
8655
|
-
if (mark !== "ok" && mark !== "fail" && mark !== "not-ready") return null;
|
|
8655
|
+
if (mark !== "ok" && mark !== "fail" && mark !== "not-ready" && mark !== "no-wake") return null;
|
|
8656
8656
|
const tickM = /^tick=(\d+)$/.exec(parts[2]);
|
|
8657
8657
|
if (!tickM) return null;
|
|
8658
8658
|
const reasonRaw = parts[4].startsWith("reason=") ? parts[4].slice(7) : "";
|
|
@@ -8661,6 +8661,7 @@ function parseCroLogLine(line) {
|
|
|
8661
8661
|
at,
|
|
8662
8662
|
ok: mark === "ok",
|
|
8663
8663
|
...mark === "not-ready" ? { notReady: true } : {},
|
|
8664
|
+
...mark === "no-wake" ? { noWake: true } : {},
|
|
8664
8665
|
detail: parts[3],
|
|
8665
8666
|
tick: Number(tickM[1]),
|
|
8666
8667
|
reason: reasonRaw === "" ? null : reasonRaw,
|
|
@@ -8820,6 +8821,7 @@ function appendCroWakeLog(root, dirName, input, nowMs = Date.now()) {
|
|
|
8820
8821
|
at: isoLocal(nowMs),
|
|
8821
8822
|
ok: input.ok,
|
|
8822
8823
|
...input.notReady === true ? { notReady: true } : {},
|
|
8824
|
+
...input.noWake === true ? { noWake: true } : {},
|
|
8823
8825
|
detail: input.detail,
|
|
8824
8826
|
tick: input.tick,
|
|
8825
8827
|
reason: input.reason,
|
|
@@ -8848,112 +8850,509 @@ function appendCroWakeLog(root, dirName, input, nowMs = Date.now()) {
|
|
|
8848
8850
|
}
|
|
8849
8851
|
}
|
|
8850
8852
|
//#endregion
|
|
8851
|
-
//#region src/
|
|
8853
|
+
//#region src/trajectory/interrupted.ts
|
|
8852
8854
|
/**
|
|
8853
|
-
*
|
|
8855
|
+
* 中断轮次发现(`interrupted` 会话扫描)
|
|
8854
8856
|
*
|
|
8855
|
-
*
|
|
8856
|
-
* · 唤醒调度器(`wake/scheduler.ts`,一次性唤醒,D58)—— **现存**
|
|
8857
|
-
* · autopilot 时钟(`autopilot-trajectory.ts`,周期自唤醒,D59)—— 🔴 **已随 v1.35.0 整段退场**
|
|
8858
|
-
* 二者的**业务判据完全不同**(谁来扫 / 扫什么 / 投递给谁 / 按什么周期),但**引擎骨架逐字同形**
|
|
8859
|
-
* ——进程态 + 快照 + 复位 + `startTimer` + tick 外壳 + 事件接线 + disposer + `timer.unref()`。
|
|
8860
|
-
* 本条把**引擎**抽到本工厂,**各钟仍各持自己的定时器与自己的串行链**(现存的那一钟 = 唤醒调度器)。
|
|
8857
|
+
* ## 这个模块解决什么
|
|
8861
8858
|
*
|
|
8862
|
-
*
|
|
8863
|
-
*
|
|
8859
|
+
* DSH 的会话日志里,**进程在某一轮完成前退出**(被 kill/崩溃/关机超时)时,
|
|
8860
|
+
* 宿主重放时会为该轮补上收尾事件:
|
|
8864
8861
|
*
|
|
8865
|
-
*
|
|
8866
|
-
*
|
|
8867
|
-
*
|
|
8868
|
-
*
|
|
8869
|
-
*
|
|
8862
|
+
* ```jsonl
|
|
8863
|
+
* turn/end {"turn":66,"reason":{"kind":"interrupted"}}
|
|
8864
|
+
* step/end {"turn":66,"step":85}
|
|
8865
|
+
* session/end-seed {}
|
|
8866
|
+
* ```
|
|
8870
8867
|
*
|
|
8871
|
-
*
|
|
8868
|
+
* 宿主**只补收尾,不续跑** —— 那一轮的工作就此停在那里。本模块负责把这些会话找出来,
|
|
8869
|
+
* 交给上层(认知轨迹)决定要不要续。
|
|
8872
8870
|
*
|
|
8873
|
-
*
|
|
8874
|
-
* 必为空,且**"恢复旧会话"不触发 `session/created`**(只有新建会)⇒ 启动瞬间判定落空 =
|
|
8875
|
-
* **时钟永久不武装**(实测 6.6h 零 tick)。⇒ 现在「**全局闸开即武装**」,"有无目标"降级为
|
|
8876
|
-
* tick 内的廉价判定。**武装与"本次有无目标"是两件事,本工厂不得把二者耦合。**
|
|
8871
|
+
* ## 判据(三条同时成立,缺一不可)
|
|
8877
8872
|
*
|
|
8878
|
-
*
|
|
8873
|
+
* 1. 日志里**最后一条 `turn/end`** 的 `reason.kind === 'interrupted'`;
|
|
8874
|
+
* 2. 该 `turn/end` **之后没有新的 `turn/start`**(否则已被后续轮次接续);
|
|
8875
|
+
* 3. ⛔ **不要求**末条是 `session/end-seed`(2026-10-23 放宽,原因见 {@link detectInterruptedEnd})。
|
|
8879
8876
|
*
|
|
8880
|
-
*
|
|
8881
|
-
*
|
|
8882
|
-
* 整段退场**一并消失 ⇒ `gate` **缺省即常态,不是特例**。**唤醒调度器已无闸**
|
|
8883
|
-
* (原 `wakeSchedulerEnabled` 于 2026-09-21 所有者令砍掉 ⇒ 调用方**不传** `gate`
|
|
8884
|
-
* = 恒武装、永不因闸跳过、`enabled` 恒 `true`)。工厂取 `gate` **回调**(每次现读),
|
|
8885
|
-
* **不做**任何合并或缓存;**缺省无闸是合法形态,不是漏配**。
|
|
8877
|
+
* ⚠️ **为什么不能只看"存在过 interrupted"**:一个长会话历史上可能中断几十次
|
|
8878
|
+
* (本容器实测单会话 33 次)。只看"曾经中断"会把早已恢复的会话全部当成待续 ⇒ 灾难。
|
|
8886
8879
|
*
|
|
8887
|
-
*
|
|
8888
|
-
*
|
|
8880
|
+
* 🔴 **为什么原判据要求"末条 = end-seed"是错的**(实测 26 条里漏 2 条):
|
|
8881
|
+
* 重启后宿主会在 end-seed **之后**追加 `agent/inbox/spliced`(把待办消息重新塞进收件箱)
|
|
8882
|
+
* ⇒ "末条必须 end-seed"**在恢复动作发生之后就恒不成立**,
|
|
8883
|
+
* 而"被恢复过又再次被打断"恰恰是守护最该覆盖的形态。
|
|
8889
8884
|
*
|
|
8890
|
-
*
|
|
8891
|
-
* |----|-----------|-----------|---------|
|
|
8892
|
-
* | `chain` | 有(全局串行) | 有(全局串行) | **总是私有**(硬约束一) |
|
|
8893
|
-
* | `lastTickLog` | 有(逐行摘要) | **无** | `logFrom` 不给 = 不记 |
|
|
8894
|
-
* | `ticks` 记账 | 工厂在 body **完成后**记 | **本钟**在同步段记 | `bodyCountsTick` + `begin` |
|
|
8895
|
-
* | body 返回类型 | `string[]`(人读摘要) | `void` | 泛型 `T` |
|
|
8896
|
-
* | 启动日志 | 固定串 | 含启用 CCC 计数 | `startLog: () => string` |
|
|
8897
|
-
* | body 异常兜底 | 留痕 + `console.warn` | 同 | 工厂统一(**吞异常不改语义**) |
|
|
8885
|
+
* ## 为什么不整文件解压
|
|
8898
8886
|
*
|
|
8899
|
-
*
|
|
8900
|
-
*
|
|
8901
|
-
*
|
|
8902
|
-
*
|
|
8903
|
-
* `surface/observation.ts:containerClocks()` 与面板都在读它们(见该文件 :225–250 的设计红线)。
|
|
8887
|
+
* DSH 日志是 **zstd 多帧追加**(实测单会话 24705 帧 / 47 MB)。原生
|
|
8888
|
+
* `zlib.zstdDecompressSync` 只解**第一帧**,对多帧直接报 `Unknown frame descriptor`。
|
|
8889
|
+
* ⇒ 采用**尾部窗口 + 倒序逐帧解压**:只读文件末尾 N 字节,从后往前找帧魔数,
|
|
8890
|
+
* 解到凑够所需事件数即停。实测 8h 窗口 6 个会话 **20 ms**。
|
|
8904
8891
|
*
|
|
8905
|
-
*
|
|
8906
|
-
*
|
|
8907
|
-
*
|
|
8892
|
+
* ## 世代识别复用 `cleanup.ts`
|
|
8893
|
+
*
|
|
8894
|
+
* 日志文件名有多个世代(`session.jsonl` / `session.v3.jsonl.zstd` / `session.v4.jsonl.zstd`…),
|
|
8895
|
+
* 宿主按世代写且**迁移保留源文件**。⇒ 本模块**不硬编码文件名**,走
|
|
8896
|
+
* {@link findSessionLog}(与清理/体积统计同一判据),避免"新世代对本模块不可见"。
|
|
8897
|
+
*
|
|
8898
|
+
* ## 边界
|
|
8899
|
+
*
|
|
8900
|
+
* - **只读**:不改任何会话日志。
|
|
8901
|
+
* - **不判周期**:何时扫、扫完做什么归调用方(CCC 的认知轨迹)。本模块只回答
|
|
8902
|
+
* 「此刻哪些会话以 interrupted 结尾」。
|
|
8908
8903
|
*/
|
|
8909
8904
|
/**
|
|
8910
|
-
*
|
|
8905
|
+
* zstd 解压(**运行时有、类型声明没有**)。
|
|
8911
8906
|
*
|
|
8912
|
-
*
|
|
8913
|
-
*
|
|
8914
|
-
*
|
|
8907
|
+
* 🔴 为什么不能用 `import { zstdDecompressSync } from 'node:zlib'`:
|
|
8908
|
+
* 本仓 `@types/node@^20` 的声明里**没有** zstd 系列(Node 22.15+ 才进声明)。
|
|
8909
|
+
* 而运行时本机 v22.22.1 **确实有**(实测 `typeof require('zlib').zstdDecompressSync === 'function'`)。
|
|
8910
|
+
* ⇒ 用命名空间导入 + 局部断言,并**在运行时校验**;缺失时走明确降级(返回空,不静默瞎猜)。
|
|
8911
|
+
*/
|
|
8912
|
+
const zstdDecompressSync = nodeZlib.zstdDecompressSync;
|
|
8913
|
+
/** zstd 帧魔数(小端 `28 B5 2F FD`) */
|
|
8914
|
+
const ZSTD_MAGIC = Buffer.from([
|
|
8915
|
+
40,
|
|
8916
|
+
181,
|
|
8917
|
+
47,
|
|
8918
|
+
253
|
|
8919
|
+
]);
|
|
8920
|
+
/** 默认回看窗口:8 小时(owner 2026-10-22 定) */
|
|
8921
|
+
const DEFAULT_LOOKBACK_MS = 288e5;
|
|
8922
|
+
/** 每次从文件尾部读取的字节数(够覆盖数百个末尾事件) */
|
|
8923
|
+
const TAIL_WINDOW_BYTES = 1 << 20;
|
|
8924
|
+
/** 需要凑够的末尾事件数(够跨过 turn/end + step/end + end-seed 即可,留余量) */
|
|
8925
|
+
const TAIL_EVENT_TARGET = 60;
|
|
8926
|
+
/**
|
|
8927
|
+
* 从文件尾部倒序解压,凑够 `want` 个事件。
|
|
8915
8928
|
*
|
|
8916
|
-
*
|
|
8917
|
-
*
|
|
8929
|
+
* 多帧 zstd 必须**按帧边界**切:从后往前找魔数,每帧用下一帧起始位置当结束边界。
|
|
8930
|
+
* 任何一帧解压失败就停下(返回已拿到的)—— 再往前更可能不完整。
|
|
8918
8931
|
*/
|
|
8919
|
-
|
|
8932
|
+
function readTailEvents(logPath, want) {
|
|
8933
|
+
if (zstdDecompressSync === void 0) return [];
|
|
8934
|
+
const st = statSync(logPath);
|
|
8935
|
+
const windowBytes = Math.min(TAIL_WINDOW_BYTES, st.size);
|
|
8936
|
+
if (windowBytes < 8) return [];
|
|
8937
|
+
const buf = Buffer.alloc(windowBytes);
|
|
8938
|
+
const fd = openSync(logPath, "r");
|
|
8939
|
+
try {
|
|
8940
|
+
readSync(fd, buf, 0, windowBytes, st.size - windowBytes);
|
|
8941
|
+
} finally {
|
|
8942
|
+
closeSync(fd);
|
|
8943
|
+
}
|
|
8944
|
+
const offsets = [];
|
|
8945
|
+
for (let i = 0; i <= windowBytes - ZSTD_MAGIC.length; i++) if (buf[i] === ZSTD_MAGIC[0] && buf[i + 1] === ZSTD_MAGIC[1] && buf[i + 2] === ZSTD_MAGIC[2] && buf[i + 3] === ZSTD_MAGIC[3]) offsets.push(i);
|
|
8946
|
+
if (offsets.length === 0) return [];
|
|
8947
|
+
const events = [];
|
|
8948
|
+
for (let k = offsets.length - 1; k >= 0 && events.length < want; k--) {
|
|
8949
|
+
const start = offsets[k];
|
|
8950
|
+
const end = k + 1 < offsets.length ? offsets[k + 1] : windowBytes;
|
|
8951
|
+
let text;
|
|
8952
|
+
try {
|
|
8953
|
+
text = zstdDecompressSync(buf.subarray(start, end)).toString("utf8");
|
|
8954
|
+
} catch {
|
|
8955
|
+
break;
|
|
8956
|
+
}
|
|
8957
|
+
const batch = [];
|
|
8958
|
+
for (const line of text.split("\n")) {
|
|
8959
|
+
if (!line.trim()) continue;
|
|
8960
|
+
try {
|
|
8961
|
+
batch.push(JSON.parse(line));
|
|
8962
|
+
} catch {}
|
|
8963
|
+
}
|
|
8964
|
+
events.unshift(...batch);
|
|
8965
|
+
}
|
|
8966
|
+
return events.slice(-want);
|
|
8967
|
+
}
|
|
8920
8968
|
/**
|
|
8921
|
-
*
|
|
8969
|
+
* 判定一组尾部事件是否「以 interrupted 结尾」。
|
|
8970
|
+
* 不命中 → null。
|
|
8922
8971
|
*
|
|
8923
|
-
*
|
|
8924
|
-
*
|
|
8925
|
-
*
|
|
8926
|
-
*
|
|
8927
|
-
*
|
|
8928
|
-
*
|
|
8972
|
+
* ## 🔴 2026-10-23 判据放宽(owner 实测:「不是挂在工具调用上的,也要能被 interrupted 筛选到并唤醒」)
|
|
8973
|
+
*
|
|
8974
|
+
* ### 原判据(v1.87.4 起)为什么漏
|
|
8975
|
+
* 原来只认 **末条事件 = `session/end-seed`**。实测(本机 3337 条会话全扫):
|
|
8976
|
+
* · 末轮 `turn/end[interrupted]` 的会话 **26** 条;
|
|
8977
|
+
* · 其中满足原判据的只有 **24** 条 ⇒ **漏 2 条**,形态逐字为:
|
|
8978
|
+
* `… turn/end[interrupted] -> session/end-seed -> agent/inbox/spliced -> agent/inbox/spliced`
|
|
8979
|
+
* ⇒ 重启后宿主把这**两条**追加在 end-seed 之后(它们是"恢复时把待办消息重新塞进收件箱"),
|
|
8980
|
+
* 于是"末条必须 end-seed"这条**在恢复动作发生之后就恒不成立**。
|
|
8981
|
+
* ⚠️ 讽刺的是:**正是那些被恢复过、又再次被打断的会话**最容易长成这样
|
|
8982
|
+
* ⇒ 原判据恰好对"反复中断"的会话失明,而它们才是最需要守护的。
|
|
8983
|
+
*
|
|
8984
|
+
* ### 新判据(三条,缺一不可)
|
|
8985
|
+
* 1. 日志里**最后一条 `turn/end`** 的 `reason.kind === 'interrupted'`;
|
|
8986
|
+
* 2. 该 `turn/end` **之后没有新的 `turn/start`**(否则说明它已被后续轮次接续);
|
|
8987
|
+
* 3. ⛔ **不再要求末条是 `end-seed`**。
|
|
8988
|
+
*
|
|
8989
|
+
* ### 为什么不改成"末条必须是某个白名单"
|
|
8990
|
+
* 那就是原缺陷换个白名单再犯一次(`agent/inbox/spliced` 之外还会有别的)。
|
|
8991
|
+
* **判据要问的是"最后一轮的结束原因是什么、之后有没有新轮开始"**,
|
|
8992
|
+
* 而不是"日志最后一个 token 长什么样"——后者是**载体形态**,前者是**状态**。
|
|
8993
|
+
*
|
|
8994
|
+
* ### 为什么不会误报"曾经中断但已恢复"
|
|
8995
|
+
* 恢复一定伴随 `turn/start`(新一轮)或新的 `turn/end`(`completed` 等)⇒ 判据 1/2 各自挡住。
|
|
8996
|
+
* 实测该放宽**不新增误报**:原 24 条全含于新判据(`curOnly = 0`),新增 2 条全是真漏。
|
|
8929
8997
|
*/
|
|
8930
|
-
function
|
|
8931
|
-
|
|
8932
|
-
|
|
8933
|
-
for (
|
|
8934
|
-
|
|
8935
|
-
|
|
8998
|
+
function detectInterruptedEnd(events) {
|
|
8999
|
+
if (events.length === 0) return null;
|
|
9000
|
+
let idx = -1;
|
|
9001
|
+
for (let i = events.length - 1; i >= 0; i--) if (events[i]?.type === "turn/end") {
|
|
9002
|
+
idx = i;
|
|
9003
|
+
break;
|
|
9004
|
+
}
|
|
9005
|
+
if (idx < 0) return null;
|
|
9006
|
+
const turnEnd = events[idx];
|
|
9007
|
+
if (turnEnd?.data?.reason?.kind !== "interrupted") return null;
|
|
9008
|
+
for (let i = idx + 1; i < events.length; i++) if (events[i]?.type === "turn/start") return null;
|
|
9009
|
+
const turn = typeof turnEnd.data?.turn === "number" ? turnEnd.data.turn : null;
|
|
9010
|
+
return {
|
|
9011
|
+
turn,
|
|
9012
|
+
pendingTools: findPendingTools(events, turn)
|
|
9013
|
+
};
|
|
8936
9014
|
}
|
|
8937
9015
|
/**
|
|
8938
|
-
*
|
|
9016
|
+
* 找出末轮里发起了但没有结果的工具调用名。
|
|
8939
9017
|
*
|
|
8940
|
-
*
|
|
8941
|
-
*
|
|
8942
|
-
* @param opts 见 {@link ClockOptions}(每项对应一条既有的语义差)
|
|
8943
|
-
* @returns 该钟的 `{start, dispose, snapshot, reset}`
|
|
9018
|
+
* ⚠️ **尽力而为**,非权威:尾部窗口可能截断更早的调用。上层应把它当
|
|
9019
|
+
* "**可能**未完成的副作用"清单,而不是"确定未完成"。
|
|
8944
9020
|
*/
|
|
8945
|
-
function
|
|
8946
|
-
|
|
8947
|
-
|
|
8948
|
-
|
|
8949
|
-
|
|
8950
|
-
|
|
8951
|
-
|
|
9021
|
+
function findPendingTools(events, turn) {
|
|
9022
|
+
if (turn === null) return [];
|
|
9023
|
+
const called = /* @__PURE__ */ new Map();
|
|
9024
|
+
const resolved = /* @__PURE__ */ new Set();
|
|
9025
|
+
for (const e of events) {
|
|
9026
|
+
const data = e.data;
|
|
9027
|
+
if (!data || data.turn !== turn) continue;
|
|
9028
|
+
if (e.type === "tool/call" && typeof data.callId === "string" && typeof data.name === "string") called.set(data.callId, data.name);
|
|
9029
|
+
else if (e.type === "tool/result" && typeof data.callId === "string") resolved.add(data.callId);
|
|
9030
|
+
}
|
|
9031
|
+
return [...called].filter(([callId]) => !resolved.has(callId)).map(([, name]) => name);
|
|
9032
|
+
}
|
|
9033
|
+
/**
|
|
9034
|
+
* 扫描一个 **project 目录**(形如 `<sessions-root>/--home-yh-home-home-serenity--/`)。
|
|
9035
|
+
*
|
|
9036
|
+
* @param projectDir DSH 项目目录(其下每个子目录是一个会话)
|
|
9037
|
+
* @param options.lookbackMs 回看窗口(缺省 8h)
|
|
9038
|
+
* @param options.now 注入"当前时刻"(测试用)
|
|
9039
|
+
* @param options.liveIds 需要跳过的会话 id(正在跑的会话不该被判为待续)
|
|
9040
|
+
*/
|
|
9041
|
+
function scanInterruptedSessions(projectDir, options = {}) {
|
|
9042
|
+
const t0 = Date.now();
|
|
9043
|
+
const lookbackMs = options.lookbackMs ?? 288e5;
|
|
9044
|
+
const cutoff = (options.now ?? Date.now()) - lookbackMs;
|
|
9045
|
+
const liveIds = options.liveIds ?? /* @__PURE__ */ new Set();
|
|
9046
|
+
const result = {
|
|
9047
|
+
sessions: [],
|
|
9048
|
+
scanned: 0,
|
|
9049
|
+
skipped: 0,
|
|
9050
|
+
errors: [],
|
|
9051
|
+
elapsedMs: 0
|
|
8952
9052
|
};
|
|
8953
|
-
if (
|
|
8954
|
-
|
|
8955
|
-
|
|
8956
|
-
|
|
9053
|
+
if (!existsSync(projectDir)) {
|
|
9054
|
+
result.elapsedMs = Date.now() - t0;
|
|
9055
|
+
return result;
|
|
9056
|
+
}
|
|
9057
|
+
let entries;
|
|
9058
|
+
try {
|
|
9059
|
+
entries = readdirSync(projectDir);
|
|
9060
|
+
} catch (err) {
|
|
9061
|
+
result.errors.push({
|
|
9062
|
+
sessionId: "*",
|
|
9063
|
+
message: err instanceof Error ? err.message : String(err)
|
|
9064
|
+
});
|
|
9065
|
+
result.elapsedMs = Date.now() - t0;
|
|
9066
|
+
return result;
|
|
9067
|
+
}
|
|
9068
|
+
for (const sessionId of entries) {
|
|
9069
|
+
if (liveIds.has(sessionId)) continue;
|
|
9070
|
+
const dirPath = join(projectDir, sessionId);
|
|
9071
|
+
const logPath = findSessionLog(dirPath);
|
|
9072
|
+
if (logPath === null) continue;
|
|
9073
|
+
let mtimeMs;
|
|
9074
|
+
try {
|
|
9075
|
+
mtimeMs = statSync(logPath).mtimeMs;
|
|
9076
|
+
} catch (err) {
|
|
9077
|
+
result.errors.push({
|
|
9078
|
+
sessionId,
|
|
9079
|
+
message: err instanceof Error ? err.message : String(err)
|
|
9080
|
+
});
|
|
9081
|
+
continue;
|
|
9082
|
+
}
|
|
9083
|
+
if (mtimeMs < cutoff) continue;
|
|
9084
|
+
result.scanned++;
|
|
9085
|
+
try {
|
|
9086
|
+
const events = readTailEvents(logPath, TAIL_EVENT_TARGET);
|
|
9087
|
+
const hit = detectInterruptedEnd(events);
|
|
9088
|
+
if (!hit) {
|
|
9089
|
+
result.skipped++;
|
|
9090
|
+
continue;
|
|
9091
|
+
}
|
|
9092
|
+
const tailTime = events[events.length - 1]?.time;
|
|
9093
|
+
result.sessions.push({
|
|
9094
|
+
sessionId,
|
|
9095
|
+
logPath,
|
|
9096
|
+
turn: hit.turn,
|
|
9097
|
+
lastEventAt: typeof tailTime === "number" ? tailTime : null,
|
|
9098
|
+
mtimeMs,
|
|
9099
|
+
pendingTools: hit.pendingTools
|
|
9100
|
+
});
|
|
9101
|
+
} catch (err) {
|
|
9102
|
+
result.errors.push({
|
|
9103
|
+
sessionId,
|
|
9104
|
+
message: err instanceof Error ? err.message : String(err)
|
|
9105
|
+
});
|
|
9106
|
+
}
|
|
9107
|
+
}
|
|
9108
|
+
result.sessions.sort((a, b) => (b.lastEventAt ?? b.mtimeMs) - (a.lastEventAt ?? a.mtimeMs));
|
|
9109
|
+
result.elapsedMs = Date.now() - t0;
|
|
9110
|
+
return result;
|
|
9111
|
+
}
|
|
9112
|
+
/**
|
|
9113
|
+
* 由 CCC 根推导它的 DSH 项目目录名(`--home-yh-home-home-serenity--`)。
|
|
9114
|
+
*
|
|
9115
|
+
* 规则取自宿主:把绝对路径的 `/` 换成 `-`,并在前后各加 `--`。
|
|
9116
|
+
* ⚠️ 这是**推导**,不是真相源 —— 实际目录名以磁盘为准;调用方应在不存在时
|
|
9117
|
+
* 回退到"扫整个 sessions root"或直接报未找到。
|
|
9118
|
+
*/
|
|
9119
|
+
function projectDirNameForCwd(cwd) {
|
|
9120
|
+
return `--${cwd.replace(/\/+$/, "").split("/").filter(Boolean).join("-")}--`;
|
|
9121
|
+
}
|
|
9122
|
+
/**
|
|
9123
|
+
* 续跑正文(**简单、稳定、不含版本号**)。
|
|
9124
|
+
*
|
|
9125
|
+
* 判据(为什么这么短):本动作的用途是**把被打断的会话推回工作状态**,
|
|
9126
|
+
* 不是替它重述任务 —— 被打断那一轮的上下文就在它自己的日志里。
|
|
9127
|
+
* ⇒ 正文只做三件事:① 说明发生了什么;② 提示**先核对副作用**;③ 让它接着做。
|
|
9128
|
+
*
|
|
9129
|
+
* 🔴 **为什么要写"先核对副作用"**:中断时可能有「有 call 无 result」的工具调用,
|
|
9130
|
+
* 它们的副作用**未知**(可能已写文件 / 已发消息)。若模型把那一轮整段重放,
|
|
9131
|
+
* 会**重复执行**。这句话是给模型的护栏,不是客套。
|
|
9132
|
+
*/
|
|
9133
|
+
function buildContinueText(sessionId, turn, pendingTools) {
|
|
9134
|
+
const lines = [];
|
|
9135
|
+
lines.push("[中断续跑] 上一条进程在本轮完成前退出,该轮已被中止。现在继续。");
|
|
9136
|
+
lines.push("");
|
|
9137
|
+
lines.push(`会话: ${sessionId}${turn === null ? "" : ` / 被中断的轮次: turn ${turn}`}`);
|
|
9138
|
+
if (pendingTools.length > 0) {
|
|
9139
|
+
lines.push("");
|
|
9140
|
+
lines.push(`⚠️ 中断时这些工具「有调用、无结果」(副作用未知,**可能已经执行**): ${pendingTools.join(", ")}`);
|
|
9141
|
+
lines.push(" ⇒ 先核对它们是否已生效(文件是否已写、消息是否已发),再决定要不要重做。");
|
|
9142
|
+
lines.push(" ⛔ 不要把被中断的那一轮整段重放。");
|
|
9143
|
+
}
|
|
9144
|
+
lines.push("");
|
|
9145
|
+
lines.push("请接着完成该轮未完成的工作;完成后把进展写入本轨迹的 SESSION.md。");
|
|
9146
|
+
return lines.join("\n");
|
|
9147
|
+
}
|
|
9148
|
+
/**
|
|
9149
|
+
* 把一个 agent 唤醒(注入一条续跑消息)。
|
|
9150
|
+
*
|
|
9151
|
+
* 两条路径(与 `wake/scheduler.ts` 的 `sendToCognitiveTrajectory` 同源判据):
|
|
9152
|
+
* - **live**:`agents.get(id)` 拿得到 ⇒ 目标在跑(或被加载)⇒ `steer` 当场注入当前轮;
|
|
9153
|
+
* `steer` 不可用则退回 `followup`(宁可排队,不可静默丢)。
|
|
9154
|
+
* - **cold-resume**:`sessionController.resolveAgent(id)` 冷载入后同样注入。
|
|
9155
|
+
*
|
|
9156
|
+
* ⚠️ 冷会话载入**会真起一个 agent**(消耗真实算力)⇒ 调用方必须自己决定要不要,
|
|
9157
|
+
* 本函数不做数量限制(那是策略,归调用者)。
|
|
9158
|
+
*/
|
|
9159
|
+
function createResumeOne(deps) {
|
|
9160
|
+
return async (hit) => {
|
|
9161
|
+
const { sessionId } = hit;
|
|
9162
|
+
const text = buildContinueText(sessionId, hit.turn, hit.pendingTools);
|
|
9163
|
+
let agent;
|
|
9164
|
+
let how;
|
|
9165
|
+
const live = deps.agents?.get?.(sessionId);
|
|
9166
|
+
if (live) {
|
|
9167
|
+
agent = live;
|
|
9168
|
+
how = "live";
|
|
9169
|
+
} else {
|
|
9170
|
+
const sc = deps.sessionController;
|
|
9171
|
+
const resolve = sc?.resolveAgent;
|
|
9172
|
+
if (typeof resolve !== "function" || sc === void 0) return {
|
|
9173
|
+
sessionId,
|
|
9174
|
+
ok: false,
|
|
9175
|
+
error: "宿主 sessionController 不可用(重启启动窗口?)—— 稍后重试"
|
|
9176
|
+
};
|
|
9177
|
+
try {
|
|
9178
|
+
const out = await resolve.call(sc, sessionId);
|
|
9179
|
+
if (!out?.agent) return {
|
|
9180
|
+
sessionId,
|
|
9181
|
+
ok: false,
|
|
9182
|
+
error: `冷载入未返回 agent: ${String(out?.error ?? "未知原因")}`
|
|
9183
|
+
};
|
|
9184
|
+
agent = out.agent;
|
|
9185
|
+
how = "cold-resume";
|
|
9186
|
+
} catch (err) {
|
|
9187
|
+
return {
|
|
9188
|
+
sessionId,
|
|
9189
|
+
ok: false,
|
|
9190
|
+
error: `冷载入失败: ${err instanceof Error ? err.message : String(err)}`
|
|
9191
|
+
};
|
|
9192
|
+
}
|
|
9193
|
+
}
|
|
9194
|
+
const a = agent;
|
|
9195
|
+
try {
|
|
9196
|
+
if (typeof a.steer === "function" && a.status === "running") {
|
|
9197
|
+
a.steer(deps.makeMessage(text));
|
|
9198
|
+
return {
|
|
9199
|
+
sessionId,
|
|
9200
|
+
ok: true,
|
|
9201
|
+
how: `${how}+steer`
|
|
9202
|
+
};
|
|
9203
|
+
}
|
|
9204
|
+
if (typeof a.followup === "function") {
|
|
9205
|
+
a.followup(deps.makeMessage(text));
|
|
9206
|
+
return {
|
|
9207
|
+
sessionId,
|
|
9208
|
+
ok: true,
|
|
9209
|
+
how: `${how}+followup`
|
|
9210
|
+
};
|
|
9211
|
+
}
|
|
9212
|
+
if (typeof a.steer === "function") {
|
|
9213
|
+
a.steer(deps.makeMessage(text));
|
|
9214
|
+
return {
|
|
9215
|
+
sessionId,
|
|
9216
|
+
ok: true,
|
|
9217
|
+
how: `${how}+steer`
|
|
9218
|
+
};
|
|
9219
|
+
}
|
|
9220
|
+
return {
|
|
9221
|
+
sessionId,
|
|
9222
|
+
ok: false,
|
|
9223
|
+
how,
|
|
9224
|
+
error: "agent 既无 steer 也无 followup(宿主契约漂移)"
|
|
9225
|
+
};
|
|
9226
|
+
} catch (err) {
|
|
9227
|
+
if (typeof a.followup === "function") try {
|
|
9228
|
+
a.followup(deps.makeMessage(text));
|
|
9229
|
+
return {
|
|
9230
|
+
sessionId,
|
|
9231
|
+
ok: true,
|
|
9232
|
+
how: `${how}+followup(steer 失败后退回)`
|
|
9233
|
+
};
|
|
9234
|
+
} catch (err2) {
|
|
9235
|
+
return {
|
|
9236
|
+
sessionId,
|
|
9237
|
+
ok: false,
|
|
9238
|
+
error: `steer 与 followup 均失败: ${String(err2?.message ?? err2)}`
|
|
9239
|
+
};
|
|
9240
|
+
}
|
|
9241
|
+
return {
|
|
9242
|
+
sessionId,
|
|
9243
|
+
ok: false,
|
|
9244
|
+
error: `注入失败: ${err instanceof Error ? err.message : String(err)}`
|
|
9245
|
+
};
|
|
9246
|
+
}
|
|
9247
|
+
};
|
|
9248
|
+
}
|
|
9249
|
+
//#endregion
|
|
9250
|
+
//#region src/wake/clock.ts
|
|
9251
|
+
/**
|
|
9252
|
+
* wake/clock.ts — **时钟运行时工厂**(S142 §38 复审候选 C6b,路径 **P2**)
|
|
9253
|
+
*
|
|
9254
|
+
* 🔴 2026-09-25(S142 分批清死代码·第 1 批)**更正**:本文件当初是为**两条**独立 5min 时钟抽出的公共引擎:
|
|
9255
|
+
* · 唤醒调度器(`wake/scheduler.ts`,一次性唤醒,D58)—— **现存**
|
|
9256
|
+
* · autopilot 时钟(`autopilot-trajectory.ts`,周期自唤醒,D59)—— 🔴 **已随 v1.35.0 整段退场**
|
|
9257
|
+
* 二者的**业务判据完全不同**(谁来扫 / 扫什么 / 投递给谁 / 按什么周期),但**引擎骨架逐字同形**
|
|
9258
|
+
* ——进程态 + 快照 + 复位 + `startTimer` + tick 外壳 + 事件接线 + disposer + `timer.unref()`。
|
|
9259
|
+
* 本条把**引擎**抽到本工厂,**各钟仍各持自己的定时器与自己的串行链**(现存的那一钟 = 唤醒调度器)。
|
|
9260
|
+
*
|
|
9261
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
9262
|
+
* 🔴 硬约束一:**两条时钟绝不可共用"排队执行"的链**(这是 P2 与 P1 的分界判据)
|
|
9263
|
+
*
|
|
9264
|
+
* autopilot 每轮要跑该 CCC 的**偏见脚本**(`fetchBiasContent`,超时 60s),可能阻塞数十秒;
|
|
9265
|
+
* 若与唤醒调度器共用一条串行链,会把另一条一起**卡住**(一次性唤醒被无关的偏见脚本拖住)。
|
|
9266
|
+
* ⇒ 本工厂把 `chain` 作为**每实例私有状态**:`createClock()` 调用一次 = 一条独立的串行域。
|
|
9267
|
+
* 实例之间**零共享**(无模块级单例、无共享 Promise、无共享 map)。
|
|
9268
|
+
* ⚠️ 若日后有人把 `chain` 提到模块级"省一次分配",**两条时钟的隔离即刻失效**——不许。
|
|
9269
|
+
*
|
|
9270
|
+
* 🔴 硬约束二:**武装门只判全局闸**(`gate()`),**不判"本次有无目标"**
|
|
9271
|
+
*
|
|
9272
|
+
* 2026-09-14 F 段实测缺陷:旧实现把"有 live CCC"当**武装前置条件**,而宿主刚重启时 live 会话
|
|
9273
|
+
* 必为空,且**"恢复旧会话"不触发 `session/created`**(只有新建会)⇒ 启动瞬间判定落空 =
|
|
9274
|
+
* **时钟永久不武装**(实测 6.6h 零 tick)。⇒ 现在「**全局闸开即武装**」,"有无目标"降级为
|
|
9275
|
+
* tick 内的廉价判定。**武装与"本次有无目标"是两件事,本工厂不得把二者耦合。**
|
|
9276
|
+
*
|
|
9277
|
+
* 🔴 硬约束三:**闸是可选能力,工厂不得替调用方假设"必须有闸"**(v1.34 S-1 解耦 → 2026-09-21 收窄)
|
|
9278
|
+
*
|
|
9279
|
+
* 🔴 2026-09-25(S142 分批清死代码·第 1 批)**更正:当前零条闸在用** —— 现存唯一那一钟
|
|
9280
|
+
* (唤醒调度器)本已无闸,而"曾只余的那一条" `autopilotWakeEnabled` 已随 **v1.35.0 autopilot
|
|
9281
|
+
* 整段退场**一并消失 ⇒ `gate` **缺省即常态,不是特例**。**唤醒调度器已无闸**
|
|
9282
|
+
* (原 `wakeSchedulerEnabled` 于 2026-09-21 所有者令砍掉 ⇒ 调用方**不传** `gate`
|
|
9283
|
+
* = 恒武装、永不因闸跳过、`enabled` 恒 `true`)。工厂取 `gate` **回调**(每次现读),
|
|
9284
|
+
* **不做**任何合并或缓存;**缺省无闸是合法形态,不是漏配**。
|
|
9285
|
+
*
|
|
9286
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
9287
|
+
* 两条时钟**逐项语义差**(`ClockOptions` 的每个可配项都对应下表一行,不是随意的灵活性):
|
|
9288
|
+
*
|
|
9289
|
+
* | 项 | 唤醒调度器 | autopilot | 工厂表达 |
|
|
9290
|
+
* |----|-----------|-----------|---------|
|
|
9291
|
+
* | `chain` | 有(全局串行) | 有(全局串行) | **总是私有**(硬约束一) |
|
|
9292
|
+
* | `lastTickLog` | 有(逐行摘要) | **无** | `logFrom` 不给 = 不记 |
|
|
9293
|
+
* | `ticks` 记账 | 工厂在 body **完成后**记 | **本钟**在同步段记 | `bodyCountsTick` + `begin` |
|
|
9294
|
+
* | body 返回类型 | `string[]`(人读摘要) | `void` | 泛型 `T` |
|
|
9295
|
+
* | 启动日志 | 固定串 | 含启用 CCC 计数 | `startLog: () => string` |
|
|
9296
|
+
* | body 异常兜底 | 留痕 + `console.warn` | 同 | 工厂统一(**吞异常不改语义**) |
|
|
9297
|
+
*
|
|
9298
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
9299
|
+
* **本工厂是"加法式"重构**(P2 的性质):不新增任何判定逻辑,只把同形骨架收成一份代码;
|
|
9300
|
+
* 两个模块仍各自导出 `wakeSchedulerState()` / `autopilotClockState()` 与各自的
|
|
9301
|
+
* `__reset…ForTest()`,**对外可观测面(字段名与语义)零变化**——`acc-diag` ①b 段、
|
|
9302
|
+
* `surface/observation.ts:containerClocks()` 与面板都在读它们(见该文件 :225–250 的设计红线)。
|
|
9303
|
+
*
|
|
9304
|
+
* 为什么**不**取 P1(真合并成一个 `setInterval` + 两条策略)——2026-09-15 所有者裁决:
|
|
9305
|
+
* P1 只多省 ~20 行,却要把 `armed` 拆成"宿主 + 每策略"两层、并强行分开串行域,
|
|
9306
|
+
* 即**动两条正在跑的时钟的骨架**;**不值当**(裁决原文见 SESSION §12.8②)。
|
|
9307
|
+
*/
|
|
9308
|
+
/**
|
|
9309
|
+
* tick 周期(**5 分钟**)。
|
|
9310
|
+
*
|
|
9311
|
+
* 2026-09-15 S142「ACC 侧 autopilot 退场」:本值原从 `autopilot-core.ts` 导入(`TICK_MS`),
|
|
9312
|
+
* 但该模块随 autopilot 退场,而**本模块的唯一消费者是唤醒调度器**(`wake-scheduler`)——
|
|
9313
|
+
* 值本身与 autopilot 无关 ⇒ 随消费者归位到本模块(不再依赖 autopilot 判据层)。
|
|
9314
|
+
*
|
|
9315
|
+
* ⚠️ 语义不得变:该值同时是**报告文案的来源**("等下个 Nmin tick"),历史上曾因文案写死
|
|
9316
|
+
* 10min 与实现 5min 不符而误导排查(§12.7 文案纠错)。
|
|
9317
|
+
*/
|
|
9318
|
+
const TICK_MS = 3e5;
|
|
9319
|
+
/**
|
|
9320
|
+
* 挂事件 → 触发 `start()`(**每个事件一次 try/catch 吞异常**:事件通道缺失不阻断时钟)。
|
|
9321
|
+
*
|
|
9322
|
+
* 为什么吞异常:宿主(headless profile 等)可能没有事件通道;**启动时已 `start()` 过一次**,
|
|
9323
|
+
* 事件只是"跟上面板开关 / 新会话"的热启动补丁,缺它不该让整个装配抛错。
|
|
9324
|
+
* @param on 宿主的 `on`(可能不存在)
|
|
9325
|
+
* @param ctx `on` 的 `this`(宿主 API 常要求绑定)
|
|
9326
|
+
* @param clock 目标时钟
|
|
9327
|
+
* @param events 事件名列表(回调恒为 `() => clock.start()`)
|
|
9328
|
+
*/
|
|
9329
|
+
function attachClockEvents(ctx, clock, events) {
|
|
9330
|
+
const on = ctx?.on;
|
|
9331
|
+
if (typeof on !== "function") return;
|
|
9332
|
+
for (const name of events) try {
|
|
9333
|
+
on.call(ctx, name, () => clock.start());
|
|
9334
|
+
} catch {}
|
|
9335
|
+
}
|
|
9336
|
+
/**
|
|
9337
|
+
* 建一条时钟(**每钟各调一次**——本工厂**不是**单例,调用次数 = 时钟条数)。
|
|
9338
|
+
*
|
|
9339
|
+
* 顺序忠实于既有实现:`start()` 先武装并立即跑一次 tick → 再挂事件。
|
|
9340
|
+
* 拆卸由调用方经 `registerDisposer(ctx, label, clock.dispose)` 落到宿主的 `ctx.effect`。
|
|
9341
|
+
* @param opts 见 {@link ClockOptions}(每项对应一条既有的语义差)
|
|
9342
|
+
* @returns 该钟的 `{start, dispose, snapshot, reset}`
|
|
9343
|
+
*/
|
|
9344
|
+
function createClock(opts) {
|
|
9345
|
+
const runtime = {
|
|
9346
|
+
armed: false,
|
|
9347
|
+
armedAt: null,
|
|
9348
|
+
lastTickAt: null,
|
|
9349
|
+
ticks: 0,
|
|
9350
|
+
lastSkipReason: null
|
|
9351
|
+
};
|
|
9352
|
+
if (opts.logFrom) runtime.lastTickLog = [];
|
|
9353
|
+
const st = {
|
|
9354
|
+
timer: null,
|
|
9355
|
+
chain: Promise.resolve(),
|
|
8957
9356
|
runtime,
|
|
8958
9357
|
wired: false,
|
|
8959
9358
|
opts
|
|
@@ -9826,7 +10225,32 @@ async function runCroPhase(ctx, root, registry, log) {
|
|
|
9826
10225
|
} else if (outcome.status === "skipped") {
|
|
9827
10226
|
skips += 1;
|
|
9828
10227
|
log.push(`· ${root}: ${renderCroOutcome(dirName, outcome)}`);
|
|
9829
|
-
} else if (outcome.status === "no-wake")
|
|
10228
|
+
} else if (outcome.status === "no-wake") {
|
|
10229
|
+
quiet += 1;
|
|
10230
|
+
/**
|
|
10231
|
+
* 🔴 **2026-10-23(A102):`no-wake` 也落盘。**
|
|
10232
|
+
*
|
|
10233
|
+
* 修前这里**只累加计数**、一个字都不写该轨迹的流水 ⇒ 流水**永久停在上一次真唤起处**。
|
|
10234
|
+
* 实测后果(guard 轨迹,真机):它的 `cro-wake-log.log` 停在 `01:40:55`,
|
|
10235
|
+
* owner 据此认为"守卫从那以后就没工作过"——而真相是它**此后每个 tick 都在被评估**,
|
|
10236
|
+
* 只是判定为"不打扰"。⇒ **「我判了、只是不叫」与「我根本没被评估」在读数上完全一样。**
|
|
10237
|
+
*
|
|
10238
|
+
* 判据同 D209:**可以不出声地判"不打扰",但"判过"本身要留痕**。
|
|
10239
|
+
* 四态:`ok` / `fail` / `not-ready` / `no-wake`。
|
|
10240
|
+
*
|
|
10241
|
+
* ⚠️ 与 `wake` 分支同一条硬约束:**写流水失败绝不影响投递与既有链路**(设计 §5 铁律)。
|
|
10242
|
+
*/
|
|
10243
|
+
{
|
|
10244
|
+
const rec = appendCroWakeLog(root, dirName, {
|
|
10245
|
+
ok: false,
|
|
10246
|
+
noWake: true,
|
|
10247
|
+
detail: outcome.detail,
|
|
10248
|
+
tick: s.ticks,
|
|
10249
|
+
reason: outcome.decision.reason ?? null
|
|
10250
|
+
}, nowMs);
|
|
10251
|
+
if (!rec.ok) log.push(`· ${root}: ${dirName}: CRO 流水写入失败(已忽略,不影响既有链路): ${rec.error}`);
|
|
10252
|
+
}
|
|
10253
|
+
}
|
|
9830
10254
|
} catch (err) {
|
|
9831
10255
|
skips += 1;
|
|
9832
10256
|
log.push(`· ${root}: ${dirName}: CRO 内部异常(已忽略,不影响既有链路): ${String(err?.message ?? err)}`);
|
|
@@ -9890,10 +10314,71 @@ async function runWakeTick(ctx) {
|
|
|
9890
10314
|
}
|
|
9891
10315
|
}
|
|
9892
10316
|
await runCroPhase(ctx, root, registry.entries, log);
|
|
10317
|
+
await runInterruptedSafetyNet(ctx, root, log);
|
|
9893
10318
|
}
|
|
9894
10319
|
return log;
|
|
9895
10320
|
}
|
|
9896
10321
|
/**
|
|
10322
|
+
* 🔴🔴 **A104-C′(2026-10-23,owner 点头):容器级「被重启打断的会话」兜底 —— 每 tick 扫一次并注入。**
|
|
10323
|
+
*
|
|
10324
|
+
* ## 为什么必须做(不是"多一层保险",是**已有实测盲窗**)
|
|
10325
|
+
*
|
|
10326
|
+
* 2026-10-23 实测:`08:02` 一次 `restart-web` 打断了一个会话,此后 **13 分钟无人拉起**。
|
|
10327
|
+
* 用真回放(把日志截到争议时刻 + 重压 zstd 帧 + 同一份实现)验证:
|
|
10328
|
+
* - 截到 `08:07:30`/`08:12:40` ⇒ **两次都能判出该会话 `turn/end[interrupted]` 且其后无新 `turn/start`**
|
|
10329
|
+
* - 而那两个 tick **实际都没有叫** ⇒ **判据没错,是"没有被跑起来"**
|
|
10330
|
+
*
|
|
10331
|
+
* ⇒ 结论:**「守护程序自己修好」≠「一定有人拉起」**。任何单条 CRO 轨迹都可能因为
|
|
10332
|
+
* 自身在跑、绑定缺失、阈值保护、程序 bug 而漏掉 —— 而被打断的会话**不会自己恢复**。
|
|
10333
|
+
* ⇒ 兜底必须放在**容器自己**身上,且必须**自己完成"发现 + 注入"**(只扫不动没有用)。
|
|
10334
|
+
*
|
|
10335
|
+
* ## 边界(写在这里免得后人扩大它)
|
|
10336
|
+
*
|
|
10337
|
+
* - ⚠️ **这会真的起 agent、真烧算力** —— owner 已被告知并点头(2026-10-23)。
|
|
10338
|
+
* - 🔴 **必须排除 live 会话**:正在跑的会话不该被再注入一次(否则重复投递)。
|
|
10339
|
+
* - 🔴 **与 CRO 并存、不替代**:CRO 是**轨迹自己的策略**(何时该叫由它判),
|
|
10340
|
+
* 本兜底只覆盖「**无论谁都没叫**」这一格。两者重复命中时,靠 `live` 排除天然去重。
|
|
10341
|
+
* - ⚠️ **本函数任何失败都不得连累既有链路**(设计 §5 铁律):整体 try/catch 吞掉并留一行日志。
|
|
10342
|
+
*/
|
|
10343
|
+
async function runInterruptedSafetyNet(ctx, root, log) {
|
|
10344
|
+
try {
|
|
10345
|
+
const agents = hostAgents(ctx);
|
|
10346
|
+
const sc = hostService(ctx, "sessionController");
|
|
10347
|
+
const liveIds = /* @__PURE__ */ new Set();
|
|
10348
|
+
if (typeof agents?.get === "function") for (const id of collectLiveSessionIdsForSafetyNet(ctx)) liveIds.add(id);
|
|
10349
|
+
const scan = scanInterruptedSessions(`${sessionsRootDir()}/${projectDirNameForCwd(root)}`, { liveIds });
|
|
10350
|
+
if (scan.sessions.length === 0) return;
|
|
10351
|
+
const resumeOne = createResumeOne({
|
|
10352
|
+
agents,
|
|
10353
|
+
sessionController: sc,
|
|
10354
|
+
makeMessage: (text) => createUserMessage({
|
|
10355
|
+
content: [{
|
|
10356
|
+
type: "text",
|
|
10357
|
+
text
|
|
10358
|
+
}],
|
|
10359
|
+
source: PLUGIN_SOURCE
|
|
10360
|
+
})
|
|
10361
|
+
});
|
|
10362
|
+
for (const hit of scan.sessions) try {
|
|
10363
|
+
const out = await resumeOne(hit);
|
|
10364
|
+
log.push(`· ${root}: 中断兜底 ${hit.sessionId}(turn ${hit.turn ?? "?"})→ ` + (out.ok ? `已注入(${out.how ?? "?"})` : `未注入:${out.error ?? "未知"}`));
|
|
10365
|
+
} catch (err) {
|
|
10366
|
+
log.push(`· ${root}: 中断兜底 ${hit.sessionId} 注入抛错(已忽略):${String(err?.message ?? err)}`);
|
|
10367
|
+
}
|
|
10368
|
+
} catch (err) {
|
|
10369
|
+
log.push(`· ${root}: 中断兜底整体失败(已忽略,不影响既有链路):${String(err?.message ?? err)}`);
|
|
10370
|
+
}
|
|
10371
|
+
}
|
|
10372
|
+
/** 取容器内**全部** live 会话 id(best-effort,取不到就少排除几个)。 */
|
|
10373
|
+
function collectLiveSessionIdsForSafetyNet(ctx) {
|
|
10374
|
+
const out = [];
|
|
10375
|
+
try {
|
|
10376
|
+
const list = hostSessions(ctx)?.list?.() ?? [];
|
|
10377
|
+
for (const s of list) if (typeof s?.id === "string" && s.id) out.push(s.id);
|
|
10378
|
+
} catch {}
|
|
10379
|
+
return out;
|
|
10380
|
+
}
|
|
10381
|
+
/**
|
|
9897
10382
|
* 装配调度器(index.ts apply 调用):5min tick + 生命周期 disposer +
|
|
9898
10383
|
* 会话出现/settings 变化时热启动(与 autopilot 同款触发面,互不干扰)。
|
|
9899
10384
|
* @param ctx 插件上下文
|
|
@@ -10130,592 +10615,195 @@ ${renderCroSampleSnapshot()}
|
|
|
10130
10615
|
· \`scheduling.pendingWakes\` 非空(**已有待办唤醒**)
|
|
10131
10616
|
· 无 S 前缀格式的情形请自己造 \`dirName\`(\`identity.code\` 会是空串——那是**正常**的,
|
|
10132
10617
|
编号不是固定格式,硬锚永远是 \`dirName\`)
|
|
10133
|
-
|
|
10134
|
-
### 8.4 自测该断言什么(建议清单)
|
|
10135
|
-
· 快照缺字段(\`null\` 体积)时不崩 —— 用 \`?? 0\` 之类兜底
|
|
10136
|
-
· \`wake:true\` 时 \`prompt\` **确实非空**
|
|
10137
|
-
· "已在干活"分支:\`runningSessionIds\` 非空 ⇒ \`wake:false\`
|
|
10138
|
-
· 同一个程序**连跑两次**给同样输入 ⇒ 同样输出(除非你刻意用了随机/外部状态,
|
|
10139
|
-
那就必须写进 \`reason\`)
|
|
10140
|
-
|
|
10141
|
-
## 9. 一句话
|
|
10142
|
-
**吃一份快照,吐一行决策。先自测,绿了再改名。**
|
|
10143
|
-
`;
|
|
10144
|
-
//#endregion
|
|
10145
|
-
//#region src/trajectory/resident.ts
|
|
10146
|
-
/**
|
|
10147
|
-
* trajectory/resident.ts — **常驻名单**(D173 的兑现口,`cognitive_trajectory resident` 的实现)
|
|
10148
|
-
*
|
|
10149
|
-
* ## 这条能力的判据(owner 2026-10-20 原话,逐字)
|
|
10150
|
-
*
|
|
10151
|
-
* > 「常驻的意思就是**自己声明为自己欢迎别人来麻烦自己**,和永不休眠没啥关系,
|
|
10152
|
-
* > **trajectory never ends 悖论的一种实现**」
|
|
10153
|
-
*
|
|
10154
|
-
* ⇒ **常驻不是资源状态**。它不是"永不休眠"、不是"常驻内存"、不是"不许销毁我的会话"。
|
|
10155
|
-
* 一条轨迹**永远存在**(没有生命周期),常驻与否都不改变这一点。
|
|
10156
|
-
*
|
|
10157
|
-
* ⇒ **它管的是「被找到」,不是「不被销毁」。**
|
|
10158
|
-
* 两件事混为一谈,就会去动调度器的载体生命周期 —— 那正是本轨反复踩的"名字撒谎"。
|
|
10159
|
-
*
|
|
10160
|
-
* ## 因此本模块的三条硬边界
|
|
10161
|
-
*
|
|
10162
|
-
* 1. **纯读**:不写任何文件、不碰注册表、不碰 `wake/scheduler.ts`。
|
|
10163
|
-
* 2. **不进系统提示词**(owner 裁决 Q2:说明类信息按需取,不占每轮 token)。
|
|
10164
|
-
* 3. **只报写了 `serves` 的轨迹** —— 只写半个(有 `resident` 没 `serves`)等于
|
|
10165
|
-
* "把自己挂上去却不说能干什么",按手册的判据那比不写更坏,所以**不列出来**,
|
|
10166
|
-
* 但要**单独数出**来,让写的人知道自己的写法没生效。
|
|
10167
|
-
*
|
|
10168
|
-
* ## 归属(D23)
|
|
10169
|
-
* 机制在 ACC(怎么找、按什么顺序、列什么字段);内容在 CCC(`serves` 那句话写什么)。
|
|
10170
|
-
*/
|
|
10171
|
-
/**
|
|
10172
|
-
* 列出全部**可被找到**的轨迹(按编码排序,确定性)。
|
|
10173
|
-
*
|
|
10174
|
-
* ⚠️ 读盘失败 / 坏 JSON 一律**跳过该条**(不抛):一份坏的声明不该让整张名单拿不到。
|
|
10175
|
-
*
|
|
10176
|
-
* @param root CCC 根
|
|
10177
|
-
*/
|
|
10178
|
-
function collectResident(root) {
|
|
10179
|
-
const entries = [];
|
|
10180
|
-
const incomplete = [];
|
|
10181
|
-
let names = [];
|
|
10182
|
-
try {
|
|
10183
|
-
names = readdirSync(sessionsRoot(root), { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith("_") && !e.name.startsWith(".")).map((e) => e.name);
|
|
10184
|
-
} catch {
|
|
10185
|
-
return {
|
|
10186
|
-
entries,
|
|
10187
|
-
incomplete
|
|
10188
|
-
};
|
|
10189
|
-
}
|
|
10190
|
-
for (const dirName of names) {
|
|
10191
|
-
let profile;
|
|
10192
|
-
try {
|
|
10193
|
-
profile = readTrajectoryConstraintAt(join(sessionsRoot(root), dirName), root);
|
|
10194
|
-
} catch {
|
|
10195
|
-
continue;
|
|
10196
|
-
}
|
|
10197
|
-
if (!profile || profile.resident === void 0) continue;
|
|
10198
|
-
const serves = profile.resident.serves;
|
|
10199
|
-
if (serves === void 0 || serves === "") {
|
|
10200
|
-
incomplete.push(profile.code);
|
|
10201
|
-
continue;
|
|
10202
|
-
}
|
|
10203
|
-
entries.push({
|
|
10204
|
-
code: profile.code,
|
|
10205
|
-
dirName: profile.dirName,
|
|
10206
|
-
serves,
|
|
10207
|
-
...profile.model !== void 0 ? { model: profile.model } : {}
|
|
10208
|
-
});
|
|
10209
|
-
}
|
|
10210
|
-
entries.sort((a, b) => a.code < b.code ? -1 : a.code > b.code ? 1 : 0);
|
|
10211
|
-
incomplete.sort();
|
|
10212
|
-
return {
|
|
10213
|
-
entries,
|
|
10214
|
-
incomplete
|
|
10215
|
-
};
|
|
10216
|
-
}
|
|
10217
|
-
/**
|
|
10218
|
-
* 渲染成给模型读的文本(`cognitive_trajectory resident` 的返回值)。
|
|
10219
|
-
*
|
|
10220
|
-
* 🔴 **空名单也是有效结果**,且必须说清"这意味着什么" —— 否则读者会以为工具坏了。
|
|
10221
|
-
*/
|
|
10222
|
-
function listResident(root) {
|
|
10223
|
-
const { entries, incomplete } = collectResident(root);
|
|
10224
|
-
const lines = ["═══ 常驻轨迹(自己声明「欢迎别人来麻烦我」)═══", ""];
|
|
10225
|
-
if (entries.length === 0) {
|
|
10226
|
-
lines.push(" (本 CCC 暂无轨迹声明常驻)");
|
|
10227
|
-
lines.push(" ⇒ 这不是错误:常驻是**自愿的自我声明**,没写就是\"不参与被找\"。");
|
|
10228
|
-
} else for (const e of entries) {
|
|
10229
|
-
const model = e.model !== void 0 ? ` [model: ${e.model}]` : "";
|
|
10230
|
-
lines.push(` · ${e.code}${model}`);
|
|
10231
|
-
lines.push(` ${e.serves}`);
|
|
10232
|
-
}
|
|
10233
|
-
if (incomplete.length > 0) {
|
|
10234
|
-
lines.push("");
|
|
10235
|
-
lines.push(` ⚠️ 写了 resident 但**没写 serves**(不会被列出):${incomplete.join("、")}`);
|
|
10236
|
-
lines.push(" 只写半个 = 把自己挂上去却不说能干什么 —— 别人会来问,然后发现问错了人。");
|
|
10237
|
-
}
|
|
10238
|
-
lines.push("");
|
|
10239
|
-
lines.push("── 怎么让自己出现在这里 ──");
|
|
10240
|
-
lines.push(" 在你这条轨迹目录的 trajectory.json 里写:");
|
|
10241
|
-
lines.push(" { \"resident\": { \"serves\": \"一句话说清我擅长什么、能帮上什么忙\" } }");
|
|
10242
|
-
lines.push(" ⚠️ 只写这一个字段**不影响任何默认行为**(D183);其余 key 全部可选。");
|
|
10243
|
-
return lines.join("\n");
|
|
10244
|
-
}
|
|
10245
|
-
//#endregion
|
|
10246
|
-
//#region src/trajectory/live.ts
|
|
10247
|
-
/**
|
|
10248
|
-
* trajectory/live.ts — 进程内 live 会话取数 + 精简诊断
|
|
10249
|
-
*
|
|
10250
|
-
* ── 为什么单独成模块(R↓,2026-09-15 S142「ACC 侧 autopilot 退场」S1 搬迁)──
|
|
10251
|
-
* 这两个函数原先住在 `autopilot-trajectory.ts`,但它们的**主语不是 autopilot**:
|
|
10252
|
-
* `listLiveSessions` 服务 `acc-diag` ① 段的"**哪个会话属于哪个 CCC**"(会话维度,
|
|
10253
|
-
* 不是 CCC 维度),而"**独立进程看不到运行时**"正是 `acc-diag` 存在的理由。
|
|
10254
|
-
* autopilot 退场后它们必须活下去 ⇒ 独立成模块,不再挂着已死机制的名字。
|
|
10255
|
-
*
|
|
10256
|
-
* ── 迁移边界(不搬什么,以及为什么)──────────────────────────────────────
|
|
10257
|
-
* 原 `diagLive` 还含两段,**均不搬**(其主语是 autopilot,随机制退场):
|
|
10258
|
-
* · `autopilotCccs`(逐 CCC 目标命中 / agent 定位诊断)—— 依赖 `readAutopilotSettings` /
|
|
10259
|
-
* `getAutopilotStatus` / `resolveTargetAgent`
|
|
10260
|
-
* · `panelResolved`(无参面板请求落到哪个 CCC)—— 面板区块随机制退场
|
|
10261
|
-
* 留下的 = **进程 cwd/CCC + live 会话清单**(`acc-diag` ① 段仍需要)。
|
|
10262
|
-
*
|
|
10263
|
-
* 判据纪律:本模块**不得**反向依赖 autopilot 判据层(那会让退场半途而废)。
|
|
10264
|
-
*
|
|
10265
|
-
* ── 🔴 2026-09-25 I2 收敛(约束文档 §2.7 违反清单,L1 违反之一)────────────────
|
|
10266
|
-
* **本模块的 `ctx` 参数类型从 `Context` 改为 `unknown`** —— 消除本文件唯一的 `W2` 接触
|
|
10267
|
-
* (直接 import 宿主 `cordis` 的类型)。
|
|
10268
|
-
*
|
|
10269
|
-
* **为什么这是"真解耦"而不是"把类型藏起来"(判据):** `ctx` 在本模块**从不被解引用** ——
|
|
10270
|
-
* 它只被**原样透传**给 `hostSessions(ctx)`,而后者(`host/access.ts`,**L0**)的参数
|
|
10271
|
-
* 本就声明为 `ctx: unknown` ⇒ `Context` 这个 import **在语义上是多余的**,
|
|
10272
|
-
* 它没有参与任何类型检查,只贡献了一处 L1→宿主 的接触点。
|
|
10273
|
-
* 🔵 **证据**:grep 本文件 `ctx` 全部 5 处命中 —— 3 处在参数/调用位、2 处在注释,
|
|
10274
|
-
* **零处**属性访问(`ctx.xxx`)。
|
|
10275
|
-
*/
|
|
10276
|
-
/**
|
|
10277
|
-
* 从 dsh 会话 log 读取标题(latest-wins `session/title` 事件)——
|
|
10278
|
-
* **标题不在 `sessions.list()` 条目上**(wire/对象均无 title 字段;F3 命名经
|
|
10279
|
-
* `sessionTitle.rename` 写进 session log),必须从 events 提取(rebuild 同款读取模式)。
|
|
10280
|
-
*/
|
|
10281
|
-
function readSessionTitle(session) {
|
|
10282
|
-
try {
|
|
10283
|
-
const events = sessionEvents(session);
|
|
10284
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
10285
|
-
const e = events[i];
|
|
10286
|
-
if (e?.type === "session/title" && typeof e.data?.title === "string" && e.data.title.trim() !== "") return e.data.title.trim();
|
|
10287
|
-
}
|
|
10288
|
-
} catch {}
|
|
10289
|
-
return null;
|
|
10290
|
-
}
|
|
10291
|
-
/**
|
|
10292
|
-
* 逐条 live 会话(`cccRoot` = 该会话 cwd 的 CCC 归属,无 → null)。
|
|
10293
|
-
*
|
|
10294
|
-
* CCC 枚举("本机有哪些 CCC")**不在这里**,归 `ccc-roots.listCccs`(并集语义);
|
|
10295
|
-
* 本函数保留的是它**独有**的那部分:会话维度 + cwd 归属 + 标题 + id。
|
|
10296
|
-
*
|
|
10297
|
-
* @param ctx 插件上下文(**只透传**给 L0 取数口,本模块不解引用 ⇒ 类型为 `unknown`)
|
|
10298
|
-
* @returns 逐条 live 会话
|
|
10299
|
-
*/
|
|
10300
|
-
function listLiveSessions(ctx) {
|
|
10301
|
-
const out = [];
|
|
10302
|
-
try {
|
|
10303
|
-
const sessions = hostSessions(ctx);
|
|
10304
|
-
for (const s of sessions?.list?.() ?? []) {
|
|
10305
|
-
const cwd = s?.header?.cwd ?? null;
|
|
10306
|
-
out.push({
|
|
10307
|
-
id: s?.id ?? "",
|
|
10308
|
-
cwd,
|
|
10309
|
-
cccRoot: cwd ? cccRootForCwd(cwd) : null,
|
|
10310
|
-
title: readSessionTitle(s)
|
|
10311
|
-
});
|
|
10312
|
-
}
|
|
10313
|
-
} catch {}
|
|
10314
|
-
return out;
|
|
10315
|
-
}
|
|
10316
|
-
function diagLive(ctx) {
|
|
10317
|
-
const processCwd = process.cwd();
|
|
10318
|
-
return {
|
|
10319
|
-
processCwd,
|
|
10320
|
-
processCcc: cccRootForCwd(processCwd),
|
|
10321
|
-
liveSessions: listLiveSessions(ctx)
|
|
10322
|
-
};
|
|
10323
|
-
}
|
|
10618
|
+
|
|
10619
|
+
### 8.4 自测该断言什么(建议清单)
|
|
10620
|
+
· 快照缺字段(\`null\` 体积)时不崩 —— 用 \`?? 0\` 之类兜底
|
|
10621
|
+
· \`wake:true\` 时 \`prompt\` **确实非空**
|
|
10622
|
+
· "已在干活"分支:\`runningSessionIds\` 非空 ⇒ \`wake:false\`
|
|
10623
|
+
· 同一个程序**连跑两次**给同样输入 ⇒ 同样输出(除非你刻意用了随机/外部状态,
|
|
10624
|
+
那就必须写进 \`reason\`)
|
|
10625
|
+
|
|
10626
|
+
## 9. 一句话
|
|
10627
|
+
**吃一份快照,吐一行决策。先自测,绿了再改名。**
|
|
10628
|
+
`;
|
|
10324
10629
|
//#endregion
|
|
10325
|
-
//#region src/trajectory/
|
|
10326
|
-
/**
|
|
10327
|
-
* 中断轮次发现(`interrupted` 会话扫描)
|
|
10328
|
-
*
|
|
10329
|
-
* ## 这个模块解决什么
|
|
10330
|
-
*
|
|
10331
|
-
* DSH 的会话日志里,**进程在某一轮完成前退出**(被 kill/崩溃/关机超时)时,
|
|
10332
|
-
* 宿主重放时会为该轮补上收尾事件:
|
|
10333
|
-
*
|
|
10334
|
-
* ```jsonl
|
|
10335
|
-
* turn/end {"turn":66,"reason":{"kind":"interrupted"}}
|
|
10336
|
-
* step/end {"turn":66,"step":85}
|
|
10337
|
-
* session/end-seed {}
|
|
10338
|
-
* ```
|
|
10339
|
-
*
|
|
10340
|
-
* 宿主**只补收尾,不续跑** —— 那一轮的工作就此停在那里。本模块负责把这些会话找出来,
|
|
10341
|
-
* 交给上层(认知轨迹)决定要不要续。
|
|
10342
|
-
*
|
|
10343
|
-
* ## 判据(三条同时成立,缺一不可)
|
|
10344
|
-
*
|
|
10345
|
-
* 1. 日志里**最后一条 `turn/end`** 的 `reason.kind === 'interrupted'`;
|
|
10346
|
-
* 2. 该 `turn/end` **之后没有新的 `turn/start`**(否则已被后续轮次接续);
|
|
10347
|
-
* 3. ⛔ **不要求**末条是 `session/end-seed`(2026-10-23 放宽,原因见 {@link detectInterruptedEnd})。
|
|
10348
|
-
*
|
|
10349
|
-
* ⚠️ **为什么不能只看"存在过 interrupted"**:一个长会话历史上可能中断几十次
|
|
10350
|
-
* (本容器实测单会话 33 次)。只看"曾经中断"会把早已恢复的会话全部当成待续 ⇒ 灾难。
|
|
10351
|
-
*
|
|
10352
|
-
* 🔴 **为什么原判据要求"末条 = end-seed"是错的**(实测 26 条里漏 2 条):
|
|
10353
|
-
* 重启后宿主会在 end-seed **之后**追加 `agent/inbox/spliced`(把待办消息重新塞进收件箱)
|
|
10354
|
-
* ⇒ "末条必须 end-seed"**在恢复动作发生之后就恒不成立**,
|
|
10355
|
-
* 而"被恢复过又再次被打断"恰恰是守护最该覆盖的形态。
|
|
10356
|
-
*
|
|
10357
|
-
* ## 为什么不整文件解压
|
|
10358
|
-
*
|
|
10359
|
-
* DSH 日志是 **zstd 多帧追加**(实测单会话 24705 帧 / 47 MB)。原生
|
|
10360
|
-
* `zlib.zstdDecompressSync` 只解**第一帧**,对多帧直接报 `Unknown frame descriptor`。
|
|
10361
|
-
* ⇒ 采用**尾部窗口 + 倒序逐帧解压**:只读文件末尾 N 字节,从后往前找帧魔数,
|
|
10362
|
-
* 解到凑够所需事件数即停。实测 8h 窗口 6 个会话 **20 ms**。
|
|
10363
|
-
*
|
|
10364
|
-
* ## 世代识别复用 `cleanup.ts`
|
|
10365
|
-
*
|
|
10366
|
-
* 日志文件名有多个世代(`session.jsonl` / `session.v3.jsonl.zstd` / `session.v4.jsonl.zstd`…),
|
|
10367
|
-
* 宿主按世代写且**迁移保留源文件**。⇒ 本模块**不硬编码文件名**,走
|
|
10368
|
-
* {@link findSessionLog}(与清理/体积统计同一判据),避免"新世代对本模块不可见"。
|
|
10369
|
-
*
|
|
10370
|
-
* ## 边界
|
|
10371
|
-
*
|
|
10372
|
-
* - **只读**:不改任何会话日志。
|
|
10373
|
-
* - **不判周期**:何时扫、扫完做什么归调用方(CCC 的认知轨迹)。本模块只回答
|
|
10374
|
-
* 「此刻哪些会话以 interrupted 结尾」。
|
|
10375
|
-
*/
|
|
10630
|
+
//#region src/trajectory/resident.ts
|
|
10376
10631
|
/**
|
|
10377
|
-
*
|
|
10632
|
+
* trajectory/resident.ts — **常驻名单**(D173 的兑现口,`cognitive_trajectory resident` 的实现)
|
|
10378
10633
|
*
|
|
10379
|
-
*
|
|
10380
|
-
* 本仓 `@types/node@^20` 的声明里**没有** zstd 系列(Node 22.15+ 才进声明)。
|
|
10381
|
-
* 而运行时本机 v22.22.1 **确实有**(实测 `typeof require('zlib').zstdDecompressSync === 'function'`)。
|
|
10382
|
-
* ⇒ 用命名空间导入 + 局部断言,并**在运行时校验**;缺失时走明确降级(返回空,不静默瞎猜)。
|
|
10383
|
-
*/
|
|
10384
|
-
const zstdDecompressSync = nodeZlib.zstdDecompressSync;
|
|
10385
|
-
/** zstd 帧魔数(小端 `28 B5 2F FD`) */
|
|
10386
|
-
const ZSTD_MAGIC = Buffer.from([
|
|
10387
|
-
40,
|
|
10388
|
-
181,
|
|
10389
|
-
47,
|
|
10390
|
-
253
|
|
10391
|
-
]);
|
|
10392
|
-
/** 默认回看窗口:8 小时(owner 2026-10-22 定) */
|
|
10393
|
-
const DEFAULT_LOOKBACK_MS = 288e5;
|
|
10394
|
-
/** 每次从文件尾部读取的字节数(够覆盖数百个末尾事件) */
|
|
10395
|
-
const TAIL_WINDOW_BYTES = 1 << 20;
|
|
10396
|
-
/** 需要凑够的末尾事件数(够跨过 turn/end + step/end + end-seed 即可,留余量) */
|
|
10397
|
-
const TAIL_EVENT_TARGET = 60;
|
|
10398
|
-
/**
|
|
10399
|
-
* 从文件尾部倒序解压,凑够 `want` 个事件。
|
|
10634
|
+
* ## 这条能力的判据(owner 2026-10-20 原话,逐字)
|
|
10400
10635
|
*
|
|
10401
|
-
*
|
|
10402
|
-
*
|
|
10403
|
-
*/
|
|
10404
|
-
function readTailEvents(logPath, want) {
|
|
10405
|
-
if (zstdDecompressSync === void 0) return [];
|
|
10406
|
-
const st = statSync(logPath);
|
|
10407
|
-
const windowBytes = Math.min(TAIL_WINDOW_BYTES, st.size);
|
|
10408
|
-
if (windowBytes < 8) return [];
|
|
10409
|
-
const buf = Buffer.alloc(windowBytes);
|
|
10410
|
-
const fd = openSync(logPath, "r");
|
|
10411
|
-
try {
|
|
10412
|
-
readSync(fd, buf, 0, windowBytes, st.size - windowBytes);
|
|
10413
|
-
} finally {
|
|
10414
|
-
closeSync(fd);
|
|
10415
|
-
}
|
|
10416
|
-
const offsets = [];
|
|
10417
|
-
for (let i = 0; i <= windowBytes - ZSTD_MAGIC.length; i++) if (buf[i] === ZSTD_MAGIC[0] && buf[i + 1] === ZSTD_MAGIC[1] && buf[i + 2] === ZSTD_MAGIC[2] && buf[i + 3] === ZSTD_MAGIC[3]) offsets.push(i);
|
|
10418
|
-
if (offsets.length === 0) return [];
|
|
10419
|
-
const events = [];
|
|
10420
|
-
for (let k = offsets.length - 1; k >= 0 && events.length < want; k--) {
|
|
10421
|
-
const start = offsets[k];
|
|
10422
|
-
const end = k + 1 < offsets.length ? offsets[k + 1] : windowBytes;
|
|
10423
|
-
let text;
|
|
10424
|
-
try {
|
|
10425
|
-
text = zstdDecompressSync(buf.subarray(start, end)).toString("utf8");
|
|
10426
|
-
} catch {
|
|
10427
|
-
break;
|
|
10428
|
-
}
|
|
10429
|
-
const batch = [];
|
|
10430
|
-
for (const line of text.split("\n")) {
|
|
10431
|
-
if (!line.trim()) continue;
|
|
10432
|
-
try {
|
|
10433
|
-
batch.push(JSON.parse(line));
|
|
10434
|
-
} catch {}
|
|
10435
|
-
}
|
|
10436
|
-
events.unshift(...batch);
|
|
10437
|
-
}
|
|
10438
|
-
return events.slice(-want);
|
|
10439
|
-
}
|
|
10440
|
-
/**
|
|
10441
|
-
* 判定一组尾部事件是否「以 interrupted 结尾」。
|
|
10442
|
-
* 不命中 → null。
|
|
10636
|
+
* > 「常驻的意思就是**自己声明为自己欢迎别人来麻烦自己**,和永不休眠没啥关系,
|
|
10637
|
+
* > **trajectory never ends 悖论的一种实现**」
|
|
10443
10638
|
*
|
|
10444
|
-
*
|
|
10639
|
+
* ⇒ **常驻不是资源状态**。它不是"永不休眠"、不是"常驻内存"、不是"不许销毁我的会话"。
|
|
10640
|
+
* 一条轨迹**永远存在**(没有生命周期),常驻与否都不改变这一点。
|
|
10445
10641
|
*
|
|
10446
|
-
*
|
|
10447
|
-
*
|
|
10448
|
-
* · 末轮 `turn/end[interrupted]` 的会话 **26** 条;
|
|
10449
|
-
* · 其中满足原判据的只有 **24** 条 ⇒ **漏 2 条**,形态逐字为:
|
|
10450
|
-
* `… turn/end[interrupted] -> session/end-seed -> agent/inbox/spliced -> agent/inbox/spliced`
|
|
10451
|
-
* ⇒ 重启后宿主把这**两条**追加在 end-seed 之后(它们是"恢复时把待办消息重新塞进收件箱"),
|
|
10452
|
-
* 于是"末条必须 end-seed"这条**在恢复动作发生之后就恒不成立**。
|
|
10453
|
-
* ⚠️ 讽刺的是:**正是那些被恢复过、又再次被打断的会话**最容易长成这样
|
|
10454
|
-
* ⇒ 原判据恰好对"反复中断"的会话失明,而它们才是最需要守护的。
|
|
10642
|
+
* ⇒ **它管的是「被找到」,不是「不被销毁」。**
|
|
10643
|
+
* 两件事混为一谈,就会去动调度器的载体生命周期 —— 那正是本轨反复踩的"名字撒谎"。
|
|
10455
10644
|
*
|
|
10456
|
-
*
|
|
10457
|
-
* 1. 日志里**最后一条 `turn/end`** 的 `reason.kind === 'interrupted'`;
|
|
10458
|
-
* 2. 该 `turn/end` **之后没有新的 `turn/start`**(否则说明它已被后续轮次接续);
|
|
10459
|
-
* 3. ⛔ **不再要求末条是 `end-seed`**。
|
|
10645
|
+
* ## 因此本模块的三条硬边界
|
|
10460
10646
|
*
|
|
10461
|
-
*
|
|
10462
|
-
*
|
|
10463
|
-
*
|
|
10464
|
-
*
|
|
10647
|
+
* 1. **纯读**:不写任何文件、不碰注册表、不碰 `wake/scheduler.ts`。
|
|
10648
|
+
* 2. **不进系统提示词**(owner 裁决 Q2:说明类信息按需取,不占每轮 token)。
|
|
10649
|
+
* 3. **只报写了 `serves` 的轨迹** —— 只写半个(有 `resident` 没 `serves`)等于
|
|
10650
|
+
* "把自己挂上去却不说能干什么",按手册的判据那比不写更坏,所以**不列出来**,
|
|
10651
|
+
* 但要**单独数出**来,让写的人知道自己的写法没生效。
|
|
10465
10652
|
*
|
|
10466
|
-
*
|
|
10467
|
-
*
|
|
10468
|
-
* 实测该放宽**不新增误报**:原 24 条全含于新判据(`curOnly = 0`),新增 2 条全是真漏。
|
|
10653
|
+
* ## 归属(D23)
|
|
10654
|
+
* 机制在 ACC(怎么找、按什么顺序、列什么字段);内容在 CCC(`serves` 那句话写什么)。
|
|
10469
10655
|
*/
|
|
10470
|
-
function detectInterruptedEnd(events) {
|
|
10471
|
-
if (events.length === 0) return null;
|
|
10472
|
-
let idx = -1;
|
|
10473
|
-
for (let i = events.length - 1; i >= 0; i--) if (events[i]?.type === "turn/end") {
|
|
10474
|
-
idx = i;
|
|
10475
|
-
break;
|
|
10476
|
-
}
|
|
10477
|
-
if (idx < 0) return null;
|
|
10478
|
-
const turnEnd = events[idx];
|
|
10479
|
-
if (turnEnd?.data?.reason?.kind !== "interrupted") return null;
|
|
10480
|
-
for (let i = idx + 1; i < events.length; i++) if (events[i]?.type === "turn/start") return null;
|
|
10481
|
-
const turn = typeof turnEnd.data?.turn === "number" ? turnEnd.data.turn : null;
|
|
10482
|
-
return {
|
|
10483
|
-
turn,
|
|
10484
|
-
pendingTools: findPendingTools(events, turn)
|
|
10485
|
-
};
|
|
10486
|
-
}
|
|
10487
10656
|
/**
|
|
10488
|
-
*
|
|
10657
|
+
* 列出全部**可被找到**的轨迹(按编码排序,确定性)。
|
|
10489
10658
|
*
|
|
10490
|
-
* ⚠️
|
|
10491
|
-
* "**可能**未完成的副作用"清单,而不是"确定未完成"。
|
|
10492
|
-
*/
|
|
10493
|
-
function findPendingTools(events, turn) {
|
|
10494
|
-
if (turn === null) return [];
|
|
10495
|
-
const called = /* @__PURE__ */ new Map();
|
|
10496
|
-
const resolved = /* @__PURE__ */ new Set();
|
|
10497
|
-
for (const e of events) {
|
|
10498
|
-
const data = e.data;
|
|
10499
|
-
if (!data || data.turn !== turn) continue;
|
|
10500
|
-
if (e.type === "tool/call" && typeof data.callId === "string" && typeof data.name === "string") called.set(data.callId, data.name);
|
|
10501
|
-
else if (e.type === "tool/result" && typeof data.callId === "string") resolved.add(data.callId);
|
|
10502
|
-
}
|
|
10503
|
-
return [...called].filter(([callId]) => !resolved.has(callId)).map(([, name]) => name);
|
|
10504
|
-
}
|
|
10505
|
-
/**
|
|
10506
|
-
* 扫描一个 **project 目录**(形如 `<sessions-root>/--home-yh-home-home-serenity--/`)。
|
|
10659
|
+
* ⚠️ 读盘失败 / 坏 JSON 一律**跳过该条**(不抛):一份坏的声明不该让整张名单拿不到。
|
|
10507
10660
|
*
|
|
10508
|
-
* @param
|
|
10509
|
-
* @param options.lookbackMs 回看窗口(缺省 8h)
|
|
10510
|
-
* @param options.now 注入"当前时刻"(测试用)
|
|
10511
|
-
* @param options.liveIds 需要跳过的会话 id(正在跑的会话不该被判为待续)
|
|
10661
|
+
* @param root CCC 根
|
|
10512
10662
|
*/
|
|
10513
|
-
function
|
|
10514
|
-
const
|
|
10515
|
-
const
|
|
10516
|
-
|
|
10517
|
-
const liveIds = options.liveIds ?? /* @__PURE__ */ new Set();
|
|
10518
|
-
const result = {
|
|
10519
|
-
sessions: [],
|
|
10520
|
-
scanned: 0,
|
|
10521
|
-
skipped: 0,
|
|
10522
|
-
errors: [],
|
|
10523
|
-
elapsedMs: 0
|
|
10524
|
-
};
|
|
10525
|
-
if (!existsSync(projectDir)) {
|
|
10526
|
-
result.elapsedMs = Date.now() - t0;
|
|
10527
|
-
return result;
|
|
10528
|
-
}
|
|
10529
|
-
let entries;
|
|
10663
|
+
function collectResident(root) {
|
|
10664
|
+
const entries = [];
|
|
10665
|
+
const incomplete = [];
|
|
10666
|
+
let names = [];
|
|
10530
10667
|
try {
|
|
10531
|
-
|
|
10532
|
-
} catch
|
|
10533
|
-
|
|
10534
|
-
|
|
10535
|
-
|
|
10536
|
-
}
|
|
10537
|
-
result.elapsedMs = Date.now() - t0;
|
|
10538
|
-
return result;
|
|
10668
|
+
names = readdirSync(sessionsRoot(root), { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith("_") && !e.name.startsWith(".")).map((e) => e.name);
|
|
10669
|
+
} catch {
|
|
10670
|
+
return {
|
|
10671
|
+
entries,
|
|
10672
|
+
incomplete
|
|
10673
|
+
};
|
|
10539
10674
|
}
|
|
10540
|
-
for (const
|
|
10541
|
-
|
|
10542
|
-
const dirPath = join(projectDir, sessionId);
|
|
10543
|
-
const logPath = findSessionLog(dirPath);
|
|
10544
|
-
if (logPath === null) continue;
|
|
10545
|
-
let mtimeMs;
|
|
10675
|
+
for (const dirName of names) {
|
|
10676
|
+
let profile;
|
|
10546
10677
|
try {
|
|
10547
|
-
|
|
10548
|
-
} catch
|
|
10549
|
-
result.errors.push({
|
|
10550
|
-
sessionId,
|
|
10551
|
-
message: err instanceof Error ? err.message : String(err)
|
|
10552
|
-
});
|
|
10678
|
+
profile = readTrajectoryConstraintAt(join(sessionsRoot(root), dirName), root);
|
|
10679
|
+
} catch {
|
|
10553
10680
|
continue;
|
|
10554
10681
|
}
|
|
10555
|
-
if (
|
|
10556
|
-
|
|
10557
|
-
|
|
10558
|
-
|
|
10559
|
-
|
|
10560
|
-
if (!hit) {
|
|
10561
|
-
result.skipped++;
|
|
10562
|
-
continue;
|
|
10563
|
-
}
|
|
10564
|
-
const tailTime = events[events.length - 1]?.time;
|
|
10565
|
-
result.sessions.push({
|
|
10566
|
-
sessionId,
|
|
10567
|
-
logPath,
|
|
10568
|
-
turn: hit.turn,
|
|
10569
|
-
lastEventAt: typeof tailTime === "number" ? tailTime : null,
|
|
10570
|
-
mtimeMs,
|
|
10571
|
-
pendingTools: hit.pendingTools
|
|
10572
|
-
});
|
|
10573
|
-
} catch (err) {
|
|
10574
|
-
result.errors.push({
|
|
10575
|
-
sessionId,
|
|
10576
|
-
message: err instanceof Error ? err.message : String(err)
|
|
10577
|
-
});
|
|
10682
|
+
if (!profile || profile.resident === void 0) continue;
|
|
10683
|
+
const serves = profile.resident.serves;
|
|
10684
|
+
if (serves === void 0 || serves === "") {
|
|
10685
|
+
incomplete.push(profile.code);
|
|
10686
|
+
continue;
|
|
10578
10687
|
}
|
|
10688
|
+
entries.push({
|
|
10689
|
+
code: profile.code,
|
|
10690
|
+
dirName: profile.dirName,
|
|
10691
|
+
serves,
|
|
10692
|
+
...profile.model !== void 0 ? { model: profile.model } : {}
|
|
10693
|
+
});
|
|
10579
10694
|
}
|
|
10580
|
-
|
|
10581
|
-
|
|
10582
|
-
return
|
|
10583
|
-
|
|
10584
|
-
|
|
10585
|
-
|
|
10586
|
-
*
|
|
10587
|
-
* 规则取自宿主:把绝对路径的 `/` 换成 `-`,并在前后各加 `--`。
|
|
10588
|
-
* ⚠️ 这是**推导**,不是真相源 —— 实际目录名以磁盘为准;调用方应在不存在时
|
|
10589
|
-
* 回退到"扫整个 sessions root"或直接报未找到。
|
|
10590
|
-
*/
|
|
10591
|
-
function projectDirNameForCwd(cwd) {
|
|
10592
|
-
return `--${cwd.replace(/\/+$/, "").split("/").filter(Boolean).join("-")}--`;
|
|
10695
|
+
entries.sort((a, b) => a.code < b.code ? -1 : a.code > b.code ? 1 : 0);
|
|
10696
|
+
incomplete.sort();
|
|
10697
|
+
return {
|
|
10698
|
+
entries,
|
|
10699
|
+
incomplete
|
|
10700
|
+
};
|
|
10593
10701
|
}
|
|
10594
10702
|
/**
|
|
10595
|
-
*
|
|
10596
|
-
*
|
|
10597
|
-
* 判据(为什么这么短):本动作的用途是**把被打断的会话推回工作状态**,
|
|
10598
|
-
* 不是替它重述任务 —— 被打断那一轮的上下文就在它自己的日志里。
|
|
10599
|
-
* ⇒ 正文只做三件事:① 说明发生了什么;② 提示**先核对副作用**;③ 让它接着做。
|
|
10703
|
+
* 渲染成给模型读的文本(`cognitive_trajectory resident` 的返回值)。
|
|
10600
10704
|
*
|
|
10601
|
-
* 🔴
|
|
10602
|
-
* 它们的副作用**未知**(可能已写文件 / 已发消息)。若模型把那一轮整段重放,
|
|
10603
|
-
* 会**重复执行**。这句话是给模型的护栏,不是客套。
|
|
10705
|
+
* 🔴 **空名单也是有效结果**,且必须说清"这意味着什么" —— 否则读者会以为工具坏了。
|
|
10604
10706
|
*/
|
|
10605
|
-
function
|
|
10606
|
-
const
|
|
10607
|
-
lines
|
|
10608
|
-
|
|
10609
|
-
|
|
10610
|
-
|
|
10707
|
+
function listResident(root) {
|
|
10708
|
+
const { entries, incomplete } = collectResident(root);
|
|
10709
|
+
const lines = ["═══ 常驻轨迹(自己声明「欢迎别人来麻烦我」)═══", ""];
|
|
10710
|
+
if (entries.length === 0) {
|
|
10711
|
+
lines.push(" (本 CCC 暂无轨迹声明常驻)");
|
|
10712
|
+
lines.push(" ⇒ 这不是错误:常驻是**自愿的自我声明**,没写就是\"不参与被找\"。");
|
|
10713
|
+
} else for (const e of entries) {
|
|
10714
|
+
const model = e.model !== void 0 ? ` [model: ${e.model}]` : "";
|
|
10715
|
+
lines.push(` · ${e.code}${model}`);
|
|
10716
|
+
lines.push(` ${e.serves}`);
|
|
10717
|
+
}
|
|
10718
|
+
if (incomplete.length > 0) {
|
|
10611
10719
|
lines.push("");
|
|
10612
|
-
lines.push(
|
|
10613
|
-
lines.push("
|
|
10614
|
-
lines.push(" ⛔ 不要把被中断的那一轮整段重放。");
|
|
10720
|
+
lines.push(` ⚠️ 写了 resident 但**没写 serves**(不会被列出):${incomplete.join("、")}`);
|
|
10721
|
+
lines.push(" 只写半个 = 把自己挂上去却不说能干什么 —— 别人会来问,然后发现问错了人。");
|
|
10615
10722
|
}
|
|
10616
10723
|
lines.push("");
|
|
10617
|
-
lines.push("
|
|
10724
|
+
lines.push("── 怎么让自己出现在这里 ──");
|
|
10725
|
+
lines.push(" 在你这条轨迹目录的 trajectory.json 里写:");
|
|
10726
|
+
lines.push(" { \"resident\": { \"serves\": \"一句话说清我擅长什么、能帮上什么忙\" } }");
|
|
10727
|
+
lines.push(" ⚠️ 只写这一个字段**不影响任何默认行为**(D183);其余 key 全部可选。");
|
|
10618
10728
|
return lines.join("\n");
|
|
10619
10729
|
}
|
|
10730
|
+
//#endregion
|
|
10731
|
+
//#region src/trajectory/live.ts
|
|
10620
10732
|
/**
|
|
10621
|
-
*
|
|
10733
|
+
* trajectory/live.ts — 进程内 live 会话取数 + 精简诊断
|
|
10622
10734
|
*
|
|
10623
|
-
*
|
|
10624
|
-
* -
|
|
10625
|
-
*
|
|
10626
|
-
*
|
|
10735
|
+
* ── 为什么单独成模块(R↓,2026-09-15 S142「ACC 侧 autopilot 退场」S1 搬迁)──
|
|
10736
|
+
* 这两个函数原先住在 `autopilot-trajectory.ts`,但它们的**主语不是 autopilot**:
|
|
10737
|
+
* `listLiveSessions` 服务 `acc-diag` ① 段的"**哪个会话属于哪个 CCC**"(会话维度,
|
|
10738
|
+
* 不是 CCC 维度),而"**独立进程看不到运行时**"正是 `acc-diag` 存在的理由。
|
|
10739
|
+
* autopilot 退场后它们必须活下去 ⇒ 独立成模块,不再挂着已死机制的名字。
|
|
10627
10740
|
*
|
|
10628
|
-
*
|
|
10629
|
-
*
|
|
10741
|
+
* ── 迁移边界(不搬什么,以及为什么)──────────────────────────────────────
|
|
10742
|
+
* 原 `diagLive` 还含两段,**均不搬**(其主语是 autopilot,随机制退场):
|
|
10743
|
+
* · `autopilotCccs`(逐 CCC 目标命中 / agent 定位诊断)—— 依赖 `readAutopilotSettings` /
|
|
10744
|
+
* `getAutopilotStatus` / `resolveTargetAgent`
|
|
10745
|
+
* · `panelResolved`(无参面板请求落到哪个 CCC)—— 面板区块随机制退场
|
|
10746
|
+
* 留下的 = **进程 cwd/CCC + live 会话清单**(`acc-diag` ① 段仍需要)。
|
|
10747
|
+
*
|
|
10748
|
+
* 判据纪律:本模块**不得**反向依赖 autopilot 判据层(那会让退场半途而废)。
|
|
10749
|
+
*
|
|
10750
|
+
* ── 🔴 2026-09-25 I2 收敛(约束文档 §2.7 违反清单,L1 违反之一)────────────────
|
|
10751
|
+
* **本模块的 `ctx` 参数类型从 `Context` 改为 `unknown`** —— 消除本文件唯一的 `W2` 接触
|
|
10752
|
+
* (直接 import 宿主 `cordis` 的类型)。
|
|
10753
|
+
*
|
|
10754
|
+
* **为什么这是"真解耦"而不是"把类型藏起来"(判据):** `ctx` 在本模块**从不被解引用** ——
|
|
10755
|
+
* 它只被**原样透传**给 `hostSessions(ctx)`,而后者(`host/access.ts`,**L0**)的参数
|
|
10756
|
+
* 本就声明为 `ctx: unknown` ⇒ `Context` 这个 import **在语义上是多余的**,
|
|
10757
|
+
* 它没有参与任何类型检查,只贡献了一处 L1→宿主 的接触点。
|
|
10758
|
+
* 🔵 **证据**:grep 本文件 `ctx` 全部 5 处命中 —— 3 处在参数/调用位、2 处在注释,
|
|
10759
|
+
* **零处**属性访问(`ctx.xxx`)。
|
|
10630
10760
|
*/
|
|
10631
|
-
|
|
10632
|
-
|
|
10633
|
-
|
|
10634
|
-
|
|
10635
|
-
|
|
10636
|
-
|
|
10637
|
-
|
|
10638
|
-
|
|
10639
|
-
|
|
10640
|
-
|
|
10641
|
-
|
|
10642
|
-
const sc = deps.sessionController;
|
|
10643
|
-
const resolve = sc?.resolveAgent;
|
|
10644
|
-
if (typeof resolve !== "function" || sc === void 0) return {
|
|
10645
|
-
sessionId,
|
|
10646
|
-
ok: false,
|
|
10647
|
-
error: "宿主 sessionController 不可用(重启启动窗口?)—— 稍后重试"
|
|
10648
|
-
};
|
|
10649
|
-
try {
|
|
10650
|
-
const out = await resolve.call(sc, sessionId);
|
|
10651
|
-
if (!out?.agent) return {
|
|
10652
|
-
sessionId,
|
|
10653
|
-
ok: false,
|
|
10654
|
-
error: `冷载入未返回 agent: ${String(out?.error ?? "未知原因")}`
|
|
10655
|
-
};
|
|
10656
|
-
agent = out.agent;
|
|
10657
|
-
how = "cold-resume";
|
|
10658
|
-
} catch (err) {
|
|
10659
|
-
return {
|
|
10660
|
-
sessionId,
|
|
10661
|
-
ok: false,
|
|
10662
|
-
error: `冷载入失败: ${err instanceof Error ? err.message : String(err)}`
|
|
10663
|
-
};
|
|
10664
|
-
}
|
|
10761
|
+
/**
|
|
10762
|
+
* 从 dsh 会话 log 读取标题(latest-wins `session/title` 事件)——
|
|
10763
|
+
* **标题不在 `sessions.list()` 条目上**(wire/对象均无 title 字段;F3 命名经
|
|
10764
|
+
* `sessionTitle.rename` 写进 session log),必须从 events 提取(rebuild 同款读取模式)。
|
|
10765
|
+
*/
|
|
10766
|
+
function readSessionTitle(session) {
|
|
10767
|
+
try {
|
|
10768
|
+
const events = sessionEvents(session);
|
|
10769
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
10770
|
+
const e = events[i];
|
|
10771
|
+
if (e?.type === "session/title" && typeof e.data?.title === "string" && e.data.title.trim() !== "") return e.data.title.trim();
|
|
10665
10772
|
}
|
|
10666
|
-
|
|
10667
|
-
|
|
10668
|
-
|
|
10669
|
-
|
|
10670
|
-
|
|
10671
|
-
|
|
10672
|
-
|
|
10673
|
-
|
|
10674
|
-
|
|
10675
|
-
|
|
10676
|
-
|
|
10677
|
-
|
|
10678
|
-
|
|
10679
|
-
|
|
10680
|
-
|
|
10681
|
-
|
|
10682
|
-
|
|
10683
|
-
|
|
10684
|
-
|
|
10685
|
-
|
|
10686
|
-
|
|
10687
|
-
|
|
10688
|
-
|
|
10689
|
-
|
|
10690
|
-
};
|
|
10691
|
-
}
|
|
10692
|
-
return {
|
|
10693
|
-
sessionId,
|
|
10694
|
-
ok: false,
|
|
10695
|
-
how,
|
|
10696
|
-
error: "agent 既无 steer 也无 followup(宿主契约漂移)"
|
|
10697
|
-
};
|
|
10698
|
-
} catch (err) {
|
|
10699
|
-
if (typeof a.followup === "function") try {
|
|
10700
|
-
a.followup(deps.makeMessage(text));
|
|
10701
|
-
return {
|
|
10702
|
-
sessionId,
|
|
10703
|
-
ok: true,
|
|
10704
|
-
how: `${how}+followup(steer 失败后退回)`
|
|
10705
|
-
};
|
|
10706
|
-
} catch (err2) {
|
|
10707
|
-
return {
|
|
10708
|
-
sessionId,
|
|
10709
|
-
ok: false,
|
|
10710
|
-
error: `steer 与 followup 均失败: ${String(err2?.message ?? err2)}`
|
|
10711
|
-
};
|
|
10712
|
-
}
|
|
10713
|
-
return {
|
|
10714
|
-
sessionId,
|
|
10715
|
-
ok: false,
|
|
10716
|
-
error: `注入失败: ${err instanceof Error ? err.message : String(err)}`
|
|
10717
|
-
};
|
|
10773
|
+
} catch {}
|
|
10774
|
+
return null;
|
|
10775
|
+
}
|
|
10776
|
+
/**
|
|
10777
|
+
* 逐条 live 会话(`cccRoot` = 该会话 cwd 的 CCC 归属,无 → null)。
|
|
10778
|
+
*
|
|
10779
|
+
* CCC 枚举("本机有哪些 CCC")**不在这里**,归 `ccc-roots.listCccs`(并集语义);
|
|
10780
|
+
* 本函数保留的是它**独有**的那部分:会话维度 + cwd 归属 + 标题 + id。
|
|
10781
|
+
*
|
|
10782
|
+
* @param ctx 插件上下文(**只透传**给 L0 取数口,本模块不解引用 ⇒ 类型为 `unknown`)
|
|
10783
|
+
* @returns 逐条 live 会话
|
|
10784
|
+
*/
|
|
10785
|
+
function listLiveSessions(ctx) {
|
|
10786
|
+
const out = [];
|
|
10787
|
+
try {
|
|
10788
|
+
const sessions = hostSessions(ctx);
|
|
10789
|
+
for (const s of sessions?.list?.() ?? []) {
|
|
10790
|
+
const cwd = s?.header?.cwd ?? null;
|
|
10791
|
+
out.push({
|
|
10792
|
+
id: s?.id ?? "",
|
|
10793
|
+
cwd,
|
|
10794
|
+
cccRoot: cwd ? cccRootForCwd(cwd) : null,
|
|
10795
|
+
title: readSessionTitle(s)
|
|
10796
|
+
});
|
|
10718
10797
|
}
|
|
10798
|
+
} catch {}
|
|
10799
|
+
return out;
|
|
10800
|
+
}
|
|
10801
|
+
function diagLive(ctx) {
|
|
10802
|
+
const processCwd = process.cwd();
|
|
10803
|
+
return {
|
|
10804
|
+
processCwd,
|
|
10805
|
+
processCcc: cccRootForCwd(processCwd),
|
|
10806
|
+
liveSessions: listLiveSessions(ctx)
|
|
10719
10807
|
};
|
|
10720
10808
|
}
|
|
10721
10809
|
//#endregion
|