dsh-mobile-plugin 0.0.0-stage → 0.1.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +128 -2
- package/config/agent-presets/standard/agent.cordis.yml +170 -0
- package/config/agent-presets/standard/preset.yml +3 -0
- package/cordis.mobile-host.yml +48 -0
- package/cordis.patch.yml +21 -0
- package/dsh-mobile-credentials/lib/credentials.js +135 -0
- package/dsh-mobile-credentials/package.json +13 -0
- package/host.js +128 -0
- package/lib/auto-archive.cjs +98 -0
- package/lib/client.js +690 -0
- package/lib/desktop-half-stats.cjs +101 -0
- package/lib/desktop-view.cjs +109 -0
- package/lib/host-guard.cjs +230 -0
- package/lib/host-patches.cjs +46 -0
- package/lib/http/package.json +3 -0
- package/lib/http/routing.js +84 -0
- package/lib/impl.js +406 -0
- package/lib/mirror/pusher.cjs +706 -0
- package/lib/mirror-snapshot.cjs +39 -0
- package/lib/mobile/agent-presets.js +182 -0
- package/lib/mobile/attachments.js +116 -0
- package/lib/mobile/auth.js +78 -0
- package/lib/mobile/completion.js +114 -0
- package/lib/mobile/credentials.js +150 -0
- package/lib/mobile/directory-browse.js +295 -0
- package/lib/mobile/envelope.js +51 -0
- package/lib/mobile/events.js +293 -0
- package/lib/mobile/history-derive.js +608 -0
- package/lib/mobile/host-bridge/endpoints.js +189 -0
- package/lib/mobile/host-bridge/event-synthesis.js +232 -0
- package/lib/mobile/host-bridge/shims.js +218 -0
- package/lib/mobile/host-bridge/transport.js +168 -0
- package/lib/mobile/host-bridge.js +71 -0
- package/lib/mobile/host-settings.js +282 -0
- package/lib/mobile/idempotency.js +78 -0
- package/lib/mobile/jobs.js +97 -0
- package/lib/mobile/local-images.js +153 -0
- package/lib/mobile/package.json +3 -0
- package/lib/mobile/plugins.js +149 -0
- package/lib/mobile/questions.js +71 -0
- package/lib/mobile/references.js +209 -0
- package/lib/mobile/rpc.js +76 -0
- package/lib/mobile/session-cache.js +263 -0
- package/lib/mobile/session-goal.js +44 -0
- package/lib/mobile/session-log.js +94 -0
- package/lib/mobile/session-mirror.js +177 -0
- package/lib/mobile/session-models.js +153 -0
- package/lib/mobile/session-presets.js +52 -0
- package/lib/mobile/session-search.js +65 -0
- package/lib/mobile/session-send.js +278 -0
- package/lib/mobile/session-snapshot.js +142 -0
- package/lib/mobile/sessions.js +462 -0
- package/lib/mobile/skills.js +80 -0
- package/lib/mobile/stream.js +268 -0
- package/lib/mobile/subagents.js +265 -0
- package/lib/mobile/workspaces.js +214 -0
- package/lib/panel/index.html +33 -0
- package/lib/read-receipt.cjs +124 -0
- package/lib/reconnect-scheduler.cjs +37 -0
- package/lib/relay/core.js +77 -0
- package/lib/relay/lan-network.cjs +85 -0
- package/lib/relay/lan-proxy.js +593 -0
- package/lib/relay/package.json +3 -0
- package/lib/relay/pairing-policy.js +45 -0
- package/lib/relay/port-allocator.cjs +60 -0
- package/lib/relay/preview.js +374 -0
- package/lib/relay/protocol.js +177 -0
- package/lib/relay/status.js +145 -0
- package/lib/relay/tunnel.js +721 -0
- package/lib/routes/control-plane.js +311 -0
- package/lib/routes/mobile-api.js +208 -0
- package/lib/routes/package.json +3 -0
- package/lib/session-cleanup.cjs +153 -0
- package/lib/shared/credentials-entry.js +48 -0
- package/lib/shared/dshtoken.js +62 -0
- package/lib/shared/fresh-request.js +35 -0
- package/lib/shared/http-util.js +71 -0
- package/lib/shared/list-revision.js +51 -0
- package/lib/shared/list-signal.js +87 -0
- package/lib/shared/package.json +3 -0
- package/lib/shared/paths.js +27 -0
- package/lib/shared/session-signal.js +110 -0
- package/lib/shared/workspace-guard.js +127 -0
- package/lib/sidecar-facade-worker.cjs +353 -0
- package/lib/sidecar-facade.cjs +141 -0
- package/lib/sidecar-lifecycle.cjs +246 -0
- package/package.json +74 -4
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 镜像推送调度器:把宿主的会话状态推到中转,手机端才看得到列表与消息。
|
|
5
|
+
*
|
|
6
|
+
* 这 200 行原本是 apply() 里最孤立的一块——它自己一整套状态(recordMirrorPush 的三个
|
|
7
|
+
* 字段、bgQueuePos/bgQueueBusy、按会话单飞的在途表),只通过 tunnelConnected() 和
|
|
8
|
+
* sidecar.pushSync() 与外界打交道,混在装配代码里让 apply() 读不出主线。
|
|
9
|
+
*
|
|
10
|
+
* 两条防线的取舍写在这里,别当成冗余删掉:
|
|
11
|
+
* 1. 隧道没连上就不构建快照——worker 的 `/tunnel/send` 在未连接时直接回 503,旧实现
|
|
12
|
+
* 照推不误,500ms 一个 tick 完整构建五份快照再撞一次 503,宿主日志被
|
|
13
|
+
* 「sidecar facade request failed: 503」刷屏(约 2 行/秒),真故障信号被淹没。
|
|
14
|
+
* 2. 空工作区/空会话快照不许广播——「没取到」和「真被删光」在单份快照上都是
|
|
15
|
+
* { ok: true, items: [] },直接放行会让手机侧工作区整行消失、会话全塌进「未分组」,
|
|
16
|
+
* 且中转只附一个 App 不看的 stale 标记,全程零报错。
|
|
17
|
+
*
|
|
18
|
+
* 注意扩展名是 .cjs:包根声明 type=module,而 lib/ 下只有 shared / mobile / relay /
|
|
19
|
+
* routes / http 这几个子目录放了自己的 {"type":"commonjs"},本目录(lib/mirror/)没有 ——
|
|
20
|
+
* 所以放这里的模块必须是 .cjs,才能被同为 CJS 的 mobile/* 与 shared/* 直接 require。
|
|
21
|
+
*
|
|
22
|
+
* 推送成功后要做两件广播(顺序不能反):先进 sessionSignal.bump(正文水位,驱动手机补拉
|
|
23
|
+
* 没打开的会话),再 announceSessionsRevision(清单 revision,驱动列表刷新)。镜像先落地、
|
|
24
|
+
* 广播后发出——反过来的话手机会立刻来取,而取的还是上一份镜像。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
|
|
29
|
+
const sessions = require('../mobile/sessions.js');
|
|
30
|
+
const workspaces = require('../mobile/workspaces.js');
|
|
31
|
+
const { createWorkspaceGuard, createSessionListGuard } = require('../shared/workspace-guard.js');
|
|
32
|
+
const { stableRevision } = require('../shared/list-revision.js');
|
|
33
|
+
const { dshHome } = require('../shared/paths.js');
|
|
34
|
+
const { listSignal: sharedListSignal } = require('../shared/list-signal.js');
|
|
35
|
+
const { sessionSignal: sharedSessionSignal } = require('../shared/session-signal.js');
|
|
36
|
+
const { dropSessionListCache } = require('../mobile/session-cache.js');
|
|
37
|
+
|
|
38
|
+
/** 后台循环每个 tick 补推一个冷会话,保证没人动的会话镜像也不长期不更新。 */
|
|
39
|
+
const HIGH_PRIORITY_WINDOW_MS = 5 * 60 * 1000;
|
|
40
|
+
const BG_TICK_INTERVAL_MS = 500;
|
|
41
|
+
/**
|
|
42
|
+
* 冷会话轮转的最小间隔(清单/工作区都没变时才生效)。
|
|
43
|
+
*
|
|
44
|
+
* 500ms 的 tick 不能省:清单与工作区的内容指纹就是这个节奏读宿主才发现的,
|
|
45
|
+
* App 侧「镜像正常最多旧 500ms」的新鲜度契约(lib/shared/fresh-request.js、
|
|
46
|
+
* lib/relay/lan-proxy.js 的 ?fresh=1、lib/mirror-snapshot.cjs 的 15s TTL)也押在它上面。
|
|
47
|
+
*
|
|
48
|
+
* 但 tick 里最贵的那一段是轮转冷会话:buildSessionData 传进去的是合成 summary
|
|
49
|
+
* (updatedAt = Date.now(),running = true),指纹每次都不同 ⇒ lib/mobile/session-mirror.js
|
|
50
|
+
* 那层 2 分钟缓存永远命中不了,于是**每一轮都真的去宿主拉 200 条消息**,外加统计投影
|
|
51
|
+
* (无缓存)与模型目录;拉完算指纹时又常常因为内容逐字段相同被判重跳过。而「冷会话」
|
|
52
|
+
* 按定义(isHighPriority 之外:没在跑、5 分钟没动过)正文不可能变,每 500ms 重做一遍
|
|
53
|
+
* 纯属白烧宿主 CPU/内存 —— 真机事故里宿主被内存压力拖慢时,这一路正是 504 的主要来源。
|
|
54
|
+
*
|
|
55
|
+
* 所以:清单或工作区指纹一变就照旧立刻整轮重建并推送(新鲜度分毫不动),两者都没变时
|
|
56
|
+
* 轮转退到 5s 一次,它只剩「把还没热身过的冷会话正文补进中转镜像」这一个作用
|
|
57
|
+
* (N 个冷会话最迟 N × 5s 补齐)。5s 必须明显小于 lib/mirror-snapshot.cjs 的
|
|
58
|
+
* MIRROR_SNAPSHOT_TTL_MS(15s),否则 sidecar 的镜像快照会被判过期。
|
|
59
|
+
*/
|
|
60
|
+
const BG_COLD_ROTATE_MIN_INTERVAL_MS = 5000;
|
|
61
|
+
const MAX_CONCURRENT_SESSION_BUILDS = 2;
|
|
62
|
+
const MAX_CONSECUTIVE_EVENT_BUILDS = 3;
|
|
63
|
+
|
|
64
|
+
function createMirrorPusher(deps) {
|
|
65
|
+
const { ctx, tunnelConnected, getSidecar, listSignal = sharedListSignal, sessionSignal = sharedSessionSignal } = deps;
|
|
66
|
+
|
|
67
|
+
// 移动镜像推送状态(供诊断/状态面板观测:最近推送时间 + 结果)
|
|
68
|
+
let mirrorPushAt = 0
|
|
69
|
+
let mirrorPushOk = null
|
|
70
|
+
let mirrorPushError = null
|
|
71
|
+
// 镜像推送状态的唯一记账点。/api/relay/status 的 mirror 字段是运维与 App
|
|
72
|
+
// 诊断「镜像是否还在更新」的唯一抓手,任何一条推送路径漏记都会让它撒谎:
|
|
73
|
+
// 要么「明明在推、pushedAt 却不动」,要么「快照构建失败却毫无信号」。
|
|
74
|
+
const recordMirrorPush = (ok, error) => {
|
|
75
|
+
mirrorPushAt = Date.now()
|
|
76
|
+
mirrorPushOk = ok
|
|
77
|
+
mirrorPushError = error || null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 「正文没读上来,但清单照推了」的诊断出口。**刻意不写 ok/pushedAt**:这两者是
|
|
81
|
+
// 「镜像还在不在更新」的判据,一次成功推送(清单已落地)不该被一条正文缺失翻成
|
|
82
|
+
// 失败——0.1.51 真机上正是这么表现成「mirror.ok 长期 false、镜像其实在更新」的,
|
|
83
|
+
// 反过来也让人误判成「镜像死了」。只留最近一条原因供 stderr 与状态诊断引用。
|
|
84
|
+
let mirrorBodySkipCount = 0
|
|
85
|
+
let mirrorBodySkipReason = null
|
|
86
|
+
const recordMirrorPushSkipped = (reason) => {
|
|
87
|
+
mirrorBodySkipCount += 1
|
|
88
|
+
mirrorBodySkipReason = reason || null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 内容未变的重复推送判重。
|
|
92
|
+
//
|
|
93
|
+
// 后台 500ms tick 每轮都会完整构建 sessions + workspaces(还带一个冷会话的消息
|
|
94
|
+
// 快照),而多数 tick 的内容与上一份**语义相同**:推过去只会让 sidecar 落盘、中转
|
|
95
|
+
// 更新镜像、手机再解一遍同样的 JSON。指纹取语义内容(清单 revision + 每个会话的
|
|
96
|
+
// 消息水位/条数 + 统计投影 + 模型 revision),不含 `at` 之类的时间戳,所以「同一份
|
|
97
|
+
// 数据」永远得到同一个指纹。只记成功推送的指纹:失败的那次必须能在下一 tick 重试。
|
|
98
|
+
let lastPushedFingerprint = null;
|
|
99
|
+
// 「中转镜像里那份清单 / 工作区是哪一版」——同样只记真的推出去(或被判重跳过)的那一版,
|
|
100
|
+
// 供 processBgQueue 的门判断这一轮有没有必要重建再推一份。判据必须是 revision 而不是
|
|
101
|
+
// pushFingerprint:后者含轮转会话的消息水位与统计投影,每轮都不同。
|
|
102
|
+
let lastPushedListRevision = '';
|
|
103
|
+
let lastPushedWorkspacesRevision = '';
|
|
104
|
+
const pushFingerprint = (payload) => {
|
|
105
|
+
const parts = [
|
|
106
|
+
`s:${payload.sessions && payload.sessions.revision ? payload.sessions.revision : ''}`,
|
|
107
|
+
`w:${payload.workspaces && payload.workspaces.revision ? payload.workspaces.revision : ''}`,
|
|
108
|
+
`a:${payload.activeSessionId || ''}`,
|
|
109
|
+
];
|
|
110
|
+
for (const id of Object.keys(payload.messages || {}).sort()) {
|
|
111
|
+
const m = payload.messages[id];
|
|
112
|
+
const lastSeq = m && Number.isFinite(m.lastSeq) ? m.lastSeq : -1;
|
|
113
|
+
const count = m && Array.isArray(m.items) ? m.items.length : -1;
|
|
114
|
+
parts.push(`m:${id}:${lastSeq}:${count}`);
|
|
115
|
+
}
|
|
116
|
+
for (const id of Object.keys(payload.stats || {}).sort()) {
|
|
117
|
+
const s = payload.stats[id];
|
|
118
|
+
parts.push(`t:${id}:${stableRevision(s && s.projections)}`);
|
|
119
|
+
}
|
|
120
|
+
for (const id of Object.keys(payload.models || {}).sort()) {
|
|
121
|
+
const d = payload.models[id];
|
|
122
|
+
parts.push(`d:${id}:${d && d.revision !== undefined ? d.revision : ''}`);
|
|
123
|
+
}
|
|
124
|
+
return parts.join('|');
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// 「清单内容变了,手机该刷新了」的唯一广播点。
|
|
128
|
+
//
|
|
129
|
+
// 判据只看清单自己的 revision,不能复用 pushFingerprint:指纹里还有本轮轮转推送的冷
|
|
130
|
+
// 会话(消息水位 / 统计投影),每 tick 都在变,拿它当变更信号会变成每 500ms 一次的
|
|
131
|
+
// 假变更。revision 是清单内容的哈希(lib/shared/list-revision.js),App 快照里存的
|
|
132
|
+
// 也是同一个值,语义上就是「这份列表换了」。
|
|
133
|
+
let lastSignalRevision = '';
|
|
134
|
+
const sessionsRevisionOf = (body) => (body && typeof body.revision === 'string' ? body.revision : '');
|
|
135
|
+
const announceSessionsRevision = (revision) => {
|
|
136
|
+
if (!revision || revision === lastSignalRevision) return false;
|
|
137
|
+
// 只在真正广播时才前移基准:构建失败、守卫拦下、推送失败这些路径都不该消耗掉
|
|
138
|
+
// 一次变更(否则手机要等到下一次内容变化,可能就是一分钟后)。
|
|
139
|
+
lastSignalRevision = revision;
|
|
140
|
+
// 手机上「我刚在桌面做的动作」也必须立刻可见:丢掉 /sessions 那层 10 秒缓存。
|
|
141
|
+
// 用 drop 而不是 invalidate:后者会抬 completion generation,而清单 revision 在
|
|
142
|
+
// 生成期每 500ms 就变一次,抬 generation 会让 confirmCompletionFromHistory 永远
|
|
143
|
+
// 被跳过(completed / 红点彻底不亮,见 session-cache.js 的注释)。
|
|
144
|
+
dropSessionListCache(ctx);
|
|
145
|
+
listSignal.bump(revision);
|
|
146
|
+
return true;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 快照里「真正装上的消息」的最大 seq。
|
|
151
|
+
*
|
|
152
|
+
* 刻意不用快照自身的 `lastSeq`:那是**事件**水位(turn/end、usage 之类都会推高它),
|
|
153
|
+
* 而 App 的会话缓存水位与 `afterSeq` 的语义都是**消息** seq(见
|
|
154
|
+
* dsh-mobile-plugin/lib/mobile/session-snapshot.js 的 eventSeqOf 过滤,以及
|
|
155
|
+
* app/src/services/conversation-cache.ts 的 cacheWatermark)。用事件水位当通知值,
|
|
156
|
+
* 手机每收到一帧都会判「缓存落后」而白拉一次。
|
|
157
|
+
*/
|
|
158
|
+
const messageWatermarkOf = (body) => {
|
|
159
|
+
const items = body && Array.isArray(body.items) ? body.items : null;
|
|
160
|
+
if (!items) return 0;
|
|
161
|
+
let watermark = 0;
|
|
162
|
+
for (const item of items) {
|
|
163
|
+
const seq = Number(item && item.seq);
|
|
164
|
+
if (Number.isFinite(seq) && seq > watermark) watermark = seq;
|
|
165
|
+
}
|
|
166
|
+
return watermark;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 「这些会话的正文又长了」的唯一广播点。
|
|
171
|
+
*
|
|
172
|
+
* 广播的**不是**「中转镜像里有新正文」,而是「本机这份会话的正文到了这个水位」——
|
|
173
|
+
* 手机拿水位和自己的缓存一比就知道该不该增量拉一次(走局域网直连时读的就是本进程)。
|
|
174
|
+
* 所以这条信号与「镜像有没有推成功」是两件事,不能绑在一起:
|
|
175
|
+
*
|
|
176
|
+
* - 隧道断开(`!tunnelConnected()`)或纯局域网模式下 pushMirrorPayload 会短路,旧实现
|
|
177
|
+
* 把广播放在成功分支里,于是**一帧水位都不发** —— 手机侧非打开会话的「有新内容」
|
|
178
|
+
* 提示只能靠 60s 兜底轮询或用户点开,正好是「点开对话要等一会儿才看到内容」的来源。
|
|
179
|
+
* - 内容未变(指纹相同)时同样不推,但快照里的水位可能已经涨过(上一帧失败过、或
|
|
180
|
+
* 这一帧由 pushMirror 用更新的事件重建)—— 这里补一次广播是幂等的。
|
|
181
|
+
*
|
|
182
|
+
* 幂等由 sessionSignal 保证(同会话水位不变或更低时不广播),所以即使在每 500ms 的
|
|
183
|
+
* tick 上调用也不会变成刷新风暴。顺序上「先落地后广播」在局域网下天然成立:正文读的
|
|
184
|
+
* 就是本进程同一份会话历史。
|
|
185
|
+
*/
|
|
186
|
+
const announceSessionActivity = (messages) => {
|
|
187
|
+
if (!messages || typeof messages !== 'object') return;
|
|
188
|
+
for (const [sessionId, body] of Object.entries(messages)) {
|
|
189
|
+
const watermark = messageWatermarkOf(body);
|
|
190
|
+
if (watermark > 0) sessionSignal.bump(sessionId, watermark);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// 镜像推送的唯一出口:所有 pushSync 都必须经此,保证状态与真实推送一致。
|
|
195
|
+
const pushMirrorPayload = async (payload) => {
|
|
196
|
+
if (!tunnelConnected()) {
|
|
197
|
+
recordMirrorPush(false, '隧道未连接,镜像未推送');
|
|
198
|
+
// 没推成镜像,但「正文长到哪儿了」这件事仍然要说给手机听(见 announceSessionActivity)。
|
|
199
|
+
announceSessionActivity(payload.messages);
|
|
200
|
+
return false
|
|
201
|
+
}
|
|
202
|
+
const listRevision = sessionsRevisionOf(payload.sessions);
|
|
203
|
+
const workspacesRevision = payload.workspaces && typeof payload.workspaces.revision === 'string'
|
|
204
|
+
? payload.workspaces.revision
|
|
205
|
+
: '';
|
|
206
|
+
const fingerprint = pushFingerprint(payload);
|
|
207
|
+
if (fingerprint === lastPushedFingerprint) {
|
|
208
|
+
// 跳过不等于故障:内容与上一份逐字段相同。但 pushedAt 必须继续走,它是运维与
|
|
209
|
+
// App 诊断「镜像是否还在更新」的唯一抓手,停在这里会让它看起来像镜像死了。
|
|
210
|
+
recordMirrorPush(true, '内容未变,跳过重复推送');
|
|
211
|
+
lastPushedListRevision = listRevision;
|
|
212
|
+
lastPushedWorkspacesRevision = workspacesRevision;
|
|
213
|
+
announceSessionActivity(payload.messages);
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
await getSidecar().pushSync(payload)
|
|
218
|
+
} catch (error) {
|
|
219
|
+
recordMirrorPush(false, error instanceof Error ? error.message : String(error))
|
|
220
|
+
throw error
|
|
221
|
+
}
|
|
222
|
+
lastPushedFingerprint = fingerprint;
|
|
223
|
+
recordMirrorPush(true, null)
|
|
224
|
+
// 基准只在真的推进去(或被判重跳过)之后才前移:推送失败必须能在下一轮重试,
|
|
225
|
+
// 否则「清单变了」会被一次失败吃掉,中转镜像永远停在旧版(见 processBgQueue 的门)。
|
|
226
|
+
lastPushedListRevision = listRevision;
|
|
227
|
+
lastPushedWorkspacesRevision = workspacesRevision;
|
|
228
|
+
// 推送成功之后才广播:sidecar 把 payload 写进隧道就回 202,中转稍后才装进
|
|
229
|
+
// inst.mirror,而信号帧走的是同一条隧道 —— 先推后广播,手机上紧跟信号的那次读取
|
|
230
|
+
// 才不会读到还没来得及更新的旧镜像。
|
|
231
|
+
announceSessionsRevision(sessionsRevisionOf(payload.sessions));
|
|
232
|
+
// 上面刻意保留内联形式(而不是传局部 listRevision):`app/scripts/test-session-activity.js`
|
|
233
|
+
// 的跨仓契约按字面钉住「广播的 revision 必须由 payload.sessions 现算」,改写会让它红。
|
|
234
|
+
// 与清单信号同一个「先推后广播」的顺序要求:手机收到这条帧后会立刻带 afterSeq 去
|
|
235
|
+
// /session/messages 增量拉,中转那边必须已经有这份正文(走同一条隧道,只有先推后
|
|
236
|
+
// 广播才能保证)。
|
|
237
|
+
announceSessionActivity(payload.messages);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 基准落盘:插件随桌面 harness 频繁重启,基准只在内存里的话,重启后的第一份快照
|
|
241
|
+
// 正好撞上桌面刚起来的忙乱期(最可能出空的那一段)就会畅通无阻。
|
|
242
|
+
const workspaceGuard = createWorkspaceGuard({
|
|
243
|
+
persistPath: path.join(dshHome(), 'mobile-empty-workspaces.json'),
|
|
244
|
+
})
|
|
245
|
+
const guardWorkspaces = (workspacesBody) => workspaceGuard.check(workspacesBody)
|
|
246
|
+
// 会话列表同样要守:它才是侧边栏的主体内容,宿主降级读一旦回空,App 立刻只剩
|
|
247
|
+
// 设备行(真机现象)。此前只有工作区一侧有防线。
|
|
248
|
+
const sessionListGuard = createSessionListGuard({
|
|
249
|
+
persistPath: path.join(dshHome(), 'mobile-empty-sessions.json'),
|
|
250
|
+
})
|
|
251
|
+
const guardSessions = (sessionsResponse) => {
|
|
252
|
+
if (!sessionsResponse || sessionsResponse.status !== 200) return 'sessions 响应不可用,本次未推送'
|
|
253
|
+
return sessionListGuard.check(sessionsResponse.body)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const isHighPriority = (s) => s && (s.running || (s.updatedAt && Date.now() - s.updatedAt < HIGH_PRIORITY_WINDOW_MS));
|
|
257
|
+
|
|
258
|
+
// Every session snapshot fans out to three Host RPCs. Bound the number of sessions
|
|
259
|
+
// being built globally; event work gets priority, but batch/cold work gets one slot
|
|
260
|
+
// after at most three consecutive event jobs to prevent starvation.
|
|
261
|
+
const sessionBuildQueues = { event: [], batch: [], cold: [] };
|
|
262
|
+
let activeSessionBuilds = 0;
|
|
263
|
+
let consecutiveEventBuilds = 0;
|
|
264
|
+
let lastBackgroundBuildPriority = 'cold';
|
|
265
|
+
const takeNextSessionBuild = () => {
|
|
266
|
+
const { event, batch, cold } = sessionBuildQueues;
|
|
267
|
+
if (event.length && (consecutiveEventBuilds < MAX_CONSECUTIVE_EVENT_BUILDS || (!batch.length && !cold.length))) {
|
|
268
|
+
consecutiveEventBuilds += 1;
|
|
269
|
+
return event.shift();
|
|
270
|
+
}
|
|
271
|
+
if (batch.length || cold.length) {
|
|
272
|
+
consecutiveEventBuilds = 0;
|
|
273
|
+
const priority = batch.length && (!cold.length || lastBackgroundBuildPriority === 'cold') ? 'batch' : 'cold';
|
|
274
|
+
lastBackgroundBuildPriority = priority;
|
|
275
|
+
return sessionBuildQueues[priority].shift();
|
|
276
|
+
}
|
|
277
|
+
if (event.length) {
|
|
278
|
+
consecutiveEventBuilds = 1;
|
|
279
|
+
return event.shift();
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
};
|
|
283
|
+
const drainSessionBuildQueue = () => {
|
|
284
|
+
while (activeSessionBuilds < MAX_CONCURRENT_SESSION_BUILDS) {
|
|
285
|
+
const job = takeNextSessionBuild();
|
|
286
|
+
if (!job) return;
|
|
287
|
+
activeSessionBuilds += 1;
|
|
288
|
+
Promise.resolve().then(job.run).then(job.resolve, job.reject).finally(() => {
|
|
289
|
+
activeSessionBuilds -= 1;
|
|
290
|
+
drainSessionBuildQueue();
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
const withSessionBuildSlot = (priority, run) => new Promise((resolve, reject) => {
|
|
295
|
+
const queue = Object.hasOwn(sessionBuildQueues, priority) ? sessionBuildQueues[priority] : sessionBuildQueues.event;
|
|
296
|
+
queue.push({ run, resolve, reject });
|
|
297
|
+
drainSessionBuildQueue();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* 构建一个会话的镜像数据(正文快照 + 统计投影 + 模型列表)。
|
|
302
|
+
*
|
|
303
|
+
* `summary` 必须是**清单里的那一行**(或它的 Promise):`session-mirror.js` 的后台缓存
|
|
304
|
+
* 指纹就是 `updatedAt:running|idle:limit`,旧实现硬塞 `{ updatedAt: Date.now(), running: true }`,
|
|
305
|
+
* 于是指纹每次都不同、2 分钟 TTL 永不命中。`running` 如实取清单值:高优先判定
|
|
306
|
+
* (isHighPriority)用的就是这个字段,不能在这里改口径。
|
|
307
|
+
*
|
|
308
|
+
* 允许传 Promise 是因为调用点通常与清单构建并行(见 pushSessionNow):为了拿一个真实的
|
|
309
|
+
* updatedAt 把「每条会话事件都走」的热路径改成串行两跳不划算。
|
|
310
|
+
*
|
|
311
|
+
* `reuseCache` 默认 true,但**事件驱动路径必须传 false**:那个缓存只认「宿主抬了
|
|
312
|
+
* updatedAt」这一种内容变化。一条新消息落盘而清单摘要还没变(或宿主不按消息抬
|
|
313
|
+
* updatedAt)时,缓存会把上一份正文当成这一份发出去 —— device-session-activity 的守卫
|
|
314
|
+
* 钉的正是这个场景(新消息必须广播新水位,见 test/device-session-activity.test.cjs)。
|
|
315
|
+
* 实现方式是 session-mirror 的 `noCache`(既不读也不写缓存),不是伪造 updatedAt。
|
|
316
|
+
* 收益落在「没人动的冷会话轮转」:那里每 5s 重建 200 条正文才是成本大头。
|
|
317
|
+
*/
|
|
318
|
+
const buildSessionDataUnlocked = async (sessionId, summaryOrPromise, { reuseCache = true } = {}) => {
|
|
319
|
+
const summary = summaryOrPromise && typeof summaryOrPromise.then === 'function'
|
|
320
|
+
? await summaryOrPromise
|
|
321
|
+
: summaryOrPromise;
|
|
322
|
+
// 清单里没有这一行(比如事件先到、清单还没刷出来)时退回 Date.now():这个值只参与
|
|
323
|
+
// 缓存指纹,不影响正文内容本身。
|
|
324
|
+
const updatedAt = (summary && summary.updatedAt) || Date.now();
|
|
325
|
+
const [messages, stats, models] = await Promise.all([
|
|
326
|
+
sessions.buildMirrorWarmSnapshots(
|
|
327
|
+
ctx,
|
|
328
|
+
[{ sessionId, updatedAt, running: Boolean(summary && summary.running) }],
|
|
329
|
+
sessionId,
|
|
330
|
+
// 事件驱动路径必须**绕开缓存**(noCache),而不是塞一个假 updatedAt 去撞指纹:
|
|
331
|
+
// 指纹是 `updatedAt:running:limit`,同一个毫秒里的两次推送指纹完全相同,照样会
|
|
332
|
+
// 命中上一份正文(test/device-session-activity.test.cjs 真红过这一次)。
|
|
333
|
+
{ maxSessions: 1, limit: 200, noCache: !reuseCache },
|
|
334
|
+
),
|
|
335
|
+
sessions.buildStatsSnapshot(ctx, sessionId),
|
|
336
|
+
sessions.buildModelsSnapshot(ctx, sessionId),
|
|
337
|
+
]);
|
|
338
|
+
const msg = messages?.[sessionId];
|
|
339
|
+
// 读不到这个会话的消息快照**不是致命错误**:正文是镜像里的一个槽位,缺了它最坏是
|
|
340
|
+
// 「这个会话的正文不新鲜」,而整轮抛错会把整份清单/工作区一起挡在门外、连同
|
|
341
|
+
// `device/list-changed` 广播一起丢掉,手机侧表现为「列表停在几分钟前」。
|
|
342
|
+
// 0.1.51 把它改成抛错之后,只要清单里有任何一个会话读不到(真机上长期有:活跃/
|
|
343
|
+
// 子会话在生成期读不到历史),后台 tick 就被失败重试占满、镜像彻底不更新。
|
|
344
|
+
// 失败原因随 `messages._errors` 带出来,状态接口与诊断不再只有一句「snapshot missing」。
|
|
345
|
+
return {
|
|
346
|
+
messages: msg?.ok ? msg : null,
|
|
347
|
+
// 记一笔不推上去的原因,只为诊断与水位广播(见 pushSessionNow 的 skipped 处理)。
|
|
348
|
+
skipReason: msg?.ok
|
|
349
|
+
? null
|
|
350
|
+
: `session message snapshot unavailable for ${sessionId}: ${messages?._errors?.[sessionId] || 'unknown'}`,
|
|
351
|
+
stats: stats?.ok ? stats : null,
|
|
352
|
+
models: models?.ok ? models : null,
|
|
353
|
+
};
|
|
354
|
+
};
|
|
355
|
+
const buildSessionData = (sessionId, summaryOrPromise, { priority = 'event', ...options } = {}) =>
|
|
356
|
+
withSessionBuildSlot(priority, () => buildSessionDataUnlocked(sessionId, summaryOrPromise, options));
|
|
357
|
+
|
|
358
|
+
// ── 失败会话的定向重试 ────────────────────────────────────────────────
|
|
359
|
+
//
|
|
360
|
+
// 为什么需要:pushSessionNow 的失败路径过去只记 stderr,而冷轮转又明确跳过所有高优先
|
|
361
|
+
// 会话(下面的 `isHighPriority(s)` 分支),于是「正在生成的会话推送失败」没有任何补推
|
|
362
|
+
// 途径 —— 它要等到自己变冷(超过 5 分钟没动)再排队等轮转,而轮转每个 5s 窗口只有一个
|
|
363
|
+
// 名额,队列长了就是分钟级。叠加 sidecar-facade 的 5s 超时且零重试,一次抖动就能让
|
|
364
|
+
// 某个会话的镜像停在旧版。
|
|
365
|
+
//
|
|
366
|
+
// 用「到期时间 + 现有 500ms tick」而不是 setTimeout:少一个要 unref 的定时器,退避精度
|
|
367
|
+
// 到 tick 粒度完全够用,也天然随 effect 清理一起停。失败次数放在 Map 里(而不是闭包
|
|
368
|
+
// 变量)是为了让测试与诊断能直接观察到「重试了几次」。
|
|
369
|
+
const sessionPushFailures = new Map();
|
|
370
|
+
const RETRY_BASE_DELAY_MS = 2000;
|
|
371
|
+
const RETRY_MAX_DELAY_MS = 30000;
|
|
372
|
+
const noteSessionPushFailure = (sessionId, error) => {
|
|
373
|
+
const previous = sessionPushFailures.get(sessionId);
|
|
374
|
+
const attempts = (previous ? previous.attempts : 0) + 1;
|
|
375
|
+
const delay = Math.min(RETRY_BASE_DELAY_MS * 2 ** (attempts - 1), RETRY_MAX_DELAY_MS);
|
|
376
|
+
sessionPushFailures.set(sessionId, {
|
|
377
|
+
attempts,
|
|
378
|
+
dueAt: Date.now() + delay,
|
|
379
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
380
|
+
});
|
|
381
|
+
};
|
|
382
|
+
const clearSessionPushFailure = (sessionId) => { sessionPushFailures.delete(sessionId); };
|
|
383
|
+
/** 到期该重试的会话 id(按到期时间升序)。 */
|
|
384
|
+
const dueRetries = (now) => [...sessionPushFailures.entries()]
|
|
385
|
+
.filter(([, entry]) => entry.dueAt <= now)
|
|
386
|
+
.sort((a, b) => a[1].dueAt - b[1].dueAt)
|
|
387
|
+
.map(([sessionId]) => sessionId);
|
|
388
|
+
|
|
389
|
+
const pushSessionNow = (sessionId, priority = 'event') => withSessionBuildSlot(priority, async () => {
|
|
390
|
+
try {
|
|
391
|
+
// 清单与消息快照并行:summary 用 Promise 传进去(见 buildSessionData 的注释),
|
|
392
|
+
// 避免为了一个真实 updatedAt 把热路径改成串行两跳。
|
|
393
|
+
const sessionsPromise = sessions.buildSessionsResponse(ctx, 'legacy');
|
|
394
|
+
const summaryPromise = sessionsPromise.then(
|
|
395
|
+
(body) => (body?.body?.items || []).find((s) => String(s.sessionId) === String(sessionId)),
|
|
396
|
+
);
|
|
397
|
+
const [sessionsBody, workspacesBody, data] = await Promise.all([
|
|
398
|
+
sessionsPromise,
|
|
399
|
+
workspaces.buildWorkspacesBody(ctx),
|
|
400
|
+
// event slot covers the full push; call the unlocked builder to avoid nesting the semaphore.
|
|
401
|
+
buildSessionDataUnlocked(sessionId, summaryPromise, { reuseCache: false }),
|
|
402
|
+
]);
|
|
403
|
+
const wsBlocked = guardWorkspaces(workspacesBody) || guardSessions(sessionsBody)
|
|
404
|
+
if (!(sessionsBody?.status === 200) || wsBlocked) {
|
|
405
|
+
const reason = wsBlocked || 'sessions 快照不可用,本次未推送';
|
|
406
|
+
recordMirrorPush(false, reason);
|
|
407
|
+
noteSessionPushFailure(sessionId, new Error(reason));
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
await pushMirrorPayload({
|
|
411
|
+
kind: 'sync', at: Date.now(),
|
|
412
|
+
activeSessionId: sessionId,
|
|
413
|
+
sessions: sessionsBody.body,
|
|
414
|
+
workspaces: workspacesBody,
|
|
415
|
+
messages: data.messages ? { [sessionId]: data.messages } : {},
|
|
416
|
+
stats: data.stats ? { [sessionId]: data.stats } : {},
|
|
417
|
+
models: data.models ? { [sessionId]: data.models } : {},
|
|
418
|
+
});
|
|
419
|
+
// 没抛错就算这一次过关:内容已推上去、或判重跳过、或隧道没连(局域网模式本来就不推)。
|
|
420
|
+
// 正文读不到(data.skipReason 非空)也走这里:**清单照推**,不带那个会话的正文。
|
|
421
|
+
// 这不算失败,所以不进退避账本——进了的话,一个永久读不到的会话会每 30s 占掉
|
|
422
|
+
// 一次 tick 的额度,把冷轮转和清单推送一起饿死(0.1.51 的真机故障)。
|
|
423
|
+
clearSessionPushFailure(sessionId);
|
|
424
|
+
if (data.skipReason) {
|
|
425
|
+
recordMirrorPushSkipped(data.skipReason);
|
|
426
|
+
process.stderr.write(`[dsh-mobile-plugin] mirror body skipped: ${data.skipReason}\n`);
|
|
427
|
+
}
|
|
428
|
+
} catch (e) {
|
|
429
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
430
|
+
recordMirrorPush(false, message);
|
|
431
|
+
noteSessionPushFailure(sessionId, e);
|
|
432
|
+
process.stderr.write(`[dsh-mobile-plugin] mirror push failed for session ${sessionId}: ${message}\n`);
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// 按会话单飞。同一会话同一时刻只允许一次立即推送在途;在途期间再来事件只置一次待推
|
|
437
|
+
// 标记,等这次推送结束后补推一次。没有它,一轮生成里的几百条事件会叠成几百次
|
|
438
|
+
// 「五份全量快照构建 + 一次 sidecar 推送」,把宿主和 sidecar 一起拖垮。
|
|
439
|
+
const sessionPushInFlight = new Map();
|
|
440
|
+
const sessionPushQueued = new Set();
|
|
441
|
+
const pushSessionImmediately = (sessionId) => {
|
|
442
|
+
const running = sessionPushInFlight.get(sessionId);
|
|
443
|
+
if (running) { sessionPushQueued.add(sessionId); return running; }
|
|
444
|
+
const task = pushSessionNow(sessionId).finally(() => {
|
|
445
|
+
sessionPushInFlight.delete(sessionId);
|
|
446
|
+
if (sessionPushQueued.delete(sessionId)) void pushSessionImmediately(sessionId);
|
|
447
|
+
});
|
|
448
|
+
sessionPushInFlight.set(sessionId, task);
|
|
449
|
+
return task;
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const pushMirror = async (sessionIds) => {
|
|
453
|
+
// 失败时要知道是哪几个会话没推上去(catch 里逐个记一笔,交给 tick 定向补推)。
|
|
454
|
+
let attempted = [];
|
|
455
|
+
try {
|
|
456
|
+
const [sessionsBody, workspacesBody] = await Promise.all([
|
|
457
|
+
sessions.buildSessionsResponse(ctx, 'legacy'),
|
|
458
|
+
workspaces.buildWorkspacesBody(ctx),
|
|
459
|
+
]);
|
|
460
|
+
const wsBlocked = guardWorkspaces(workspacesBody) || guardSessions(sessionsBody)
|
|
461
|
+
if (!(sessionsBody?.status === 200) || wsBlocked) {
|
|
462
|
+
recordMirrorPush(false, wsBlocked || 'sessions 快照不可用,本次未推送')
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
const list = sessionsBody.body.items || [];
|
|
466
|
+
const highPrio = sessionIds
|
|
467
|
+
? list.filter(s => sessionIds.includes(String(s.sessionId)))
|
|
468
|
+
: list.filter(isHighPriority);
|
|
469
|
+
attempted = highPrio;
|
|
470
|
+
const allMessages = {}; const allStats = {}; const allModels = {};
|
|
471
|
+
await Promise.all(highPrio.map(async (s) => {
|
|
472
|
+
const id = String(s.sessionId);
|
|
473
|
+
// 整批高优先会话:host/* 帧或隧道刚连上(补推全量),内容同样可能刚变 —— 不复用缓存。
|
|
474
|
+
const d = await buildSessionData(id, s, { reuseCache: false, priority: 'batch' });
|
|
475
|
+
if (d.messages) allMessages[id] = d.messages;
|
|
476
|
+
if (d.stats) allStats[id] = d.stats;
|
|
477
|
+
if (d.models) allModels[id] = d.models;
|
|
478
|
+
// 正文缺席在批量路径同样要留诊断:否则「整批里某个会话读不到」只表现为镜像里少一份
|
|
479
|
+
// 正文,状态接口一句都看不到(这正是真机上查不出原因的那一类静默)。
|
|
480
|
+
if (d.skipReason) recordMirrorPushSkipped(d.skipReason);
|
|
481
|
+
}));
|
|
482
|
+
const activeId = list.find(s => s.running)?.sessionId || null;
|
|
483
|
+
await pushMirrorPayload({
|
|
484
|
+
kind: 'sync', at: Date.now(),
|
|
485
|
+
activeSessionId: activeId,
|
|
486
|
+
sessions: sessionsBody.body,
|
|
487
|
+
workspaces: workspacesBody,
|
|
488
|
+
messages: allMessages, stats: allStats, models: allModels,
|
|
489
|
+
});
|
|
490
|
+
for (const s of highPrio) clearSessionPushFailure(String(s.sessionId));
|
|
491
|
+
} catch (e) {
|
|
492
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
493
|
+
recordMirrorPush(false, message);
|
|
494
|
+
// 整批失败归因不到单个会话,但这一批确实都没推上去:逐个记一笔,让 500ms tick 的
|
|
495
|
+
// 定向重试把它们(包括高优先会话)一个个补推,而不是等它们变冷再排队。
|
|
496
|
+
for (const s of attempted) noteSessionPushFailure(String(s.sessionId), e);
|
|
497
|
+
process.stderr.write(`[dsh-mobile-plugin] mirror push failed: ${message}\n`);
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
// 主动镜像:事件驱动。会话事件一律立即推(见下方 mux 循环);后台循环每个 tick 再补推一个
|
|
502
|
+
// 冷会话(从 bgQueuePos 起轮转),保证没人动的会话镜像也不会长期不更新。
|
|
503
|
+
let bgQueuePos = 0;
|
|
504
|
+
let bgQueueBusy = false;
|
|
505
|
+
// 上一次真的跑过冷会话轮转的时刻(见 BG_COLD_ROTATE_MIN_INTERVAL_MS)。
|
|
506
|
+
let lastColdRotateAt = 0;
|
|
507
|
+
|
|
508
|
+
const processBgQueue = async () => {
|
|
509
|
+
if (bgQueueBusy) return;
|
|
510
|
+
const online = tunnelConnected();
|
|
511
|
+
// 隧道未连接时这个 500ms 的 tick 只在**有人听**的时候才有意义:构建出来的快照没有
|
|
512
|
+
// 任何去处(pushMirrorPayload 会短路),但「清单变了」这件事仍然要说给手机听 ——
|
|
513
|
+
// 走局域网的手机是直接读本进程的,不需要中转镜像。没有观众就保留原来的空转早退,
|
|
514
|
+
// 省掉构建开销,也避免每 tick 记一次推送失败。
|
|
515
|
+
if (!online && !listSignal.watching()) return;
|
|
516
|
+
bgQueueBusy = true;
|
|
517
|
+
let shouldAnnounceRevision = true;
|
|
518
|
+
try {
|
|
519
|
+
const sessionsBody = await sessions.buildSessionsResponse(ctx, 'legacy');
|
|
520
|
+
// 空列表此前只是静默 return,看不出是「真的没会话」还是「没取到」;这里让守卫
|
|
521
|
+
// 判定并记录理由,别再无声无息。
|
|
522
|
+
const sessionsBlocked = guardSessions(sessionsBody);
|
|
523
|
+
if (sessionsBlocked) { if (online) recordMirrorPush(false, sessionsBlocked); return }
|
|
524
|
+
// 清单内容版本(lib/shared/list-revision.js 的哈希):既是广播判据,也是下面
|
|
525
|
+
// 「中转镜像里那一版是不是这份」的比对基准 —— 全函数只算这一次。
|
|
526
|
+
const revision = sessionsRevisionOf(sessionsBody && sessionsBody.body);
|
|
527
|
+
if (!online) {
|
|
528
|
+
// 离线:只广播「清单变了」,不推送也不记推送状态(否则会变成每 tick 一条假故障)。
|
|
529
|
+
announceSessionsRevision(revision);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const list = sessionsBody?.body?.items || [];
|
|
533
|
+
if (list.length === 0) {
|
|
534
|
+
// 空清单**也要推**。守卫放行的空清单是「用户真的清空了」(body.emptyConfirmed
|
|
535
|
+
// 由 mobile/sessions.js 按 items.length === 0 现算),不推的话中转镜像里那份旧
|
|
536
|
+
// 清单会一直挂到 15s TTL 过期,手机上已删的会话最长还能看到十几秒;而且这条路径
|
|
537
|
+
// 完全不记账,运维看不出镜像为什么不动。
|
|
538
|
+
//
|
|
539
|
+
// 跨包契约:中转侧对「空覆盖非空」有同口径保护,只认 emptyConfirmed === true 的
|
|
540
|
+
// 空清单(dsh-mobile-server/lib/mirror-state.js 的 installMirror,谓词与
|
|
541
|
+
// dsh-mobile-server/server.js:110 一致)。这里推的就是 buildSessionsResponse 的
|
|
542
|
+
// 原样 body,天然带这个字段。
|
|
543
|
+
const workspacesBodyEmpty = await workspaces.buildWorkspacesBody(ctx);
|
|
544
|
+
const wsBlockedEmpty = guardWorkspaces(workspacesBodyEmpty);
|
|
545
|
+
if (wsBlockedEmpty) {
|
|
546
|
+
// 工作区那份快照不可用:别拿一份「没取到」的工作区去覆盖镜像,只广播清单。
|
|
547
|
+
recordMirrorPush(false, wsBlockedEmpty);
|
|
548
|
+
announceSessionsRevision(revision);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
await pushMirrorPayload({
|
|
552
|
+
kind: 'sync', at: Date.now(),
|
|
553
|
+
activeSessionId: null,
|
|
554
|
+
sessions: sessionsBody.body,
|
|
555
|
+
workspaces: workspacesBodyEmpty,
|
|
556
|
+
messages: {}, stats: {}, models: {},
|
|
557
|
+
});
|
|
558
|
+
announceSessionsRevision(revision);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
// 门(见 BG_COLD_ROTATE_MIN_INTERVAL_MS):清单与工作区都还停在中转镜像里那一版、
|
|
562
|
+
// 又不到冷会话轮转时间时,这一轮到此为止 —— 省掉统计/模型投影与一个冷会话的 200 条
|
|
563
|
+
// 正文(那才是这个 tick 的大头)。工作区体提前到这里构建,正是为了拿到它的 revision。
|
|
564
|
+
const workspacesBody = await workspaces.buildWorkspacesBody(ctx);
|
|
565
|
+
const workspacesRevision = workspacesBody && typeof workspacesBody.revision === 'string' ? workspacesBody.revision : '';
|
|
566
|
+
const now = Date.now();
|
|
567
|
+
// 清掉已经不在清单里的会话(归档/删除),免得这张表无限长。
|
|
568
|
+
const hasPendingRetry = sessionPushFailures.size > 0;
|
|
569
|
+
if (hasPendingRetry) {
|
|
570
|
+
const known = new Set(list.map((s) => String(s.sessionId)));
|
|
571
|
+
for (const id of [...sessionPushFailures.keys()]) if (!known.has(id)) sessionPushFailures.delete(id);
|
|
572
|
+
}
|
|
573
|
+
// 失败会话只允许到期后的定向路径重试;不能让它绕过退避落入 500ms 冷轮转。
|
|
574
|
+
// **重试是「额外补一次」,不是「这一轮的替代品」**:旧实现 retry 之后直接 return,
|
|
575
|
+
// 于是只要账本里有一个到期失败会话(真机上是常态——活跃/子会话读不到历史),
|
|
576
|
+
// 这一 tick 就永远停在重试上:冷轮转不跑、整份清单不推,镜像从此不再更新。
|
|
577
|
+
const retryId = dueRetries(now).find((id) => list.some((s) => String(s.sessionId) === id));
|
|
578
|
+
if (retryId) await pushSessionNow(retryId, 'batch');
|
|
579
|
+
const coldRotateDue = now - lastColdRotateAt >= BG_COLD_ROTATE_MIN_INTERVAL_MS;
|
|
580
|
+
if (revision === lastPushedListRevision && workspacesRevision === lastPushedWorkspacesRevision && !coldRotateDue) {
|
|
581
|
+
// 清单与工作区都没变、也还没到轮转时间:这一轮除了刚做的重试什么都不用做。
|
|
582
|
+
if (retryId) announceSessionsRevision(revision);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
lastColdRotateAt = now;
|
|
586
|
+
if (bgQueuePos >= list.length) bgQueuePos = 0;
|
|
587
|
+
for (let i = 0; i < list.length; i++) {
|
|
588
|
+
const idx = (bgQueuePos + i) % list.length;
|
|
589
|
+
const s = list[idx];
|
|
590
|
+
const candidateId = s ? String(s.sessionId) : '';
|
|
591
|
+
// Failed sessions are retried only by dueRetries; cold ticks must not bypass their backoff.
|
|
592
|
+
if (!s || sessionPushFailures.has(candidateId) || isHighPriority(s)) continue;
|
|
593
|
+
const id = candidateId;
|
|
594
|
+
let data;
|
|
595
|
+
try {
|
|
596
|
+
data = await buildSessionData(id, s, { priority: 'cold' });
|
|
597
|
+
} catch (error) {
|
|
598
|
+
// 单个会话构建失败只影响它自己:以前这里 rethrow,等于让一个读不到的会话
|
|
599
|
+
// 把这一 tick 的整份清单推送一起带走。
|
|
600
|
+
noteSessionPushFailure(id, error);
|
|
601
|
+
shouldAnnounceRevision = false;
|
|
602
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
603
|
+
process.stderr.write(`[dsh-mobile-plugin] cold session build failed for ${id}: ${message}\n`);
|
|
604
|
+
bgQueuePos = (idx + 1) % list.length;
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
const wsBlocked = guardWorkspaces(workspacesBody)
|
|
608
|
+
if (!wsBlocked) {
|
|
609
|
+
try {
|
|
610
|
+
await pushMirrorPayload({
|
|
611
|
+
kind: 'sync', at: Date.now(),
|
|
612
|
+
activeSessionId: list.find(s2 => s2.running)?.sessionId || null,
|
|
613
|
+
sessions: sessionsBody.body,
|
|
614
|
+
workspaces: workspacesBody,
|
|
615
|
+
messages: data.messages ? { [id]: data.messages } : {},
|
|
616
|
+
stats: data.stats ? { [id]: data.stats } : {},
|
|
617
|
+
models: data.models ? { [id]: data.models } : {},
|
|
618
|
+
});
|
|
619
|
+
if (data.skipReason) recordMirrorPushSkipped(data.skipReason);
|
|
620
|
+
} catch (error) {
|
|
621
|
+
// The payload helper already records the transport failure; add the session retry ledger
|
|
622
|
+
// here without rethrowing into the generic catch (which would double-count the failure).
|
|
623
|
+
noteSessionPushFailure(id, error);
|
|
624
|
+
shouldAnnounceRevision = false;
|
|
625
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
626
|
+
process.stderr.write(`[dsh-mobile-plugin] cold mirror push failed for session ${id}: ${message}\n`);
|
|
627
|
+
}
|
|
628
|
+
} else {
|
|
629
|
+
recordMirrorPush(false, wsBlocked);
|
|
630
|
+
noteSessionPushFailure(id, new Error(wsBlocked));
|
|
631
|
+
shouldAnnounceRevision = false;
|
|
632
|
+
}
|
|
633
|
+
bgQueuePos = (idx + 1) % list.length;
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
// 所有会话都是高优先(正在跑 / 5 分钟内动过)时上面一条都没推:这一 tick 唯一
|
|
637
|
+
// 能做的是把「清单变了」传下去。这类变更通常已经由事件路径推过镜像,广播是幂等的。
|
|
638
|
+
if (shouldAnnounceRevision) announceSessionsRevision(revision);
|
|
639
|
+
} catch (e) {
|
|
640
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
641
|
+
recordMirrorPush(false, message);
|
|
642
|
+
process.stderr.write(`[dsh-mobile-plugin] bg queue: ${message}\n`);
|
|
643
|
+
} finally {
|
|
644
|
+
bgQueueBusy = false;
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
/** 返回 effect 的清理函数,交给 index.js 用 ctx.effect 挂载(与原有形状一致)。 */
|
|
649
|
+
const start = () => {
|
|
650
|
+
let disposed = false;
|
|
651
|
+
const ac = new AbortController();
|
|
652
|
+
void (async () => {
|
|
653
|
+
try {
|
|
654
|
+
for await (const frame of ctx.apiProxy.events.mux({ rpcId: 'dsh-mirror-sync', payload: {} }, ac.signal)) {
|
|
655
|
+
if (disposed) break;
|
|
656
|
+
// 帧类型必须**先看 payload 再看顶层**。
|
|
657
|
+
//
|
|
658
|
+
// 0.2.0 的兼容桥(lib/mobile/host-bridge/*)把三类流合成旧 `events.mux` 时,产出的
|
|
659
|
+
// 帧形状是 `{ payload: { type, sessionId, … } }` —— 顶层没有 type。旧实现只读
|
|
660
|
+
// `frame?.type`,于是每一帧都在下面这行被 continue 丢掉:事件驱动的「变化即推」
|
|
661
|
+
// 整条通道是哑的,镜像只剩 500ms 的冷轮转在推,而它每个 5s 窗口只补一个**冷**会话、
|
|
662
|
+
// 明确跳过刚动过的会话(isHighPriority)⇒ 用户刚发过消息的那个会话永远轮不到,
|
|
663
|
+
// 真机表现为「桌面发一条、手机列表要等好几秒甚至更久」。
|
|
664
|
+
// 顶层 type 保留兼容(测试与将来恢复真 apiProxy 时形状可能不同)。
|
|
665
|
+
const p = frame?.payload;
|
|
666
|
+
const type = typeof frame?.type === 'string' ? frame.type : (p && typeof p.type === 'string' ? p.type : '');
|
|
667
|
+
if (!type.startsWith('session/') && !type.startsWith('host/')) continue;
|
|
668
|
+
if (type.startsWith('host/')) { void pushMirror(); continue; }
|
|
669
|
+
// session/event 事件本身就是变更发生,按高优先级立即推送
|
|
670
|
+
const sessionId = frame?.sessionId || p?.sessionId;
|
|
671
|
+
if (!sessionId) { void pushMirror(); continue; }
|
|
672
|
+
// assistant/chunk 是逐块流式增量,一轮生成里可达上千条。镜像只需要粗粒度状态:
|
|
673
|
+
// 同一轮收尾时的 assistant/message 会把完整消息带进来,手机侧的实时文本由
|
|
674
|
+
// session/stream 的 SSE 负责。不跳的话每条增量都要重建五份全量快照并推一次
|
|
675
|
+
// sidecar,形成推送风暴(配合 pushSessionImmediately 的单飞才有稳定上界)。
|
|
676
|
+
if (p?.event?.type === 'assistant/chunk') continue;
|
|
677
|
+
void pushSessionImmediately(sessionId);
|
|
678
|
+
}
|
|
679
|
+
} catch { /* stream ended */ }
|
|
680
|
+
})();
|
|
681
|
+
const bgTimer = setInterval(() => { if (!disposed) void processBgQueue(); }, BG_TICK_INTERVAL_MS);
|
|
682
|
+
// 定时器不该拖住进程退出(同 lib/sidecar-facade-worker.cjs 的 shutdown)。清理函数里
|
|
683
|
+
// 已经 clearInterval,unref 是给「宿主进程要退出、effect 清理还没轮到」的那一瞬间兜底。
|
|
684
|
+
if (bgTimer && typeof bgTimer.unref === 'function') bgTimer.unref();
|
|
685
|
+
void pushMirror();
|
|
686
|
+
return () => { disposed = true; ac.abort(); clearInterval(bgTimer); };
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
return {
|
|
690
|
+
start,
|
|
691
|
+
pushMirror,
|
|
692
|
+
pushSessionImmediately,
|
|
693
|
+
getMirrorPush: () => ({
|
|
694
|
+
pushedAt: mirrorPushAt, ok: mirrorPushOk, error: mirrorPushError,
|
|
695
|
+
// 正文缺失而清单照推的次数与最近原因:与 ok/pushedAt 分开,避免再把
|
|
696
|
+
// 「某会话正文读不到」误读成「镜像死了」(见 recordMirrorPushSkipped)。
|
|
697
|
+
skippedBodies: mirrorBodySkipCount, skipReason: mirrorBodySkipReason,
|
|
698
|
+
}),
|
|
699
|
+
/** 失败会话的定向重试账(诊断 + 守卫用):失败几次、下次什么时候补推。 */
|
|
700
|
+
getPushFailures: () => [...sessionPushFailures.entries()].map(([sessionId, entry]) => ({
|
|
701
|
+
sessionId, attempts: entry.attempts, dueAt: entry.dueAt, error: entry.lastError,
|
|
702
|
+
})),
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
module.exports = { createMirrorPusher };
|