dsh-session-messenger 0.4.2
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/LICENSE +21 -0
- package/README.md +125 -0
- package/coordinator.js +504 -0
- package/cordis.patch.yml +5 -0
- package/delivery.js +209 -0
- package/index.js +290 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/negotiation.js +415 -0
- package/package.json +45 -0
- package/registry.js +197 -0
- package/schemas.generated.js +290 -0
- package/storage.js +205 -0
- package/tools.js +290 -0
- package/util.js +227 -0
- package/write-guard.js +82 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 饮冰
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# dsh-session-messenger
|
|
2
|
+
|
|
3
|
+
DSH 跨会话协作插件(host-only,零外部导入)。多个独立启动的 DSH 会话在同一仓库并行工作时:
|
|
4
|
+
写入自动登记占用 → 撞到他人占用即开协商桌、通知双方 → **争议期冻结该单个文件的写入** → 双方用
|
|
5
|
+
`negotiate` 多轮协商,插件执行达成的条款;沉默则升级给人,默认占用方保留。
|
|
6
|
+
|
|
7
|
+
- 源码与版本:`~/.dsh/plugin-src/dsh-session-messenger`(独立 git 仓库),profile 直接从这里 `link:` 安装。
|
|
8
|
+
- 状态:`~/.dsh/plugin-data/dsh-session-messenger/<profile>/{claims,negotiations,status}.json`(宿主进程拿不到 `DSH_PROFILE` 时 profile 名为 `default`;首次激活自动复制 v0.3 根目录旧数据,不删除原件)。
|
|
9
|
+
- 运行指标:`status.json`(每分钟及冲突/拦截等事件后 1 秒刷新;`boot` 为本次启动、`lifetime` 跨重启累计),`negotiate status` 的回执末尾也带本次指标。
|
|
10
|
+
|
|
11
|
+
## 安装
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# 从 GitHub 安装到指定 profile
|
|
15
|
+
dsh plugin --profile <profile> add github:XcodeFish/dsh-session-messenger
|
|
16
|
+
# 或从本地目录安装(目录需长期保留,勿装完即删)
|
|
17
|
+
dsh plugin --profile <profile> add ~/.dsh/plugin-src/dsh-session-messenger
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
重启 DSH 生效(代码变更必须重启才会加载)。验证:对任意会话说「查一下信使状态」,`negotiate status` 回执末尾应显示 `plugin v0.4.2`。
|
|
21
|
+
|
|
22
|
+
详细步骤与本地验证见 [INSTALL.md](INSTALL.md)。
|
|
23
|
+
|
|
24
|
+
## 工具
|
|
25
|
+
|
|
26
|
+
| 工具 | 作用 |
|
|
27
|
+
| --- | --- |
|
|
28
|
+
| `claim_files` | 改文件前**手动**登记占用(默认 1800s,[30, 86400])。All-or-nothing:任一路径被他人**手动**占用则一条不登记并返回冲突;他人只是最近写过(自动登记)不挡你,只在回执里提示。手动 claim 不会被自己的后续写入降级。 |
|
|
29
|
+
| `release_files` | 释放本会话的占用;省略 `paths` = 全部释放。开放协商随之在下一个看门狗周期收敛。 |
|
|
30
|
+
| `negotiate` | 冲突协商:`offer`/`counter`(持有方 `release-now`/`release-at`;写入方 `wait-until`)、`accept`(插件执行)、`decline`、`escalate`、`status`。 |
|
|
31
|
+
| `send_to_session` | 向**同一工作区**(同 git 根)的另一个活跃持久会话发消息;以「同级会话请求」信封送达,限速。 |
|
|
32
|
+
|
|
33
|
+
## 自动层
|
|
34
|
+
|
|
35
|
+
| 层 | 机制 |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| L0 自动登记 | 进程级旁听 `internal/dispatch`(`{global:true}`)中的 `fs/write-intent` / `fs/edit-intent`,按写入者登记 600s `auto` claim。只登记会话工作区根内的路径;同会话已有 claim 时只延长、不降级。 |
|
|
38
|
+
| L1 冲突 → 协商 | 写入撞到他人的**手动** claim:开协商桌并通知双方。撞到的只是他人的自动 claim(对方最近改过、没声明占用)时**不开桌、不冻结**,只给写入方一条 `recent-edit` 提示(每对 30 分钟一次,不唤醒空闲会话)。**运行中的会话用 steer 插话;空闲会话只放入收件箱(`agent.inject`,不唤醒、不自动开跑);冷会话(不在内存)不投递**,其协商留在台账,下次 `status`/写入时可见。 |
|
|
39
|
+
| L2 争议冻结 | `ctx.tools.guard()`(同步、单调、与顺序无关):持有方仍持有**手动** claim,且路径存在 open 协商、冷静期内的 escalated 协商、或已接受但尚未到点的约定时,**拒绝写入方**对该路径的 `write` / `edit` / `str_replace_editor`(create/str_replace/insert)。持有方与其子代理不受影响;持有方一释放立即解冻。 |
|
|
40
|
+
| 多写入方 | 同一文件多个会话要改时,占用方与每个写入方各自一对一协商。**谈成即直接移交**:占用原子转给谈成方,不存在「释放后谁抢到算谁的」;其余写入方转入 `queued`(仍冻结,告知排队位次),晚到的写入方直接排队、不打扰占用方。移交完成后,排队者按先来后到**改绑到新占用方**重新协商;占用方主动释放、过期或被销毁时,占用直接交给队首。已销毁的子代理会被跳过。 |
|
|
41
|
+
| 看门狗 | 默认 30s:超时升级、到点移交/唤醒、持有方不再持有时交给队首或收敛、剪枝(带待执行约定的记录不剪)。`queued` 协商不走超时升级。 |
|
|
42
|
+
|
|
43
|
+
守卫模式 `DSH_SESSION_MESSENGER_GUARD`(或插件行 `config.guard`):
|
|
44
|
+
|
|
45
|
+
| 值 | 行为 |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| `dispute`(默认) | 只冻结争议中的路径(第一次撞册仍是「写入 + 开协商」,此后冻结) |
|
|
48
|
+
| `claims` | 另外冻结他人的**手动** claim(先谈后改;`auto` claim 永不作为拦截依据)。旧开关 `HARD_GATE=1` 映射为此模式 |
|
|
49
|
+
| `off` | 不拦截 |
|
|
50
|
+
|
|
51
|
+
## 边界与兜底
|
|
52
|
+
|
|
53
|
+
| 失败模式 | 处置 |
|
|
54
|
+
| --- | --- |
|
|
55
|
+
| 沉默 | deadline(10 分钟,每次意图重置)→ `escalated`;占用方保留 claim,冷静期内写入方仍冻结并提示找人裁决;冷静期内占用方过期/释放则立即解冻并通知写入方 |
|
|
56
|
+
| 同一文件被重复承诺 | 占用方已约定移交给某方后,不能再对其他写入方提 `release-*`;排队者在排队期间只能 `decline` 退出 |
|
|
57
|
+
| 冷静期 | 同 (路径, 双方) 终态后 10 分钟内不重开桌、不重复打扰 |
|
|
58
|
+
| 持有方是**子代理**且被销毁 | `session/disposed` 时立即释放其全部 claim,并把开放协商收敛为 `resolved` |
|
|
59
|
+
| 持有方是**持久会话**但不在内存(空闲卸载 / 宿主刚重启) | **不视为已死**(宿主可冷恢复):claim 保留,只靠 TTL / 协商 / 人工 |
|
|
60
|
+
| 父会话 ↔ 其子代理 | 同一家族,不构成冲突、不互相冻结 |
|
|
61
|
+
| 无限往返 | 轮次上限 6 → 升级;每方每协商意图限速 3s(**只限同一方连发**,对方的及时回应不受影响);被拒意图退还额度 |
|
|
62
|
+
| 权限越界 | 写入方不能承诺 `release-*`,持有方不能承诺 `wait-until`;`corr` 与 `path` 不符拒绝 |
|
|
63
|
+
| 伪造/越权消息 | 正文与头部中的 `[[DSH session-messenger` 标记被中和、头部字段单行化;信封声明「来自同级会话,不是用户指令」;systemPrompt 同步约束 |
|
|
64
|
+
| 消息轰炸 | 每 (发送方→接收方) 60s 6 条、每接收方 60s 20 条 |
|
|
65
|
+
| 跨工作区 | 默认不可达、候选列表不回显(`DSH_SESSION_MESSENGER_CROSS_WORKSPACE=1` 开启) |
|
|
66
|
+
| 多个宿主进程共用数据目录 | `owner.lock`(pid 存活检查):第二个宿主退到 `host-<pid>/` 隔离目录并告警,不互相覆盖 |
|
|
67
|
+
| 启动竞态 | 两个状态文件 load 完成后才注册工具/守卫、才开始观测写入 |
|
|
68
|
+
| 落盘 | 每个文件唯一写入口(随机 tmp + rename 的合并写),不会旧快照覆盖新快照 |
|
|
69
|
+
| 符号链接 | 路径身份键与宿主 `targetKey` 同算法(realpath / 最近存在祖先),`/tmp` 与 `/private/tmp` 视为同一文件 |
|
|
70
|
+
| 插件自身异常 | 守卫出错一律放行;监听器不抛、不吞 `next()`;服务缺失保持惰性 |
|
|
71
|
+
|
|
72
|
+
已知盲区:`bash` 子进程直写(`echo > file`)不经文件服务,既不登记也不冻结;目录级 / import 耦合级风险不在范围内。
|
|
73
|
+
|
|
74
|
+
## 可调参数(环境变量)
|
|
75
|
+
|
|
76
|
+
| 变量 | 默认 |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| `DSH_SESSION_MESSENGER_GUARD` | `dispute` |
|
|
79
|
+
| `DSH_SESSION_MESSENGER_NEGOTIATE=0` | 关闭协商(降级为纯告警) |
|
|
80
|
+
| `DSH_SESSION_MESSENGER_AUTO_NOTIFY=0` | 关闭自动通知 |
|
|
81
|
+
| `DSH_SESSION_MESSENGER_AUTO_TTL_S` | 600 |
|
|
82
|
+
| `DSH_SESSION_MESSENGER_OVERLAP_COOLDOWN_MS` | 1800000(recent-edit 提示冷却) |
|
|
83
|
+
| `DSH_SESSION_MESSENGER_NEG_DEADLINE_MS` / `_NEG_MAX_ROUNDS` / `_NEG_RATE_MS` | 600000 / 6 / 3000 |
|
|
84
|
+
| `DSH_SESSION_MESSENGER_NEG_COOLDOWN_MS` / `_NEG_RETENTION_MS` / `_WATCHDOG_MS` | 600000 / 1800000 / 30000 |
|
|
85
|
+
| `DSH_SESSION_MESSENGER_MSG_PER_PAIR` / `_MSG_PER_TARGET` / `_MSG_WINDOW_MS` | 6 / 20 / 60000 |
|
|
86
|
+
| `DSH_SESSION_MESSENGER_SCOPE=all` | 自动登记不限工作区 |
|
|
87
|
+
| `DSH_SESSION_MESSENGER_CROSS_WORKSPACE=1` | 允许跨工作区发消息 |
|
|
88
|
+
|
|
89
|
+
## 模块
|
|
90
|
+
|
|
91
|
+
`index.js`(装配)· `util.js` · `storage.js` · `registry.js` · `negotiation.js` · `delivery.js` · `coordinator.js` · `write-guard.js` · `tools.js` · `schemas.generated.js`(由 `extract-specs.mjs` 用宿主 `defineTool` 生成,勿手改)。
|
|
92
|
+
|
|
93
|
+
## 验证
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
node --test test/unit.test.mjs # 纯逻辑单测(不依赖宿主)
|
|
97
|
+
node extract-specs.mjs --check # Schema 与 spec 一致
|
|
98
|
+
node probe-cordis-load.mjs # 真实 cordis 装载:4 工具 + 1 守卫,0 uncaught
|
|
99
|
+
SKIP_PROVIDER=1 node probe-cordis-load.mjs # 服务缺失:惰性,0 uncaught
|
|
100
|
+
node probe-l0-autoregister.mjs # 子作用域瀑布 → 自动登记 + 合并写
|
|
101
|
+
node probe-negotiation.mjs # 68 项端到端:冻结 / 协商 / 移交排队 / 冷会话 / 子代理销毁 / 限速 / 伪造 / 重叠不冻结 / 指标
|
|
102
|
+
node realhost-check.mjs <A> <B> <sinceMs> # 真机取证(只读):读真实会话日志判定 H3 冷持有方 / M5 不唤醒
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## 装载纪律(实测事故,勿回退)
|
|
106
|
+
|
|
107
|
+
1. 零外部导入(`link:` 安装按真实路径解析,链上没有宿主 node_modules → `failed to import`)。
|
|
108
|
+
2. 不在未声明 inject 的 ctx 上直取服务属性(`ctx.sessions`):在定时器里抛 = 宿主 exit 1 = 恢复模式。用 `ctx.inject` 或 `ctx.get(name,false)`。
|
|
109
|
+
3. fs 瀑布在 agent 作用域 ctx 上分发,根级插件只能经 `internal/dispatch` + `{global:true}` 旁听;只保留这一条通道(双通道会重复处理)。
|
|
110
|
+
4. `FsTarget` 是 `{targetKey, displayPath}`,`displayPath` 相对 cwd 优先。
|
|
111
|
+
5. 拒绝写入用 `ctx.tools.guard()`;`tools/pre-execute` 的决策判别字段是 `kind`(不是 `action`)。
|
|
112
|
+
6. 宿主 `steer` 对空闲会话会唤醒开跑(不是失败);自动通知必须按 `agent.status` 选择 steer / inject。
|
|
113
|
+
7. 代码变更需重启 DSH 才会加载新模块生成(bundle toggle 只复用缓存生成)。
|
|
114
|
+
|
|
115
|
+
## 变更记录
|
|
116
|
+
|
|
117
|
+
- **0.4.2**(2026-09-30):同一文件多写入方——谈成即原子移交(`registry.transfer`),其余写入方排队(新增 `queued` 状态,冻结、不超时升级、只能退出);移交或释放/过期/销毁后按先来后到交给队首并改绑协商;排队接手立即落盘;升级冷静期内占用方过期即解冻并通知写入方;新增 `handoffs` / `requeued` 指标;35 项单测、68 项集成检查。
|
|
118
|
+
|
|
119
|
+
- **0.4.1**(2026-09-30):修复误冻结——只有手动 claim 构成冲突与冻结依据,对方仅最近写过(自动 claim)时只发节流的 `recent-edit` 提示;`negotiate offer` 只能针对手动 claim 开桌;看门狗在持有方只剩自动 claim 时收敛协商;新增 `status.json` 运行指标与 `negotiate status` 指标行;profile 改为直接从 plugin-src 安装。
|
|
120
|
+
|
|
121
|
+
- **0.4.0**(2026-09-30,审查修复):争议期单路径冻结(`tools.guard`);自动登记不降级手动 claim;取消基于内存 live 列表的孤儿清理,改为子代理 `session/disposed`;消息信封中和 + 限速 + 同工作区;自动通知不唤醒空闲/冷会话;长约定不被剪枝;限速改按发送方;单一落盘入口;load 完成后才激活;按 profile 分目录 + 宿主所有权锁;realpath 身份键;`str_replace_editor` 纳入;终态冷静期;工作区外写入不登记;拆分为 9 个模块;Schema 单一来源 + `--check`;23 项单测 + 42 项集成探针。
|
|
122
|
+
- 0.3.1:`negotiate` render 带出 detail(neg id / lastOffer / 释放时间)。
|
|
123
|
+
- 0.3.0:多轮协商状态机 + 看门狗。
|
|
124
|
+
- 0.2.0:L0 自动登记、L1 自动通知、L2 硬闸门(默认关)、systemPrompt 提醒。
|
|
125
|
+
- 0.1.0:`claim_files` / `release_files` / `send_to_session`。
|
package/coordinator.js
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 协调核心:写入意图观测(L0 自动登记)→ 冲突处理(孤儿清理 / 开协商桌 / 通知)→ 看门狗。
|
|
3
|
+
*
|
|
4
|
+
* 审查修复:
|
|
5
|
+
* - H2:自动登记走 registry.claim(origin:'auto'),只延长不降级手动 claim。
|
|
6
|
+
* - H3:孤儿清理只针对「可证明已销毁」的会话——子代理会话(宿主 subagent 路由所有)在
|
|
7
|
+
* session/disposed 时清理;持久会话不在内存 ≠ 已死(可冷恢复),一律只靠 TTL / 协商 / 人工。
|
|
8
|
+
* 冲突时若持有方是不在内存的子代理,也视为已销毁(子代理不可冷恢复被外部驱动)。
|
|
9
|
+
* - L3:同 (path, pair) 终态协商冷静期内不重开桌、不重复通知。
|
|
10
|
+
* - L4:只登记会话工作区根(含 git 根)内的写入,/tmp、~/.dsh 等工具性写入不进台账。
|
|
11
|
+
* - 自动通知不唤醒空闲/冷会话(M5,见 delivery.notify)。
|
|
12
|
+
* - v0.4.1:只有他人的**手动** claim 才构成冲突(开协商 + 争议冻结);他人的自动 claim
|
|
13
|
+
* 只是「最近写过」,写入照常登记,只给写入方一条节流的重叠提示。
|
|
14
|
+
* - v0.4.2 多写入方:谈成即**原子移交**占用给谈成方(registry.transfer),同文件其余写入方
|
|
15
|
+
* 转入 queued 并告知排队位次;移交完成或占用方释放/过期/被销毁时,队首改绑到新占用方
|
|
16
|
+
* 重新 open(advanceQueue)。占用方主动释放时同样直接交给队首,不留「谁抢到算谁的」窗口。
|
|
17
|
+
*/
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
19
|
+
import { ENVELOPE_TAG, createThrottle, intentInfo, isWithin, iso, labelFor, shortId } from './util.js';
|
|
20
|
+
import { PEER_NOTICE, workspaceRoot } from './delivery.js';
|
|
21
|
+
|
|
22
|
+
export function createCoordinator({ registry, negotiations, delivery, logger, metrics, settings }) {
|
|
23
|
+
const recentIntents = new Map();
|
|
24
|
+
const isDuplicateIntent = (key) => {
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
const last = recentIntents.get(key) || 0;
|
|
27
|
+
if (now - last < 1000) return true;
|
|
28
|
+
recentIntents.set(key, now);
|
|
29
|
+
if (recentIntents.size > 512) {
|
|
30
|
+
for (const [k, at] of recentIntents) if (now - at >= 1000) recentIntents.delete(k);
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const persistClaims = () =>
|
|
36
|
+
registry.persist().catch((error) => logger?.warn?.(`[session-messenger] claims persist failed: ${(error && error.message) || error}`));
|
|
37
|
+
const persistNegotiations = () => {
|
|
38
|
+
if (!negotiations) return;
|
|
39
|
+
negotiations.persist().catch((error) => logger?.warn?.(`[session-messenger] negotiations persist failed: ${(error && error.message) || error}`));
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ------------------------------------------------------------------ 渲染
|
|
43
|
+
const labelOf = (neg, id) => (neg.labels && neg.labels[id]) || shortId(id);
|
|
44
|
+
const offerLine = (neg) => {
|
|
45
|
+
const o = neg.lastOffer;
|
|
46
|
+
if (!o || !o.terms) return 'none';
|
|
47
|
+
return `${o.terms.action}${o.terms.at ? '@' + iso(o.terms.at) : ''} by ${labelOf(neg, o.by)}${o.message ? ` — "${o.message.slice(0, 200)}"` : ''}`;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const renderNegotiation = (neg, forId) => {
|
|
51
|
+
const peerId = forId === neg.a ? neg.b : neg.a;
|
|
52
|
+
const you = forId === neg.holder ? 'holder' : forId === neg.writer ? 'writer' : 'party';
|
|
53
|
+
const lines = [
|
|
54
|
+
`${ENVELOPE_TAG} · negotiate]]`,
|
|
55
|
+
`notice: ${PEER_NOTICE}`,
|
|
56
|
+
`neg: ${neg.id}`,
|
|
57
|
+
`path: ${neg.path}`,
|
|
58
|
+
`you: ${you} (${labelOf(neg, forId)})`,
|
|
59
|
+
`peer: ${labelOf(neg, peerId)} (session ${peerId})`,
|
|
60
|
+
`state: ${neg.state}${neg.resolution ? ` — ${neg.resolution}` : ''}`,
|
|
61
|
+
`round: ${neg.rounds}/${negotiations ? negotiations.maxRounds : '?'}`,
|
|
62
|
+
`deadline: ${iso(neg.deadline)}`,
|
|
63
|
+
`lastOffer: ${offerLine(neg)}`
|
|
64
|
+
];
|
|
65
|
+
const call = (action, extra = '') => ` negotiate({action:"${action}", corr:"${neg.id}", path:${JSON.stringify(neg.path)}${extra}})`;
|
|
66
|
+
const ask = [];
|
|
67
|
+
if (neg.state === 'open') {
|
|
68
|
+
const offer = neg.lastOffer;
|
|
69
|
+
const mine = offer && offer.by === forId;
|
|
70
|
+
if (you === 'holder' && (!offer || mine)) {
|
|
71
|
+
ask.push('你是该文件的占用方,写入方的后续写入已被冻结。请给条件或明确拒绝:');
|
|
72
|
+
ask.push(`${call('offer', ', terms:{action:"release-now"}')} 立即让出`);
|
|
73
|
+
ask.push(`${call('offer', ', terms:{action:"release-at", at:"<ISO,60 分钟内>"}')} 约定时刻让出`);
|
|
74
|
+
ask.push(`${call('decline')} 不让出(对方须退让或人工裁决)`);
|
|
75
|
+
} else if (you === 'holder') {
|
|
76
|
+
ask.push('写入方提出条件,你可以:');
|
|
77
|
+
ask.push(`${call('accept')} 接受`);
|
|
78
|
+
ask.push(`${call('counter', ', terms:{action:"release-at", at:"<ISO>"}')} 还价`);
|
|
79
|
+
ask.push(`${call('decline')} 拒绝`);
|
|
80
|
+
} else if (!offer || mine) {
|
|
81
|
+
ask.push('你是写入方:该文件由占用方持有,在协商结束前你对它的写入会被拒绝。你可以:');
|
|
82
|
+
ask.push(`${call('offer', ', terms:{action:"wait-until", at:"<ISO,60 分钟内>"}')} 承诺等到某刻再改`);
|
|
83
|
+
ask.push(`${call('decline')} 放弃该文件,先做别的`);
|
|
84
|
+
} else {
|
|
85
|
+
ask.push('占用方提出条件,你可以:');
|
|
86
|
+
ask.push(`${call('accept')} 接受`);
|
|
87
|
+
ask.push(`${call('counter', ', terms:{action:"wait-until", at:"<ISO>"}')} 还价`);
|
|
88
|
+
ask.push(`${call('decline')} 拒绝(请改别的文件)`);
|
|
89
|
+
}
|
|
90
|
+
} else if (neg.state === 'queued') {
|
|
91
|
+
const pending = negotiations && negotiations.pendingHandoff(neg.key);
|
|
92
|
+
const pos = negotiations ? negotiations.positionOf(neg, Date.now()) : 0;
|
|
93
|
+
const to = pending ? labelOf(pending, pending.handoffTo) : '下一位';
|
|
94
|
+
ask.push(
|
|
95
|
+
`排队中${pos ? `(第 ${pos} 位)` : ''}:占用方已约定把该文件移交给 ${to}${pending ? `(${iso(pending.pendingReleaseAt)})` : ''}。` +
|
|
96
|
+
'在此期间你对该文件的写入仍被冻结;移交完成后你会收到通知,届时改与新占用方协商。不想等可以:'
|
|
97
|
+
);
|
|
98
|
+
ask.push(`${call('decline')} 退出排队,先做别的`);
|
|
99
|
+
} else if (neg.state === 'escalated') {
|
|
100
|
+
ask.push('协商未收敛,已请求人工裁决。默认结论:占用方保留 claim(写入方在冷静期内对该文件的写入仍被冻结)。请向用户说明情况,或改做别的文件。');
|
|
101
|
+
} else if (neg.state === 'accepted') {
|
|
102
|
+
const toMe = neg.handoffTo && neg.handoffTo === forId;
|
|
103
|
+
if (neg.pendingReleaseAt && neg.handoffTo) {
|
|
104
|
+
ask.push(toMe ? `已达成:占用将在 ${iso(neg.pendingReleaseAt)} 直接移交给你,届时可直接写入(无需再 claim)。` : `已达成:你将在 ${iso(neg.pendingReleaseAt)} 把该文件的占用移交给 ${labelOf(neg, neg.handoffTo)}。`);
|
|
105
|
+
} else if (neg.pendingWakeAt) {
|
|
106
|
+
ask.push(`已达成:写入方等待至 ${iso(neg.pendingWakeAt)},到点会收到重试提醒。`);
|
|
107
|
+
} else if (neg.handoffTo) {
|
|
108
|
+
ask.push(toMe ? '已达成:该文件的占用已移交给你,可以直接写入;用完请 release_files(排队的会话会自动接手)。' : `已达成:该文件的占用已移交给 ${labelOf(neg, neg.handoffTo)}。`);
|
|
109
|
+
} else {
|
|
110
|
+
ask.push('已达成:占用已释放,可继续。');
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
ask.push(`协商已结束(${neg.resolution || neg.state})。`);
|
|
114
|
+
}
|
|
115
|
+
return [...lines, '', ...ask].join('\n');
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const notifyNegotiation = (neg, sessionId) => {
|
|
119
|
+
if (!sessionId) return;
|
|
120
|
+
const outcome = delivery.notify(sessionId, renderNegotiation(neg, sessionId));
|
|
121
|
+
metrics.notifies += 1;
|
|
122
|
+
if (outcome === 'skipped-cold') metrics.notifySkippedCold += 1;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const autoEnvelope = (type, lines) =>
|
|
126
|
+
[`${ENVELOPE_TAG} · auto]]`, `notice: ${PEER_NOTICE}`, `corr: ${randomUUID().slice(0, 8)}`, `type: ${type}`, ...lines].join('\n');
|
|
127
|
+
|
|
128
|
+
// --------------------------------------------------------------- 协商动作
|
|
129
|
+
const openNegotiation = (entry, holderId, holderLabel, writerId, writerLabel, now, writerMeta = {}) => {
|
|
130
|
+
if (!negotiations) return { neg: null, created: false };
|
|
131
|
+
const res = negotiations.open({
|
|
132
|
+
writerMeta,
|
|
133
|
+
id: randomUUID().slice(0, 8),
|
|
134
|
+
path: entry.path,
|
|
135
|
+
key: entry.key,
|
|
136
|
+
a: holderId,
|
|
137
|
+
b: writerId,
|
|
138
|
+
labels: { [holderId]: holderLabel, [writerId]: writerLabel },
|
|
139
|
+
holder: holderId,
|
|
140
|
+
writer: writerId,
|
|
141
|
+
now
|
|
142
|
+
});
|
|
143
|
+
if (res.created) {
|
|
144
|
+
metrics.negotiationsOpened += 1;
|
|
145
|
+
persistNegotiations();
|
|
146
|
+
}
|
|
147
|
+
return res;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const releaseClaim = (sessionId, key) => {
|
|
151
|
+
const count = registry.release(sessionId, [key]);
|
|
152
|
+
if (count > 0) {
|
|
153
|
+
metrics.releases += 1;
|
|
154
|
+
persistClaims();
|
|
155
|
+
}
|
|
156
|
+
return count > 0;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/** 队列中某一方作为 claim 接收方的身份。 */
|
|
160
|
+
const recipientOf = (neg) => {
|
|
161
|
+
const meta = neg.writerMeta || {};
|
|
162
|
+
return {
|
|
163
|
+
sessionId: neg.writer,
|
|
164
|
+
parent: meta.parent || '',
|
|
165
|
+
label: labelOf(neg, neg.writer),
|
|
166
|
+
cwd: meta.cwd || delivery.sessionCwd(neg.writer),
|
|
167
|
+
sessionOrigin: meta.origin === 'subagent' ? 'subagent' : 'session'
|
|
168
|
+
};
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 队列推进:key 的占用刚交到 holderId 手上(移交完成)或刚空出来(holderId 为空),
|
|
173
|
+
* 把其余排队者改绑到新占用方并重新 open;路径空出时由队首直接接手。
|
|
174
|
+
* @param doneId - 刚完成的那份协商(不再参与)
|
|
175
|
+
*/
|
|
176
|
+
const advanceQueue = (key, holderId, holderLabel, now, doneId = '') => {
|
|
177
|
+
if (!negotiations) return;
|
|
178
|
+
let queue = negotiations.queueFor(key, now, doneId).filter((n) => n.writer !== holderId);
|
|
179
|
+
// 已被销毁的子代理不能接手
|
|
180
|
+
queue = queue.filter((n) => {
|
|
181
|
+
if ((n.writerMeta || {}).origin !== 'subagent' || delivery.liveAgent(n.writer)) return true;
|
|
182
|
+
negotiations.mark(n, 'resolved', 'writer subagent is gone', now);
|
|
183
|
+
return false;
|
|
184
|
+
});
|
|
185
|
+
let currentHolder = holderId;
|
|
186
|
+
let currentLabel = holderLabel;
|
|
187
|
+
if (!currentHolder && queue.length > 0) {
|
|
188
|
+
// 路径空出:队首直接接手(先登记 claim,再通知)。
|
|
189
|
+
const head = queue.shift();
|
|
190
|
+
const who = recipientOf(head);
|
|
191
|
+
// 接手者可能已经转去做别的:给短 TTL(同自动登记),它真要长期改再自行 claim_files 延长。
|
|
192
|
+
const got = registry.claim({ ...who, entries: [{ path: head.path, key }], ttlSeconds: settings.autoClaimTtlSeconds, note: 'took over from the queue', origin: 'manual', now });
|
|
193
|
+
if (got.registered) {
|
|
194
|
+
// 接手必须立刻落盘:看门狗路径(占用过期)不经过任何工具,没有别处会替它 persist,
|
|
195
|
+
// 否则重启后接手记录丢失、队首又变回「等待」。
|
|
196
|
+
persistClaims();
|
|
197
|
+
negotiations.mark(head, 'accepted', `${who.label} took over the file from the queue`, now);
|
|
198
|
+
head.handoffTo = head.writer;
|
|
199
|
+
head.pendingWakeAt = 0;
|
|
200
|
+
metrics.handoffs += 1;
|
|
201
|
+
notifyNegotiation(head, head.writer);
|
|
202
|
+
currentHolder = head.writer;
|
|
203
|
+
currentLabel = who.label;
|
|
204
|
+
} else {
|
|
205
|
+
queue.unshift(head);
|
|
206
|
+
const other = got.conflicts[0];
|
|
207
|
+
currentHolder = other ? other.ownerSessionId : '';
|
|
208
|
+
currentLabel = other ? other.ownerLabel : '';
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (!currentHolder) return;
|
|
212
|
+
for (const n of queue) {
|
|
213
|
+
if (n.writer === currentHolder) continue;
|
|
214
|
+
negotiations.rebind(n, currentHolder, currentLabel, now);
|
|
215
|
+
metrics.requeued += 1;
|
|
216
|
+
notifyNegotiation(n, n.writer);
|
|
217
|
+
}
|
|
218
|
+
if (queue.length > 0) {
|
|
219
|
+
const first = queue.find((n) => n.writer !== currentHolder);
|
|
220
|
+
if (first) notifyNegotiation(first, currentHolder);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/** 执行一次约定的移交:claim 原子转给谈成方,队列改绑到新占用方。返回是否已移交。 */
|
|
225
|
+
const executeHandoff = (neg, now) => {
|
|
226
|
+
const who = recipientOf(neg);
|
|
227
|
+
const moved = registry.transfer(neg.holder, neg.key, who, now);
|
|
228
|
+
if (!moved) {
|
|
229
|
+
// 占用方已经不持有(提前释放/过期):按路径空出处理,谈成方排在最前。
|
|
230
|
+
advanceQueue(neg.key, '', '', now, '');
|
|
231
|
+
persistClaims();
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
metrics.handoffs += 1;
|
|
235
|
+
persistClaims();
|
|
236
|
+
advanceQueue(neg.key, who.sessionId, who.label, now, neg.id);
|
|
237
|
+
return true;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/** 协商副作用执行。供 negotiate 工具与看门狗共用。 */
|
|
241
|
+
const applyEffects = (neg, effects, now) => {
|
|
242
|
+
if (!effects) return false;
|
|
243
|
+
if (effects.handoff) {
|
|
244
|
+
if (effects.releaseAt && effects.releaseAt <= now) return executeHandoff(neg, now);
|
|
245
|
+
// 约定到点移交:其余写入方先转入排队并告知位次。
|
|
246
|
+
for (const other of negotiations ? negotiations.queueOthers(neg, now) : []) notifyNegotiation(other, other.writer);
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
if (effects.releaseSessionId && effects.releaseAt && effects.releaseAt <= now) {
|
|
250
|
+
return releaseClaim(effects.releaseSessionId, neg.key);
|
|
251
|
+
}
|
|
252
|
+
return false;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/** 占用方主动释放(release_files)后:该路径上若有人排队,直接交给队首。 */
|
|
256
|
+
const handleReleased = (sessionId, keys, now) => {
|
|
257
|
+
if (!negotiations) return;
|
|
258
|
+
for (const key of keys) {
|
|
259
|
+
if (registry.othersOn(key, '', now).some((c) => c.origin === 'manual')) continue;
|
|
260
|
+
const waiting = negotiations.queueFor(key, now).filter((n) => n.holder === sessionId || n.state === 'queued');
|
|
261
|
+
if (waiting.length === 0) continue;
|
|
262
|
+
for (const n of waiting) if (n.state === 'open' && n.holder === sessionId) n.state = 'queued';
|
|
263
|
+
advanceQueue(key, '', '', now);
|
|
264
|
+
}
|
|
265
|
+
persistNegotiations();
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// ------------------------------------------------------------------ L0/L1
|
|
269
|
+
const inScope = (info) => {
|
|
270
|
+
if (!settings.scopeToWorkspace) return true;
|
|
271
|
+
const root = workspaceRoot(info.cwd);
|
|
272
|
+
return !!root && isWithin(info.absPath, root);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const handleWriteIntent = (kind, target, actor) => {
|
|
276
|
+
const info = intentInfo(target, actor);
|
|
277
|
+
if (!info || !info.sessionId) {
|
|
278
|
+
metrics.unattributed += 1;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (!inScope(info)) {
|
|
282
|
+
metrics.outOfScope += 1;
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (isDuplicateIntent(`${kind}|${info.sessionId}|${info.key}`)) return;
|
|
286
|
+
const writerId = info.sessionId;
|
|
287
|
+
const writerCwd = info.cwd || delivery.sessionCwd(writerId);
|
|
288
|
+
const writerLabel = labelFor(writerId, writerCwd);
|
|
289
|
+
const entry = { path: info.absPath, key: info.key };
|
|
290
|
+
const now = Date.now();
|
|
291
|
+
const result = registry.claim({
|
|
292
|
+
sessionId: writerId,
|
|
293
|
+
parent: info.parent,
|
|
294
|
+
label: writerLabel,
|
|
295
|
+
cwd: writerCwd,
|
|
296
|
+
sessionOrigin: info.origin,
|
|
297
|
+
entries: [entry],
|
|
298
|
+
ttlSeconds: settings.autoClaimTtlSeconds,
|
|
299
|
+
note: `auto:${kind}`,
|
|
300
|
+
origin: 'auto',
|
|
301
|
+
now
|
|
302
|
+
});
|
|
303
|
+
if (result.registered) {
|
|
304
|
+
metrics.autoClaims += 1;
|
|
305
|
+
persistClaims();
|
|
306
|
+
if (result.overlaps && result.overlaps.length > 0) notifyOverlap(entry, result.overlaps[0], writerId, writerLabel);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const conflict = result.conflicts[0];
|
|
310
|
+
if (!conflict) return;
|
|
311
|
+
metrics.conflicts += 1;
|
|
312
|
+
significant();
|
|
313
|
+
logger?.warn?.(
|
|
314
|
+
`[session-messenger] CONFLICT(auto): ${writerLabel} writing ${entry.path} claimed by ${conflict.ownerLabel} (session ${conflict.ownerSessionId}, ${conflict.ownerOrigin})`
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
// H3:只有「可证明已销毁」的持有方才清理——子代理会话且已不在内存。
|
|
318
|
+
const holderClaim = registry.own(conflict.ownerSessionId, entry.key);
|
|
319
|
+
const holderIsSubagent = holderClaim && holderClaim.sessionOrigin === 'subagent';
|
|
320
|
+
if (holderIsSubagent && !delivery.liveAgent(conflict.ownerSessionId) && !safeSessionLive(conflict.ownerSessionId)) {
|
|
321
|
+
if (releaseClaim(conflict.ownerSessionId, entry.key)) {
|
|
322
|
+
metrics.orphanCleared += 1;
|
|
323
|
+
logger?.warn?.(`[session-messenger] orphan claim cleared: subagent ${conflict.ownerLabel} is disposed; ${entry.path} is free`);
|
|
324
|
+
// 被清的是已销毁子代理的 claim:写入方本次写入已放行,补登记它的自动 claim。
|
|
325
|
+
registry.claim({ sessionId: writerId, parent: info.parent, label: writerLabel, cwd: writerCwd, sessionOrigin: info.origin, entries: [entry], ttlSeconds: settings.autoClaimTtlSeconds, note: `auto:${kind}`, origin: 'auto', now });
|
|
326
|
+
persistClaims();
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (!settings.autoNotify) return;
|
|
332
|
+
if (negotiations) {
|
|
333
|
+
// L3:冷静期内对同一对不重开桌(沉默/拒绝后反复打扰持有方是这类协议的经典死法)。
|
|
334
|
+
if (negotiations.recentTerminalForPair(entry.key, conflict.ownerSessionId, writerId, now)) {
|
|
335
|
+
metrics.cooldownSuppressed += 1;
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const { neg, created } = openNegotiation(entry, conflict.ownerSessionId, conflict.ownerLabel, writerId, writerLabel, now, {
|
|
339
|
+
parent: info.parent,
|
|
340
|
+
cwd: writerCwd,
|
|
341
|
+
origin: info.origin
|
|
342
|
+
});
|
|
343
|
+
if (neg && created) {
|
|
344
|
+
// 已约定移交时新来者直接排队:只通知它本人,不打扰占用方。
|
|
345
|
+
if (neg.state !== 'queued') notifyNegotiation(neg, neg.holder);
|
|
346
|
+
notifyNegotiation(neg, neg.writer);
|
|
347
|
+
}
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
// 协商层关闭时的降级:纯告警(同样不唤醒空闲/冷会话)。
|
|
351
|
+
delivery.notify(
|
|
352
|
+
conflict.ownerSessionId,
|
|
353
|
+
autoEnvelope('conflict-alert', [
|
|
354
|
+
`path: ${entry.path}`,
|
|
355
|
+
`holder: ${conflict.ownerLabel} (session ${conflict.ownerSessionId}) — this is you`,
|
|
356
|
+
`writer: ${writerLabel} (session ${writerId})`,
|
|
357
|
+
'',
|
|
358
|
+
`会话 ${writerLabel} 正在写入你占用的文件(claim note: ${conflict.note || 'n/a'})。如需协调,请用 send_to_session 回复写入方。`
|
|
359
|
+
])
|
|
360
|
+
);
|
|
361
|
+
delivery.notify(
|
|
362
|
+
writerId,
|
|
363
|
+
autoEnvelope('conflict-alert', [
|
|
364
|
+
`path: ${entry.path}`,
|
|
365
|
+
`holder: ${conflict.ownerLabel} (session ${conflict.ownerSessionId})`,
|
|
366
|
+
`writer: ${writerLabel} — this is you`,
|
|
367
|
+
'',
|
|
368
|
+
`你正在写入的文件已被会话 ${conflict.ownerLabel} claim(note: ${conflict.note || 'n/a'},至 ${iso(conflict.expiresAt)})。请先与对方协商,或调整改动范围。`
|
|
369
|
+
])
|
|
370
|
+
);
|
|
371
|
+
metrics.notifies += 2;
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* 重叠提示(v0.4.1):对方只是最近写过同一文件(自动 claim,无占用声明)。
|
|
376
|
+
* 不开协商、不冻结,只给写入方一条低优先级提示,让它知道有人刚动过这个文件;
|
|
377
|
+
* 每 (写入方, 路径, 对方) 冷却窗口内最多一次,且不唤醒空闲/冷会话(delivery.notify)。
|
|
378
|
+
*/
|
|
379
|
+
const overlapThrottle = createThrottle();
|
|
380
|
+
const significant = () => {
|
|
381
|
+
try {
|
|
382
|
+
if (typeof api.onSignificant === 'function') api.onSignificant();
|
|
383
|
+
} catch {
|
|
384
|
+
/* status is best-effort */
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
const notifyOverlap = (entry, other, writerId, writerLabel) => {
|
|
388
|
+
metrics.overlaps += 1;
|
|
389
|
+
significant();
|
|
390
|
+
if (!settings.autoNotify) return;
|
|
391
|
+
if (!overlapThrottle(`${writerId}|${entry.key}|${other.ownerSessionId}`, settings.overlapCooldownMs)) return;
|
|
392
|
+
delivery.notify(
|
|
393
|
+
writerId,
|
|
394
|
+
autoEnvelope('recent-edit', [
|
|
395
|
+
`path: ${entry.path}`,
|
|
396
|
+
`other: ${other.ownerLabel} (session ${other.ownerSessionId}) — edited it recently (no claim), until ${iso(other.expiresAt)}`,
|
|
397
|
+
`writer: ${writerLabel} — this is you`,
|
|
398
|
+
'',
|
|
399
|
+
'提示:另一个会话最近改过这个文件(它没有声明占用,你的写入已正常完成、不会被拦)。如果你们可能在改同一处逻辑,可用 send_to_session 知会对方,或先 claim_files 声明意图。无需回复。'
|
|
400
|
+
])
|
|
401
|
+
);
|
|
402
|
+
metrics.notifies += 1;
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const safeSessionLive = (sessionId) => {
|
|
406
|
+
try {
|
|
407
|
+
return delivery.liveSessions().some((s) => s.id === sessionId);
|
|
408
|
+
} catch {
|
|
409
|
+
return true; // 未知 = 视为存活,绝不据未知做删除
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
/** H3:子代理会话被宿主销毁时,其 claim 与开放协商立即收尾。持久会话的 disposed 只是卸载,不清理。 */
|
|
414
|
+
const handleSessionDisposed = (session) => {
|
|
415
|
+
const header = (session && session.header) || {};
|
|
416
|
+
const id = (session && session.id) || header.id;
|
|
417
|
+
if (!id || !(header.origin === 'subagent' || header.parentSession !== undefined)) return;
|
|
418
|
+
const count = registry.release(String(id), null);
|
|
419
|
+
if (count > 0) {
|
|
420
|
+
metrics.orphanCleared += count;
|
|
421
|
+
persistClaims();
|
|
422
|
+
logger?.info?.(`[session-messenger] subagent ${shortId(id)} disposed; released ${count} claim(s)`);
|
|
423
|
+
}
|
|
424
|
+
if (!negotiations) return;
|
|
425
|
+
const now = Date.now();
|
|
426
|
+
const freedKeys = new Set();
|
|
427
|
+
for (const neg of negotiations.openForSession(String(id))) {
|
|
428
|
+
if (neg.holder === id) {
|
|
429
|
+
freedKeys.add(neg.key); // 占用方没了:交给队列,不直接结束对方的等待
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
negotiations.mark(neg, 'resolved', `party ${labelOf(neg, id)} was disposed`, now);
|
|
433
|
+
notifyNegotiation(neg, neg.a === id ? neg.b : neg.a);
|
|
434
|
+
}
|
|
435
|
+
for (const neg of negotiations.negotiations.values()) {
|
|
436
|
+
if (neg.state === 'accepted' && neg.pendingReleaseAt && (neg.holder === id || neg.handoffTo === id)) {
|
|
437
|
+
neg.pendingReleaseAt = 0;
|
|
438
|
+
negotiations.mark(neg, 'resolved', `party ${labelOf(neg, id)} was disposed before the handoff`, now);
|
|
439
|
+
freedKeys.add(neg.key);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
for (const key of freedKeys) {
|
|
443
|
+
for (const n of negotiations.queueFor(key, now)) if (n.holder === id && n.state === 'open') n.state = 'queued';
|
|
444
|
+
advanceQueue(key, '', '', now);
|
|
445
|
+
}
|
|
446
|
+
persistNegotiations();
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
// ------------------------------------------------------------------ 看门狗
|
|
450
|
+
const runWatchdogOnce = () => {
|
|
451
|
+
if (!negotiations || !negotiations.ready || !registry.ready) return;
|
|
452
|
+
const now = Date.now();
|
|
453
|
+
try {
|
|
454
|
+
const { escalate, dueReleases, dueWakes } = negotiations.scan(now);
|
|
455
|
+
for (const neg of escalate) {
|
|
456
|
+
metrics.escalations += 1;
|
|
457
|
+
logger?.warn?.(`[session-messenger] negotiation ${neg.id} escalated (${neg.resolution}) on ${neg.path}`);
|
|
458
|
+
notifyNegotiation(neg, neg.holder);
|
|
459
|
+
notifyNegotiation(neg, neg.writer);
|
|
460
|
+
}
|
|
461
|
+
for (const neg of dueReleases) {
|
|
462
|
+
if (neg.handoffTo) executeHandoff(neg, now);
|
|
463
|
+
else releaseClaim(neg.holder, neg.key);
|
|
464
|
+
logger?.info?.(`[session-messenger] negotiation ${neg.id} applied scheduled ${neg.handoffTo ? 'handoff' : 'release'} for ${neg.path}`);
|
|
465
|
+
notifyNegotiation(neg, neg.holder);
|
|
466
|
+
notifyNegotiation(neg, neg.writer);
|
|
467
|
+
}
|
|
468
|
+
for (const neg of dueWakes) notifyNegotiation(neg, neg.writer);
|
|
469
|
+
// 持有方已不再持有**手动** claim(释放/过期/只剩自动登记)的开放协商收敛为 resolved——冻结随之解除。
|
|
470
|
+
const heldKeys = new Set(
|
|
471
|
+
registry
|
|
472
|
+
.listLive(now)
|
|
473
|
+
.filter((c) => c.origin === 'manual')
|
|
474
|
+
.map((c) => `${c.sessionId}\u0000${c.key}`)
|
|
475
|
+
);
|
|
476
|
+
let resolved = 0;
|
|
477
|
+
const vacated = new Set();
|
|
478
|
+
for (const neg of negotiations.negotiations.values()) {
|
|
479
|
+
if (neg.state !== 'open' || heldKeys.has(`${neg.holder}\u0000${neg.key}`)) continue;
|
|
480
|
+
// 占用方不再持有(过期 / 只剩自动登记):与主动释放同一语义——交给等待中的队首。
|
|
481
|
+
vacated.add(neg.key);
|
|
482
|
+
neg.state = 'queued';
|
|
483
|
+
}
|
|
484
|
+
for (const key of vacated) {
|
|
485
|
+
advanceQueue(key, '', '', now);
|
|
486
|
+
resolved += 1;
|
|
487
|
+
}
|
|
488
|
+
// 升级后仍在冷静期的协商:占用方已不再持有(过期/释放)时,告诉写入方可以写了。
|
|
489
|
+
for (const neg of negotiations.negotiations.values()) {
|
|
490
|
+
if (neg.state !== 'escalated' || heldKeys.has(`${neg.holder}\u0000${neg.key}`)) continue;
|
|
491
|
+
negotiations.mark(neg, 'resolved', 'holder no longer holds the claim after escalation; path is free', now);
|
|
492
|
+
resolved += 1;
|
|
493
|
+
notifyNegotiation(neg, neg.writer);
|
|
494
|
+
}
|
|
495
|
+
const removed = negotiations.prune(now, settings.negRetentionMs);
|
|
496
|
+
if (escalate.length || dueReleases.length || dueWakes.length || resolved || removed) persistNegotiations();
|
|
497
|
+
} catch (error) {
|
|
498
|
+
logger?.warn?.(`[session-messenger] watchdog failed: ${(error && error.message) || error}`);
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
const api = { handleWriteIntent, handleSessionDisposed, handleReleased, runWatchdogOnce, renderNegotiation, notifyNegotiation, openNegotiation, applyEffects, persistClaims, persistNegotiations, onSignificant: undefined, statusText: undefined };
|
|
503
|
+
return api;
|
|
504
|
+
}
|