dsh-expert-group 0.1.6 → 0.1.7
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.en.md +330 -0
- package/README.md +6 -18
- package/client-bundle.js +1271 -67
- package/client-expert-group-model.js +31 -4
- package/client-expert-group-sidebar.js +1240 -63
- package/host/expert-group-runtime.js +710 -33
- package/host/expert-group-snapshot.js +5 -1
- package/index.js +42 -0
- package/package.json +2 -1
package/client-bundle.js
CHANGED
|
@@ -116,18 +116,36 @@
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
/**
|
|
119
|
-
*
|
|
119
|
+
* 轻量(partial)快照下的中性运行状态:既不声明运行,也不声明已结束。
|
|
120
|
+
*
|
|
121
|
+
* 与 ready 的区别:ready 是「有证据表明当前没在运行」(圆点渲染成绿色 done),
|
|
122
|
+
* unknown 是「证据不足」——轻量快照跳过了冷读,此时既不能声明运行也不能声明结束,
|
|
123
|
+
* 圆点与活动文案必须保持中性。它不是协议状态词表的一员,只用于展示层。
|
|
124
|
+
*/
|
|
125
|
+
const UNKNOWN_RUNTIME_STATUS = "unknown";
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 归一化代理运行状态:默认只有 running | ready;轻量快照下多一个中性值 unknown。
|
|
120
129
|
*
|
|
121
130
|
* 只用 activitySignalOf 的活动证据链:明确运行为 running,其余(inactive、缺失、
|
|
122
131
|
* 目录未命中且无其它运行证据)一律 ready。目录 activity 与 Host 旧信号冲突时,
|
|
123
132
|
* 以目录 activity 为准,不把真实 running 降级成 ready。
|
|
124
133
|
*
|
|
134
|
+
* partial 选项(Host 的轻量快照):证据链返回 null(完全没有任何活动证据,既不是
|
|
135
|
+
* running 也不是 inactive)时返回中性值 unknown,让圆点与活动文案保持中性;
|
|
136
|
+
* 只要存在任何证据(目录 activity/status、节点 running 布尔、协议 status)就仍按原规则渲染。
|
|
137
|
+
* 不传 options 时行为与旧实现逐字一致(旧 Host、测试与所有既有调用点不受影响)。
|
|
138
|
+
*
|
|
125
139
|
* @param {object} node - 快照节点(专家/根/fan-out 任务或分片)。
|
|
126
140
|
* @param {object} [entry] - sessions 快照里的官方目录项。
|
|
127
|
-
* @
|
|
141
|
+
* @param {{partial?: boolean}} [options] - 展示层选项;partial 为 true 时才可能出现中性值。
|
|
142
|
+
* @returns {"running"|"ready"|"unknown"}
|
|
128
143
|
*/
|
|
129
|
-
function runtimeStatusOf(node, entry) {
|
|
130
|
-
|
|
144
|
+
function runtimeStatusOf(node, entry, options) {
|
|
145
|
+
const signal = activitySignalOf(node, entry);
|
|
146
|
+
if (signal === "running") return "running";
|
|
147
|
+
if (isObject(options) && options.partial === true && signal === null) return UNKNOWN_RUNTIME_STATUS;
|
|
148
|
+
return "ready";
|
|
131
149
|
}
|
|
132
150
|
|
|
133
151
|
/** 只有精确命中词表的 mode 才能映射文案;未知/非法值返回 null(不猜)。 */
|
|
@@ -304,6 +322,13 @@
|
|
|
304
322
|
return [...expertChildren, ...fanoutChildren];
|
|
305
323
|
}
|
|
306
324
|
|
|
325
|
+
/**
|
|
326
|
+
* 快照归一。
|
|
327
|
+
*
|
|
328
|
+
* partial 是 Host 新增的快照级标记:true = **轻量快照**(Host 跳过了全部冷读,状态可能不完整),
|
|
329
|
+
* false/缺失 = 完整权威快照。客户端只把它当成「状态证据不完整」的信号,渲染规则见
|
|
330
|
+
* runtimeStatusOf 的 partial 选项;缺失时按完整快照处理(旧 Host 行为不变)。
|
|
331
|
+
*/
|
|
307
332
|
function normalizeExpertGroupSnapshot(raw) {
|
|
308
333
|
if (!isObject(raw) || raw.schemaVersion !== SCHEMA_VERSION) return null;
|
|
309
334
|
const sourceRoot = isObject(raw.root) ? raw.root : {};
|
|
@@ -324,6 +349,7 @@
|
|
|
324
349
|
sessionId: rootSessionId,
|
|
325
350
|
rootSessionId,
|
|
326
351
|
generatedAt: numberOrNull(raw.generatedAt),
|
|
352
|
+
partial: raw.partial === true,
|
|
327
353
|
root,
|
|
328
354
|
};
|
|
329
355
|
}
|
|
@@ -420,6 +446,7 @@
|
|
|
420
446
|
global[SYMBOL] = Object.freeze({
|
|
421
447
|
SCHEMA_VERSION,
|
|
422
448
|
RUNTIME_STATUSES,
|
|
449
|
+
UNKNOWN_RUNTIME_STATUS,
|
|
423
450
|
normalizeExpertGroupSnapshot,
|
|
424
451
|
normalizeStatus,
|
|
425
452
|
runtimeStatusOf,
|
|
@@ -438,14 +465,21 @@
|
|
|
438
465
|
const MODEL_SYMBOL = Symbol.for("dsh-expert-group/client-expert-group-model");
|
|
439
466
|
const MODEL = global[MODEL_SYMBOL] || {};
|
|
440
467
|
const modelApi = () => global[MODEL_SYMBOL] || MODEL;
|
|
441
|
-
/**
|
|
442
|
-
|
|
468
|
+
/**
|
|
469
|
+
* 右侧栏对外的代理运行状态:默认是 running | ready 两个规范值。
|
|
470
|
+
*
|
|
471
|
+
* unknown 是**轻量(partial)快照专用**的中性值(见 client-expert-group-model.js 的
|
|
472
|
+
* UNKNOWN_RUNTIME_STATUS):证据不足时不声明运行、也不声明结束。完整快照永远不会产生它,
|
|
473
|
+
* 因此正常渲染、无障碍标签与圆点语义都不受影响。
|
|
474
|
+
*/
|
|
475
|
+
const RUNTIME_STATUS_LABELS = Object.freeze({ running: "running", ready: "ready", unknown: "unknown" });
|
|
443
476
|
/**
|
|
444
477
|
* 规范运行状态到 primitives.StateDot 的语义映射:
|
|
445
|
-
* running 用 ongoing(DSH 原生 1 秒追逐动画),ready 用 done
|
|
478
|
+
* running 用 ongoing(DSH 原生 1 秒追逐动画),ready 用 done(绿色静态圆点),
|
|
479
|
+
* unknown 用 idle——官方词表里唯一「无进展信息」的中性态,既不是进行中也不是已完成。
|
|
446
480
|
* 插件不自造第二套动画,动画只由官方组件提供。
|
|
447
481
|
*/
|
|
448
|
-
const RUNTIME_STATE_DOT = Object.freeze({ running: "ongoing", ready: "done" });
|
|
482
|
+
const RUNTIME_STATE_DOT = Object.freeze({ running: "ongoing", ready: "done", unknown: "idle" });
|
|
449
483
|
// ACTIVE_STATUSES / RETRY_STATUSES 只服务于 fan-out 管理语义(取消/重试判定),不是展示状态词表。
|
|
450
484
|
const ACTIVE_STATUSES = new Set(["queued", "waiting", "running"]);
|
|
451
485
|
const RETRY_STATUSES = new Set(["failed", "timed_out", "interrupted"]);
|
|
@@ -590,6 +624,27 @@
|
|
|
590
624
|
const SIDEBAR_TAB_DISAPPEAR_CONFIRMATIONS = 2;
|
|
591
625
|
/** 打开后等待 active() 提交的最长时间;超时只释放待确认记录,不伪造 << 状态。 */
|
|
592
626
|
const SIDEBAR_TAB_OPEN_CONFIRMATION_TIMEOUT_MS = 15000;
|
|
627
|
+
/**
|
|
628
|
+
* 共享来源账本里「Auto 打开已经发出、tabId 始终没落地」的**占位条目**存活上限。
|
|
629
|
+
*
|
|
630
|
+
* 取值理由:与本地 opening 的等待窗口(SIDEBAR_TAB_OPEN_CONFIRMATION_TIMEOUT_MS,15 秒)对齐——
|
|
631
|
+
* 两者回答的是同一个问题「这次打开还要等多久才算没有落地证据」。窗口内保留占位:它承担「同一个资源
|
|
632
|
+
* 绝不并发第二次 openResource」的去重语义(autoOpenCandidate 的 origin.has(key))。窗口外释放占位,
|
|
633
|
+
* 让「资源仍 running 且地址上确实没有 tab」的形状能有一次有界重试——每次重试至少间隔一个窗口,
|
|
634
|
+
* 绝不自转(同一批微任务内还有 sidebarAutoFailedKeysRef 失败屏障兜底)。
|
|
635
|
+
*
|
|
636
|
+
* 为什么必须有这条 TTL:占位同时占着归属账本的容量槽位(SIDEBAR_AUTO_KEY_LIMIT = 128),而候选
|
|
637
|
+
* 选择又靠 origin.has(key) 跳过重试。宿主始终没落地该 tab 时,一批占位会把账本占满,Auto 从此
|
|
638
|
+
* 再也开不出任何新 tab——纯可用性故障,而且只有整页刷新(清掉内存账本)才能恢复。
|
|
639
|
+
*/
|
|
640
|
+
const SIDEBAR_AUTO_OPEN_PLACEHOLDER_TTL_MS = SIDEBAR_TAB_OPEN_CONFIRMATION_TIMEOUT_MS;
|
|
641
|
+
/**
|
|
642
|
+
* 别的 scope(别的 root / sidebar session)的占位回收阈值:留出 4 倍余量,避免打断另一个 surface
|
|
643
|
+
* 仍在进行中的打开动作;超过它还在的占位一律回收(占位本身不是归属证据,回收不会授权任何关闭,
|
|
644
|
+
* 而且别的 scope 的候选选择必须在发出 openResource 前重新核对它自己的 inventory,因此也不可能
|
|
645
|
+
* 因此开出重复 tab)。
|
|
646
|
+
*/
|
|
647
|
+
const SIDEBAR_AUTO_OPEN_PLACEHOLDER_FOREIGN_TTL_MS = 4 * SIDEBAR_AUTO_OPEN_PLACEHOLDER_TTL_MS;
|
|
593
648
|
/** 首次 split 的跨 inventory/DOM 提交 deadline,避免临时 50% 状态无限期占用右栏。 */
|
|
594
649
|
const SIDEBAR_SPLIT_TRANSACTION_TIMEOUT_MS = 15000;
|
|
595
650
|
/** RAF 被后台节流时的短 fallback;只负责放行一次 split 尝试,不改变事务提交 deadline。 */
|
|
@@ -1110,9 +1165,11 @@
|
|
|
1110
1165
|
* 规范运行状态(展示与图标唯一使用的状态)。
|
|
1111
1166
|
* 模型可用时由模型按官方源字段归一,模型缺失时才退回可证明的 running 布尔。
|
|
1112
1167
|
*/
|
|
1113
|
-
function runtimeStatusOf(node, entry) {
|
|
1168
|
+
function runtimeStatusOf(node, entry, options) {
|
|
1114
1169
|
const model = modelApi();
|
|
1115
|
-
if (typeof model.runtimeStatusOf === "function") return model.runtimeStatusOf(node, entry);
|
|
1170
|
+
if (typeof model.runtimeStatusOf === "function") return model.runtimeStatusOf(node, entry, options);
|
|
1171
|
+
// 模型缺失的降级路径:没有证据链可判,轻量快照下一律中性。
|
|
1172
|
+
if (options?.partial === true && object(node).running !== true) return "unknown";
|
|
1116
1173
|
return object(node).running === true ? "running" : "ready";
|
|
1117
1174
|
}
|
|
1118
1175
|
|
|
@@ -1380,6 +1437,9 @@
|
|
|
1380
1437
|
return left.schemaVersion === right.schemaVersion
|
|
1381
1438
|
&& left.sessionId === right.sessionId
|
|
1382
1439
|
&& left.rootSessionId === right.rootSessionId
|
|
1440
|
+
// partial 必须参与比较:任务树内容恰好一致时(轻量快照与完整快照投影结果相同),
|
|
1441
|
+
// 从 partial→完整 的升级仍要上屏,否则中性渲染会一直留在界面上。
|
|
1442
|
+
&& (left.partial === true) === (right.partial === true)
|
|
1383
1443
|
&& snapshotContentEquals(left.root, right.root);
|
|
1384
1444
|
}
|
|
1385
1445
|
|
|
@@ -1523,6 +1583,8 @@
|
|
|
1523
1583
|
".deg_expert_group_state_dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:currentColor}",
|
|
1524
1584
|
".deg_expert_group_state_dot[data-state=running]{color:var(--dsw-static-deepseek-450,var(--dsw-alias-state-business-primary))}",
|
|
1525
1585
|
".deg_expert_group_state_dot[data-state=ready]{color:var(--dsw-alias-state-success-primary)}",
|
|
1586
|
+
// 轻量(partial)快照的中性圆点:用次要文字色,既不是「运行中」蓝也不是「已完成」绿。
|
|
1587
|
+
".deg_expert_group_state_dot[data-state=unknown]{color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary))}",
|
|
1526
1588
|
// 固定 30px 批量控件紧跟标题文字;不占独立 grid 尾列,也不使用自动外边距推向行尾。
|
|
1527
1589
|
// 标题自身 min-width:0 + 单行省略,窄宽度下只收缩文字,箭头与批量控件保持固定占位。
|
|
1528
1590
|
".deg_expert_group_trailing{display:flex;flex:0 0 30px;align-items:center;justify-content:center;width:30px;height:20px;min-width:30px}",
|
|
@@ -2561,6 +2623,511 @@
|
|
|
2561
2623
|
};
|
|
2562
2624
|
}
|
|
2563
2625
|
|
|
2626
|
+
/* ----------------------------------------------------------------------------------------------- *
|
|
2627
|
+
* realm 级共享 Auto 账本(跨 bundle 重新执行/HMR)、持久归属标记(跨整页刷新)与有界诊断
|
|
2628
|
+
* ----------------------------------------------------------------------------------------------- */
|
|
2629
|
+
|
|
2630
|
+
/**
|
|
2631
|
+
* 本 realm 的共享 Auto 账本槽位。
|
|
2632
|
+
*
|
|
2633
|
+
* 症状与根因(现场证据):DSH 的 ClientModuleSystem 支持 HMR,插件客户端模块被失效后会在**同一个
|
|
2634
|
+
* realm 重新执行整份 bundle**(见 client-freshness.js 的 REGISTERED_LOADERS_KEY 说明)。这些账本
|
|
2635
|
+
* 原先建在 createExpertGroupPanel 的工厂闭包里,bundle 重新执行即全部归零:一个正在运行的子代理
|
|
2636
|
+
* 期间跑过构建(HMR)之后,它那个已经打开的 tab 在下一次收养时只能落到 manual 兜底——Auto 从此
|
|
2637
|
+
* 再也不会把它收回去(用户看到的正是「只有能跑构建的角色更容易残留」)。
|
|
2638
|
+
*
|
|
2639
|
+
* 因此账本必须挂在 realm 的 globalThis 上(与 client-freshness 同一手法、同一条注释风格):
|
|
2640
|
+
* 首次执行创建,之后每次 bundle 重新执行都复用同一批 Map。整页刷新或进程重启后 realm 是新的,
|
|
2641
|
+
* 账本自然为空——那条安全取舍由**持久归属标记**(见 SIDEBAR_AUTO_OWNERSHIP_KEY)承担。
|
|
2642
|
+
*
|
|
2643
|
+
* globalThis 不可用 / 被冻结 / 写入被拒时退回「每次执行各自 new Map」的旧行为,绝不抛错(同
|
|
2644
|
+
* client-freshness 的降级策略)。
|
|
2645
|
+
*/
|
|
2646
|
+
const SIDEBAR_SHARED_AUTO_LEDGER_KEY = Symbol.for("dsh-expert-group/sidebar#shared-auto-ledger");
|
|
2647
|
+
const SIDEBAR_SHARED_AUTO_LEDGER_VERSION = 1;
|
|
2648
|
+
/** 持久归属标记的版本化键(与 dsh-expert-group:rightbar-presented:v1 同命名风格)。 */
|
|
2649
|
+
const SIDEBAR_AUTO_OWNERSHIP_KEY = "dsh-expert-group:auto-ownership:v1";
|
|
2650
|
+
const SIDEBAR_AUTO_OWNERSHIP_LIMIT = 128;
|
|
2651
|
+
const SIDEBAR_AUTO_OWNERSHIP_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
2652
|
+
const SIDEBAR_AUTO_OWNERSHIP_ORIGIN_AUTO = "auto";
|
|
2653
|
+
const SIDEBAR_AUTO_OWNERSHIP_ORIGIN_MANUAL = "manual";
|
|
2654
|
+
/** 归属标记的内存索引槽位(与 localStorage 同源 write-through;测试可重置)。 */
|
|
2655
|
+
const SIDEBAR_AUTO_OWNERSHIP_MEMORY_KEY = Symbol.for("dsh-expert-group/sidebar#auto-ownership-memory");
|
|
2656
|
+
/** 有界诊断环形缓冲:只记结论摘要,绝不记提示词 / 消息正文 / 令牌 / 凭据。 */
|
|
2657
|
+
const SIDEBAR_AUTO_DIAG_KEY = "dsh-expert-group:auto-diag:v1";
|
|
2658
|
+
const SIDEBAR_AUTO_DIAG_LIMIT = 60;
|
|
2659
|
+
const SIDEBAR_AUTO_DIAG_ENTRY_MAX_CHARS = 360;
|
|
2660
|
+
const SIDEBAR_AUTO_DIAG_TEXT_MAX_CHARS = 96;
|
|
2661
|
+
/** 账本里八份 Map 的字段名(顺序即诊断里的 sizes 顺序)。 */
|
|
2662
|
+
const SIDEBAR_SHARED_AUTO_LEDGER_FIELDS = [
|
|
2663
|
+
"origin", "suppressed", "closeAttempts", "closeAttemptOwners",
|
|
2664
|
+
"closeVerifications", "closeCooldowns", "tabMisses", "tabCloseInFlight",
|
|
2665
|
+
];
|
|
2666
|
+
|
|
2667
|
+
/** 安全取 realm 的 globalThis:不可用时给 null,调用方一律走降级分支。 */
|
|
2668
|
+
const sidebarRealmHost = () => {
|
|
2669
|
+
try {
|
|
2670
|
+
return typeof globalThis === "object" && globalThis !== null ? globalThis : null;
|
|
2671
|
+
} catch {
|
|
2672
|
+
return null;
|
|
2673
|
+
}
|
|
2674
|
+
};
|
|
2675
|
+
|
|
2676
|
+
/** 安全读 localStorage:缺失 / 被拒 / 抛错一律返回 null(调用方静默降级)。 */
|
|
2677
|
+
const sidebarLocalStorage = () => {
|
|
2678
|
+
try {
|
|
2679
|
+
const host = sidebarRealmHost();
|
|
2680
|
+
const storage = host === null ? null : host.localStorage;
|
|
2681
|
+
if (storage === null || storage === undefined) return null;
|
|
2682
|
+
if (typeof storage.getItem !== "function" || typeof storage.setItem !== "function") return null;
|
|
2683
|
+
return storage;
|
|
2684
|
+
} catch {
|
|
2685
|
+
return null;
|
|
2686
|
+
}
|
|
2687
|
+
};
|
|
2688
|
+
|
|
2689
|
+
/** 有界文本字段:非字符串按字符串化处理并截断,空值返回 undefined(不写这个字段)。 */
|
|
2690
|
+
const sidebarBoundedText = (value) => {
|
|
2691
|
+
if (value === null || value === undefined) return undefined;
|
|
2692
|
+
const text = typeof value === "string" ? value : String(value);
|
|
2693
|
+
if (text.length === 0) return undefined;
|
|
2694
|
+
return text.length > SIDEBAR_AUTO_DIAG_TEXT_MAX_CHARS ? text.slice(0, SIDEBAR_AUTO_DIAG_TEXT_MAX_CHARS) : text;
|
|
2695
|
+
};
|
|
2696
|
+
|
|
2697
|
+
/** 复合键摘要:只落 hash,绝不落 session / root / 地址原文。 */
|
|
2698
|
+
const sidebarAutoKeyDigest = (key) => {
|
|
2699
|
+
const source = typeof key === "string" ? key : String(key);
|
|
2700
|
+
let hash = 0x811c9dc5;
|
|
2701
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
2702
|
+
hash ^= source.charCodeAt(index);
|
|
2703
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
2704
|
+
}
|
|
2705
|
+
return "k" + hash.toString(36);
|
|
2706
|
+
};
|
|
2707
|
+
|
|
2708
|
+
const sidebarAutoScopedKey = (sessionId, rootId, address) => String(sessionId) + "|" + String(rootId) + "|" + String(address);
|
|
2709
|
+
|
|
2710
|
+
/**
|
|
2711
|
+
* realm 共享 Auto 来源账本(sharedAutoLedger.origin)条目的唯一构造函数。
|
|
2712
|
+
*
|
|
2713
|
+
* 条目形状:{ sessionId, rootSessionId, address, tabId }。**tabId 是这条账本的全部证明力所在**:
|
|
2714
|
+
* 它必须是「写入这一刻,插件亲眼在宿主 inventory 里读到的那个 tab」。
|
|
2715
|
+
*
|
|
2716
|
+
* - 非 null:这条条目证明「这个 tab 是 Auto 亲手打开/替换并落地的那一个」。任何据此认定 auto 的
|
|
2717
|
+
* 位置都必须要求 entry.tabId 与当前观察到的 tabId **逐字相等**;只要求「有条目」是不够的
|
|
2718
|
+
* (缺陷 1:旧 tab 被用户/宿主移除而插件未观察到时,同 realm 的收养会把用户新开的同地址 tab
|
|
2719
|
+
* 当成 Auto 的旧 tab 认领,资源一空闲就把它关掉)。
|
|
2720
|
+
* - null:只表示「本次运行已经为这个资源发出过一次 Auto 打开、落地证据还没到」这条**去重记账**,
|
|
2721
|
+
* 它**不是**归属证据——任何收养 / 关闭判据都不许把它当作 auto。
|
|
2722
|
+
*
|
|
2723
|
+
* 只有插件亲眼观察到的那次 Auto 打开/替换落地(recordSidebarTab 从宿主 inventory 读到 tabId 的
|
|
2724
|
+
* 那一刻)才允许写入或刷新 tabId。宿主合法重 mint 新 id 而插件没有观察到时,这条绑定随之失配,
|
|
2725
|
+
* 收养一律退化成 manual——宁可漏收一次(tab 留在右栏交给用户手动处理),也绝不误关一个可能是
|
|
2726
|
+
* 用户自己刚打开的 tab。
|
|
2727
|
+
*/
|
|
2728
|
+
const sidebarAutoOriginEntry = (sessionId, rootId, address, tabId) => ({
|
|
2729
|
+
sessionId,
|
|
2730
|
+
rootSessionId: rootId,
|
|
2731
|
+
address,
|
|
2732
|
+
tabId: typeof tabId === "string" && tabId.length > 0 ? tabId : null,
|
|
2733
|
+
/**
|
|
2734
|
+
* 写入时刻:**只有占位**(tabId === null,「打开已发出、落地证据还没到」)用它判 TTL,
|
|
2735
|
+
* 见 SIDEBAR_AUTO_OPEN_PLACEHOLDER_TTL_MS 与 sweepAutoOpenPlaceholders。
|
|
2736
|
+
* 落地条目(tabId 非 null)不使用它:那条生命周期由既有收口路径负责(verified-closed /
|
|
2737
|
+
* inventory-absent / tab-recycled / resource-running),比 TTL 精确得多,绝不能被 TTL 顺手回收。
|
|
2738
|
+
*/
|
|
2739
|
+
writtenAt: Date.now(),
|
|
2740
|
+
});
|
|
2741
|
+
|
|
2742
|
+
/** 读一条来源账本条目绑定的 tabId:缺失 / 畸形一律 null(调用方必须按「不是我们的 tab」处理)。 */
|
|
2743
|
+
const sidebarAutoOriginTabIdOf = (entry) => {
|
|
2744
|
+
if (entry === null || entry === undefined || typeof entry !== "object") return null;
|
|
2745
|
+
return typeof entry.tabId === "string" && entry.tabId.length > 0 ? entry.tabId : null;
|
|
2746
|
+
};
|
|
2747
|
+
|
|
2748
|
+
/** 读一条来源账本条目的写入时刻:缺失 / 畸形一律 0(按「最旧」处理,优先回收)。 */
|
|
2749
|
+
const sidebarAutoOriginWrittenAtOf = (entry) => {
|
|
2750
|
+
if (entry === null || entry === undefined || typeof entry !== "object") return 0;
|
|
2751
|
+
const value = Number(entry.writtenAt);
|
|
2752
|
+
return Number.isFinite(value) ? value : 0;
|
|
2753
|
+
};
|
|
2754
|
+
|
|
2755
|
+
/**
|
|
2756
|
+
* 写一条诊断(环形、有界、失败静默)。
|
|
2757
|
+
*
|
|
2758
|
+
* 结构:{ t, kind, event?, action?, verdict?, reason?, key?, origin?, reused?, degraded?, sizes? },
|
|
2759
|
+
* key 一律是摘要(sidebarAutoKeyDigest),绝不含原文;单条 JSON 超过硬上限时先丢 sizes 再整体丢弃。
|
|
2760
|
+
*/
|
|
2761
|
+
const recordSidebarAutoDiag = (entry) => {
|
|
2762
|
+
try {
|
|
2763
|
+
const storage = sidebarLocalStorage();
|
|
2764
|
+
if (storage === null) return;
|
|
2765
|
+
const source = entry !== null && typeof entry === "object" ? entry : {};
|
|
2766
|
+
const normalized = { t: Date.now() };
|
|
2767
|
+
for (const field of ["kind", "event", "action", "verdict", "reason", "key", "origin"]) {
|
|
2768
|
+
const value = sidebarBoundedText(source[field]);
|
|
2769
|
+
if (value !== undefined) normalized[field] = value;
|
|
2770
|
+
}
|
|
2771
|
+
if (source.reused === true) normalized.reused = true;
|
|
2772
|
+
if (source.degraded === true) normalized.degraded = true;
|
|
2773
|
+
if (source.sizes !== null && typeof source.sizes === "object") {
|
|
2774
|
+
const sizes = {};
|
|
2775
|
+
for (const name of SIDEBAR_SHARED_AUTO_LEDGER_FIELDS) {
|
|
2776
|
+
const value = Number(source.sizes[name]);
|
|
2777
|
+
if (Number.isFinite(value) === true) sizes[name] = Math.max(0, Math.trunc(value));
|
|
2778
|
+
}
|
|
2779
|
+
normalized.sizes = sizes;
|
|
2780
|
+
}
|
|
2781
|
+
let serialized = JSON.stringify(normalized);
|
|
2782
|
+
if (serialized.length > SIDEBAR_AUTO_DIAG_ENTRY_MAX_CHARS) {
|
|
2783
|
+
delete normalized.sizes;
|
|
2784
|
+
serialized = JSON.stringify(normalized);
|
|
2785
|
+
}
|
|
2786
|
+
if (serialized.length > SIDEBAR_AUTO_DIAG_ENTRY_MAX_CHARS) return;
|
|
2787
|
+
let list = [];
|
|
2788
|
+
const raw = storage.getItem(SIDEBAR_AUTO_DIAG_KEY);
|
|
2789
|
+
if (typeof raw === "string" && raw.length > 0) {
|
|
2790
|
+
const parsed = JSON.parse(raw);
|
|
2791
|
+
if (Array.isArray(parsed) === true) list = parsed.slice(-SIDEBAR_AUTO_DIAG_LIMIT);
|
|
2792
|
+
}
|
|
2793
|
+
list.push(JSON.parse(serialized));
|
|
2794
|
+
storage.setItem(SIDEBAR_AUTO_DIAG_KEY, JSON.stringify(list.slice(-SIDEBAR_AUTO_DIAG_LIMIT)));
|
|
2795
|
+
} catch {
|
|
2796
|
+
/* 诊断只服务排查:任何失败都静默,绝不阻断功能。 */
|
|
2797
|
+
}
|
|
2798
|
+
};
|
|
2799
|
+
|
|
2800
|
+
/** 新建一份共享 Auto 账本(八份 Map + 必要元数据)。 */
|
|
2801
|
+
const createSidebarSharedAutoLedger = () => {
|
|
2802
|
+
const ledger = { version: SIDEBAR_SHARED_AUTO_LEDGER_VERSION, createdAt: Date.now() };
|
|
2803
|
+
for (const field of SIDEBAR_SHARED_AUTO_LEDGER_FIELDS) ledger[field] = new Map();
|
|
2804
|
+
return ledger;
|
|
2805
|
+
};
|
|
2806
|
+
|
|
2807
|
+
/**
|
|
2808
|
+
* 鸭子类型校验:只认「版本一致 + 八份 Map 都在」的账本,避免跨 realm / 旧格式的脏值被复用。
|
|
2809
|
+
* 不用 instanceof:测试替身与 vm realm 里的 Map 不共享构造函数。
|
|
2810
|
+
*/
|
|
2811
|
+
const isSidebarSharedAutoLedger = (value) => {
|
|
2812
|
+
if (value === null || typeof value !== "object") return false;
|
|
2813
|
+
if (value.version !== SIDEBAR_SHARED_AUTO_LEDGER_VERSION) return false;
|
|
2814
|
+
for (const field of SIDEBAR_SHARED_AUTO_LEDGER_FIELDS) {
|
|
2815
|
+
const map = value[field];
|
|
2816
|
+
if (map === null || typeof map !== "object" || typeof map.get !== "function" || typeof map.set !== "function") return false;
|
|
2817
|
+
}
|
|
2818
|
+
return true;
|
|
2819
|
+
};
|
|
2820
|
+
|
|
2821
|
+
/** 各 Map 规模的只读快照(诊断用;缺失字段按 0 计)。 */
|
|
2822
|
+
const sidebarSharedAutoLedgerSizes = (ledger) => {
|
|
2823
|
+
const sizes = {};
|
|
2824
|
+
for (const field of SIDEBAR_SHARED_AUTO_LEDGER_FIELDS) {
|
|
2825
|
+
const map = ledger === null || ledger === undefined ? null : ledger[field];
|
|
2826
|
+
sizes[field] = map !== null && typeof map === "object" && Number.isFinite(Number(map.size)) ? Number(map.size) : 0;
|
|
2827
|
+
}
|
|
2828
|
+
return sizes;
|
|
2829
|
+
};
|
|
2830
|
+
|
|
2831
|
+
const sidebarSharedAutoLedgerTotal = (ledger) => {
|
|
2832
|
+
const sizes = sidebarSharedAutoLedgerSizes(ledger);
|
|
2833
|
+
let total = 0;
|
|
2834
|
+
for (const field of SIDEBAR_SHARED_AUTO_LEDGER_FIELDS) total += sizes[field];
|
|
2835
|
+
return total;
|
|
2836
|
+
};
|
|
2837
|
+
|
|
2838
|
+
/**
|
|
2839
|
+
* 取本 realm 的共享 Auto 账本:命中即复用(reused=true),否则新建并写回 realm 槽位。
|
|
2840
|
+
*
|
|
2841
|
+
* 写回失败(globalThis 被冻结 / 受限)时只返回这一份新账本,绝不抛错:行为退回「每次执行各自
|
|
2842
|
+
* new Map」的旧语义,功能可用性不受影响。
|
|
2843
|
+
*/
|
|
2844
|
+
const resolveSidebarSharedAutoLedger = () => {
|
|
2845
|
+
const host = sidebarRealmHost();
|
|
2846
|
+
let existing = null;
|
|
2847
|
+
if (host !== null) {
|
|
2848
|
+
try { existing = host[SIDEBAR_SHARED_AUTO_LEDGER_KEY]; } catch { existing = null; }
|
|
2849
|
+
}
|
|
2850
|
+
if (isSidebarSharedAutoLedger(existing) === true) {
|
|
2851
|
+
return { ledger: existing, reused: true, degraded: false };
|
|
2852
|
+
}
|
|
2853
|
+
const created = createSidebarSharedAutoLedger();
|
|
2854
|
+
let degraded = host === null;
|
|
2855
|
+
if (host !== null) {
|
|
2856
|
+
try {
|
|
2857
|
+
Object.defineProperty(host, SIDEBAR_SHARED_AUTO_LEDGER_KEY, { value: created, configurable: true, writable: true });
|
|
2858
|
+
} catch {
|
|
2859
|
+
degraded = true;
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
return { ledger: created, reused: false, degraded };
|
|
2863
|
+
};
|
|
2864
|
+
|
|
2865
|
+
/**
|
|
2866
|
+
* 归属标记的内存索引(懒加载 + write-through):命中 realm 槽位即复用,否则新建并写回。
|
|
2867
|
+
* 槽位不可用时返回 null——此时所有读写直接走 localStorage(仍然正确,只是每次都要解析)。
|
|
2868
|
+
*/
|
|
2869
|
+
const sidebarOwnershipMemory = () => {
|
|
2870
|
+
const host = sidebarRealmHost();
|
|
2871
|
+
if (host === null) return null;
|
|
2872
|
+
try {
|
|
2873
|
+
const existing = host[SIDEBAR_AUTO_OWNERSHIP_MEMORY_KEY];
|
|
2874
|
+
if (existing !== null && typeof existing === "object" && existing.entries instanceof Map) return existing;
|
|
2875
|
+
} catch { return null; }
|
|
2876
|
+
try {
|
|
2877
|
+
const created = { entries: new Map(), loaded: false };
|
|
2878
|
+
Object.defineProperty(host, SIDEBAR_AUTO_OWNERSHIP_MEMORY_KEY, { value: created, configurable: true, writable: true });
|
|
2879
|
+
return created;
|
|
2880
|
+
} catch {
|
|
2881
|
+
return null;
|
|
2882
|
+
}
|
|
2883
|
+
};
|
|
2884
|
+
|
|
2885
|
+
/**
|
|
2886
|
+
* 把持久条目数组整理成 Map:只做两件「数据卫生」的事——丢掉 TTL 过期条目、同一 key 去重
|
|
2887
|
+
* (manual 优先)。
|
|
2888
|
+
*
|
|
2889
|
+
* **这里刻意不做任何按上限的 LRU 淘汰**(缺陷 2):条目描述的 owner 在载入这一刻完全可能仍然
|
|
2890
|
+
* 有效(宿主里那个 tab 还开着,只是插件这一帧还没有 inventory 证据),LRU 淘汰它们等于永久丢掉
|
|
2891
|
+
* 一批仍然打开的 Auto owner——它们再也不会被自动收回,刷新也不能恢复。容量改由**写入路径**
|
|
2892
|
+
* 分账 fail-closed 控制:新增 auto 条目在满额时被拒绝,manual 条目照常写入(见
|
|
2893
|
+
* writeSidebarAutoOwnership / sidebarOwnershipAcceptsNewAuto)。
|
|
2894
|
+
*/
|
|
2895
|
+
const normalizeSidebarOwnershipEntries = (raw, now) => {
|
|
2896
|
+
const entries = new Map();
|
|
2897
|
+
if (Array.isArray(raw) !== true) return entries;
|
|
2898
|
+
for (const item of raw) {
|
|
2899
|
+
if (item === null || typeof item !== "object") continue;
|
|
2900
|
+
const key = typeof item.key === "string" ? item.key : null;
|
|
2901
|
+
if (key === null) continue;
|
|
2902
|
+
const updatedAt = Number(item.updatedAt);
|
|
2903
|
+
if (Number.isFinite(updatedAt) !== true || now - updatedAt > SIDEBAR_AUTO_OWNERSHIP_TTL_MS) continue;
|
|
2904
|
+
const origin = item.origin === SIDEBAR_AUTO_OWNERSHIP_ORIGIN_MANUAL
|
|
2905
|
+
? SIDEBAR_AUTO_OWNERSHIP_ORIGIN_MANUAL
|
|
2906
|
+
: SIDEBAR_AUTO_OWNERSHIP_ORIGIN_AUTO;
|
|
2907
|
+
const previous = entries.get(key);
|
|
2908
|
+
if (previous !== undefined && previous.origin === SIDEBAR_AUTO_OWNERSHIP_ORIGIN_MANUAL) continue;
|
|
2909
|
+
/**
|
|
2910
|
+
* tabId 是「写下这条归属的那一刻,宿主 inventory 里这个地址的 tab id」(缺省表示这次写入没有
|
|
2911
|
+
* 可证明的 tab)。只有 **auto** 条目在收养时会按它比对(见 recordSidebarTab 的 tabId 绑定闸门):
|
|
2912
|
+
* 宿主重新 mint 了新 id 就说明这条条目描述的那个 tab 已经不存在,此时宁可退化成 manual
|
|
2913
|
+
* (漏收一次、留给用户手动处理),也绝不让 Auto 去关一个可能是用户自己刚打开的 tab。
|
|
2914
|
+
*/
|
|
2915
|
+
entries.delete(key);
|
|
2916
|
+
entries.set(key, {
|
|
2917
|
+
key,
|
|
2918
|
+
origin,
|
|
2919
|
+
updatedAt,
|
|
2920
|
+
reason: sidebarBoundedText(item.reason),
|
|
2921
|
+
tabId: sidebarBoundedText(item.tabId),
|
|
2922
|
+
});
|
|
2923
|
+
}
|
|
2924
|
+
return entries;
|
|
2925
|
+
};
|
|
2926
|
+
|
|
2927
|
+
/** 读取(必要时从 localStorage 懒加载)归属索引;两处都不可用时返回空的临时索引。 */
|
|
2928
|
+
const sidebarOwnershipEntries = () => {
|
|
2929
|
+
const memory = sidebarOwnershipMemory();
|
|
2930
|
+
if (memory === null) {
|
|
2931
|
+
const storage = sidebarLocalStorage();
|
|
2932
|
+
if (storage === null) return new Map();
|
|
2933
|
+
try {
|
|
2934
|
+
const raw = storage.getItem(SIDEBAR_AUTO_OWNERSHIP_KEY);
|
|
2935
|
+
return normalizeSidebarOwnershipEntries(raw === null || raw.length === 0 ? [] : JSON.parse(raw), Date.now());
|
|
2936
|
+
} catch {
|
|
2937
|
+
return new Map();
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
if (memory.loaded !== true) {
|
|
2941
|
+
memory.loaded = true;
|
|
2942
|
+
const storage = sidebarLocalStorage();
|
|
2943
|
+
if (storage !== null) {
|
|
2944
|
+
try {
|
|
2945
|
+
const raw = storage.getItem(SIDEBAR_AUTO_OWNERSHIP_KEY);
|
|
2946
|
+
memory.entries = normalizeSidebarOwnershipEntries(raw === null || raw.length === 0 ? [] : JSON.parse(raw), Date.now());
|
|
2947
|
+
} catch {
|
|
2948
|
+
memory.entries = new Map();
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
return memory.entries;
|
|
2953
|
+
};
|
|
2954
|
+
|
|
2955
|
+
/** 把索引写回 localStorage(失败 / 不可用一律静默:内存仍然一致,只是不持久)。 */
|
|
2956
|
+
const persistSidebarOwnership = (entries) => {
|
|
2957
|
+
const storage = sidebarLocalStorage();
|
|
2958
|
+
if (storage === null) return false;
|
|
2959
|
+
try {
|
|
2960
|
+
const list = [];
|
|
2961
|
+
for (const entry of entries.values()) {
|
|
2962
|
+
const item = { key: entry.key, origin: entry.origin, updatedAt: entry.updatedAt };
|
|
2963
|
+
if (entry.reason !== undefined) item.reason = entry.reason;
|
|
2964
|
+
// tabId 必须随条目一起持久化:整页刷新之后它是唯一能证明「这条 auto 归属描述的是哪个 tab」的证据。
|
|
2965
|
+
if (entry.tabId !== undefined) item.tabId = entry.tabId;
|
|
2966
|
+
list.push(item);
|
|
2967
|
+
}
|
|
2968
|
+
storage.setItem(SIDEBAR_AUTO_OWNERSHIP_KEY, JSON.stringify(list));
|
|
2969
|
+
return true;
|
|
2970
|
+
} catch {
|
|
2971
|
+
return false;
|
|
2972
|
+
}
|
|
2973
|
+
};
|
|
2974
|
+
|
|
2975
|
+
/**
|
|
2976
|
+
* 读一个地址的持久归属:返回 { origin, tabId },没有标记时返回 null。
|
|
2977
|
+
*
|
|
2978
|
+
* - origin 命中 manual 一律 manual(安全侧),命中 auto 才 auto;
|
|
2979
|
+
* - tabId 是写入那一刻宿主 inventory 里这个地址的 tab id(没有可证明的 tab 时为 null);
|
|
2980
|
+
* 只有 auto 条目会被调用方按它比对,manual 条目只按 origin 判定。
|
|
2981
|
+
*
|
|
2982
|
+
* 读取本身绝不修改任何状态,失败一律当作「没有标记」(调用方按 manual 兜底)。
|
|
2983
|
+
* options.quiet === true 时连诊断也不写:调用点可能是每轮同步都会跑的判据(自动打开候选、
|
|
2984
|
+
* 空闲资源的抑制清除扫描),它们的结论由各自的决策 / 删除诊断承担,不该把有界诊断环挤满。
|
|
2985
|
+
*/
|
|
2986
|
+
const readSidebarAutoOwnershipEntry = (sessionId, rootId, address, options) => {
|
|
2987
|
+
try {
|
|
2988
|
+
const key = sidebarAutoScopedKey(sessionId, rootId, address);
|
|
2989
|
+
const entry = sidebarOwnershipEntries().get(key);
|
|
2990
|
+
const origin = entry === undefined
|
|
2991
|
+
? null
|
|
2992
|
+
: (entry.origin === SIDEBAR_AUTO_OWNERSHIP_ORIGIN_MANUAL ? SIDEBAR_AUTO_OWNERSHIP_ORIGIN_MANUAL : SIDEBAR_AUTO_OWNERSHIP_ORIGIN_AUTO);
|
|
2993
|
+
if (options === null || options === undefined || options.quiet !== true) {
|
|
2994
|
+
recordSidebarAutoDiag({ kind: "ownership", event: "hit", origin, key: sidebarAutoKeyDigest(key) });
|
|
2995
|
+
}
|
|
2996
|
+
if (entry === undefined) return null;
|
|
2997
|
+
return {
|
|
2998
|
+
origin,
|
|
2999
|
+
tabId: typeof entry.tabId === "string" && entry.tabId.length > 0 ? entry.tabId : null,
|
|
3000
|
+
};
|
|
3001
|
+
} catch {
|
|
3002
|
+
return null;
|
|
3003
|
+
}
|
|
3004
|
+
};
|
|
3005
|
+
|
|
3006
|
+
/** 持久表里的 auto 条目数(容量只对 auto 计账:manual 是用户意图,恒优先、从不被容量拒绝)。 */
|
|
3007
|
+
const sidebarOwnershipAutoCount = (entries) => {
|
|
3008
|
+
let count = 0;
|
|
3009
|
+
for (const entry of entries.values()) {
|
|
3010
|
+
if (entry.origin === SIDEBAR_AUTO_OWNERSHIP_ORIGIN_AUTO) count += 1;
|
|
3011
|
+
}
|
|
3012
|
+
return count;
|
|
3013
|
+
};
|
|
3014
|
+
|
|
3015
|
+
/**
|
|
3016
|
+
* 持久表是否还容得下这个地址的一条 **auto** 归属(fail-closed 容量判据,缺陷 2)。
|
|
3017
|
+
*
|
|
3018
|
+
* 判据只有一条:写入之后 auto 条目数不得超过 SIDEBAR_AUTO_OWNERSHIP_LIMIT。**绝不为了腾位而
|
|
3019
|
+
* 淘汰任何已有条目**——持久表里的 auto 条目描述的 owner 在本帧完全可能仍然有效(宿主里那个 tab
|
|
3020
|
+
* 还开着),LRU(或任何「看起来更旧」的排序)淘汰它同样会让那个 tab 永久失去 Auto 归属,刷新
|
|
3021
|
+
* 也不能恢复。
|
|
3022
|
+
*
|
|
3023
|
+
* manual 条目不参与这里的容量判定:它表达用户意图,写入 / 改写一律照常(手动恒优先)。因此本
|
|
3024
|
+
* 函数回答的是「新增/改写一条 auto 条目能不能被保留」,而不是「表里总共有多少条」。
|
|
3025
|
+
*
|
|
3026
|
+
* 读取失败(localStorage 不可用 / 抛错)按「容得下」处理:内存账本仍然守着同一份容量,持久层
|
|
3027
|
+
* 只是「刷新之后的记忆」,读不到它不该让自动打开整体失效(与既有的静默降级一致)。
|
|
3028
|
+
*/
|
|
3029
|
+
const sidebarOwnershipAcceptsNewAuto = (sessionId, rootId, address) => {
|
|
3030
|
+
try {
|
|
3031
|
+
const entries = sidebarOwnershipEntries();
|
|
3032
|
+
const key = sidebarAutoScopedKey(sessionId, rootId, address);
|
|
3033
|
+
const existing = entries.get(key);
|
|
3034
|
+
const existingIsAuto = existing !== undefined && existing.origin === SIDEBAR_AUTO_OWNERSHIP_ORIGIN_AUTO;
|
|
3035
|
+
const nextAutoCount = sidebarOwnershipAutoCount(entries) - (existingIsAuto ? 1 : 0) + 1;
|
|
3036
|
+
return nextAutoCount <= SIDEBAR_AUTO_OWNERSHIP_LIMIT;
|
|
3037
|
+
} catch {
|
|
3038
|
+
return true;
|
|
3039
|
+
}
|
|
3040
|
+
};
|
|
3041
|
+
|
|
3042
|
+
/**
|
|
3043
|
+
* 写入归属标记(内存与 localStorage 同一处收口)并记一条诊断;返回是否真的写进去了。
|
|
3044
|
+
*
|
|
3045
|
+
* tabId 只在**调用方真的读到了宿主 inventory 里这个地址的 tab** 时才传(当前唯一的 auto 调用点
|
|
3046
|
+
* 是 recordSidebarTab):它把这条归属绑定到具体的 tab 上,刷新之后的收养必须靠它证明「还是同一个
|
|
3047
|
+
* tab」(见 recordSidebarTab 的 tabId 绑定闸门)。manual 条目不需要它,传 undefined 即可。
|
|
3048
|
+
*
|
|
3049
|
+
* 容量 fail-closed(缺陷 2):会挤掉仍有效 owner 的新增 / 改写(即写入之后 auto 条目数超过上限)
|
|
3050
|
+
* **一律拒绝**,并留下一条 reject/capacity 诊断。manual 写入从不被容量拒绝——但同样绝不为了腾位
|
|
3051
|
+
* 去淘汰任何已有条目,因此持久表最多是「上限条 auto + 用户自己产生的 manual 条目」。
|
|
3052
|
+
* 到达上限是可用性代价而不是错误:那个资源不自动打开,用户仍然可以手动打开(手动打开照常写
|
|
3053
|
+
* manual 归属,见 handleSidebarAction 的入口写入)。
|
|
3054
|
+
*/
|
|
3055
|
+
const writeSidebarAutoOwnership = (sessionId, rootId, address, origin, reason, tabId) => {
|
|
3056
|
+
try {
|
|
3057
|
+
const normalizedOrigin = origin === SIDEBAR_AUTO_OWNERSHIP_ORIGIN_MANUAL
|
|
3058
|
+
? SIDEBAR_AUTO_OWNERSHIP_ORIGIN_MANUAL
|
|
3059
|
+
: SIDEBAR_AUTO_OWNERSHIP_ORIGIN_AUTO;
|
|
3060
|
+
const entries = sidebarOwnershipEntries();
|
|
3061
|
+
const key = sidebarAutoScopedKey(sessionId, rootId, address);
|
|
3062
|
+
if (normalizedOrigin === SIDEBAR_AUTO_OWNERSHIP_ORIGIN_AUTO
|
|
3063
|
+
&& sidebarOwnershipAcceptsNewAuto(sessionId, rootId, address) !== true) {
|
|
3064
|
+
recordSidebarAutoDiag({ kind: "ownership", event: "reject", origin: normalizedOrigin, reason: "capacity", key: sidebarAutoKeyDigest(key) });
|
|
3065
|
+
return false;
|
|
3066
|
+
}
|
|
3067
|
+
entries.delete(key);
|
|
3068
|
+
entries.set(key, {
|
|
3069
|
+
key,
|
|
3070
|
+
origin: normalizedOrigin,
|
|
3071
|
+
updatedAt: Date.now(),
|
|
3072
|
+
reason: sidebarBoundedText(reason),
|
|
3073
|
+
tabId: sidebarBoundedText(tabId),
|
|
3074
|
+
});
|
|
3075
|
+
persistSidebarOwnership(entries);
|
|
3076
|
+
recordSidebarAutoDiag({ kind: "ownership", event: "write", origin: normalizedOrigin, reason, key: sidebarAutoKeyDigest(key) });
|
|
3077
|
+
return true;
|
|
3078
|
+
} catch {
|
|
3079
|
+
/* 归属标记只影响「刷新之后还认不认」,任何失败都静默。 */
|
|
3080
|
+
return false;
|
|
3081
|
+
}
|
|
3082
|
+
};
|
|
3083
|
+
|
|
3084
|
+
/**
|
|
3085
|
+
* 删除归属标记(与写入同源:内存删掉后立刻落盘)。
|
|
3086
|
+
*
|
|
3087
|
+
* 默认**拒绝**删除 manual 条目:manual 是用户的手动意图(手动关闭 / 手动打开),而调用这条删除的
|
|
3088
|
+
* 路径全是 Auto 自己的收口(verified-close / inventory-absent / tab-recycled / resource-running)。
|
|
3089
|
+
* 让 Auto 的收口顺手删掉用户的抑制,就等于「刷新之后用户的关闭又被撤销」——正是 P1-B 要堵的洞。
|
|
3090
|
+
* 因此这里只回答「Auto 自己的归属是否已经不需要了」,manual 条目一律保留并留一条 keep 诊断。
|
|
3091
|
+
*
|
|
3092
|
+
* options.manual === true 是唯一的例外:只有「观察到资源已经明确空闲(ready)」那条专用清点路径
|
|
3093
|
+
* 才允许清除 manual(见 clearManualSuppressionOfIdleResources),语义是「这一轮运行已经结束,
|
|
3094
|
+
* 用户当时的关闭意图不再需要跨运行保留」。
|
|
3095
|
+
*/
|
|
3096
|
+
const deleteSidebarAutoOwnership = (sessionId, rootId, address, reason, options) => {
|
|
3097
|
+
try {
|
|
3098
|
+
const entries = sidebarOwnershipEntries();
|
|
3099
|
+
const key = sidebarAutoScopedKey(sessionId, rootId, address);
|
|
3100
|
+
const entry = entries.get(key);
|
|
3101
|
+
if (entry === undefined) {
|
|
3102
|
+
recordSidebarAutoDiag({ kind: "ownership", event: "delete-miss", reason, key: sidebarAutoKeyDigest(key) });
|
|
3103
|
+
return false;
|
|
3104
|
+
}
|
|
3105
|
+
if (entry.origin === SIDEBAR_AUTO_OWNERSHIP_ORIGIN_MANUAL
|
|
3106
|
+
&& (options === null || options === undefined || options.manual !== true)) {
|
|
3107
|
+
recordSidebarAutoDiag({ kind: "ownership", event: "keep", origin: SIDEBAR_AUTO_OWNERSHIP_ORIGIN_MANUAL, reason, key: sidebarAutoKeyDigest(key) });
|
|
3108
|
+
return false;
|
|
3109
|
+
}
|
|
3110
|
+
entries.delete(key);
|
|
3111
|
+
persistSidebarOwnership(entries);
|
|
3112
|
+
recordSidebarAutoDiag({ kind: "ownership", event: "delete", reason, key: sidebarAutoKeyDigest(key) });
|
|
3113
|
+
return true;
|
|
3114
|
+
} catch {
|
|
3115
|
+
return false;
|
|
3116
|
+
}
|
|
3117
|
+
};
|
|
3118
|
+
|
|
3119
|
+
/** 记录一次自动打开/自动关闭决策(候选 + 结算),失败原因只留截断后的一行。 */
|
|
3120
|
+
const recordSidebarAutoDecision = (event, candidate, verdict, reason) => {
|
|
3121
|
+
recordSidebarAutoDiag({
|
|
3122
|
+
kind: "decision",
|
|
3123
|
+
event,
|
|
3124
|
+
action: candidate === null || candidate === undefined ? undefined : candidate.kind,
|
|
3125
|
+
verdict,
|
|
3126
|
+
reason,
|
|
3127
|
+
key: candidate === null || candidate === undefined ? undefined : sidebarAutoKeyDigest(candidate.key),
|
|
3128
|
+
});
|
|
3129
|
+
};
|
|
3130
|
+
|
|
2564
3131
|
function createExpertGroupPanel(deps) {
|
|
2565
3132
|
const config = object(deps);
|
|
2566
3133
|
const react = config.react;
|
|
@@ -2673,10 +3240,37 @@
|
|
|
2673
3240
|
* busy 闸门、tab 打开记录(records/openings)、手动意图都属于「当前这一帧 surface 的在途状态」,
|
|
2674
3241
|
* 跨实例共享只会让旧实例的在途状态污染新实例。键始终是(sidebar session + rootSessionId +
|
|
2675
3242
|
* 资源地址)复合键,因此共享也不会让不同 root / 不同 session 互相影响。
|
|
3243
|
+
*
|
|
3244
|
+
* 生命周期(本轮修复):这八份 Map 不只是「同一 install 内共享」,而是**本 realm 内共享**——
|
|
3245
|
+
* bundle 被 HMR 重新执行之后仍复用同一批 Map(见 resolveSidebarSharedAutoLedger)。否则一次
|
|
3246
|
+
* 构建(fixer 角色最容易触发)就会把「这个 tab 是 Auto 亲手打开的」全部抹掉,tab 随即被收养成
|
|
3247
|
+
* manual 并永久留在右栏。跨整页刷新/进程重启的归属由持久标记层负责(SIDEBAR_AUTO_OWNERSHIP_KEY)。
|
|
2676
3248
|
*/
|
|
2677
|
-
const
|
|
2678
|
-
const
|
|
2679
|
-
|
|
3249
|
+
const sharedAutoLedgerState = resolveSidebarSharedAutoLedger();
|
|
3250
|
+
const sharedAutoLedger = sharedAutoLedgerState.ledger;
|
|
3251
|
+
recordSidebarAutoDiag({
|
|
3252
|
+
kind: "install",
|
|
3253
|
+
reused: sharedAutoLedgerState.reused,
|
|
3254
|
+
degraded: sharedAutoLedgerState.degraded,
|
|
3255
|
+
sizes: sidebarSharedAutoLedgerSizes(sharedAutoLedger),
|
|
3256
|
+
});
|
|
3257
|
+
if (sharedAutoLedgerState.reused === true && sharedAutoLedgerState.degraded !== true
|
|
3258
|
+
&& sidebarSharedAutoLedgerTotal(sharedAutoLedger) > 0) {
|
|
3259
|
+
// 只在「复用了一份非空账本」时提示一行:用户开着 DevTools 就能确认 HMR 之后归属没有丢。
|
|
3260
|
+
try {
|
|
3261
|
+
console.info("[dsh-expert-group] 本 realm 已有共享 Auto 账本:本次装载复用既有的归属 / 预算 / 确认计数(bundle 重新执行或 HMR)");
|
|
3262
|
+
} catch { /* 控制台不可用不影响功能。 */ }
|
|
3263
|
+
}
|
|
3264
|
+
/**
|
|
3265
|
+
* realm 共享的 Auto 来源账本:key → { sessionId, rootSessionId, address, tabId }。
|
|
3266
|
+
*
|
|
3267
|
+
* tabId 是「Auto 打开/替换落地那一刻,插件亲眼在宿主 inventory 里读到的那个 tab」,它让这份
|
|
3268
|
+
* 跨 bundle 重新执行(HMR)/ 跨 Panel 实例的账本不再只是一句「我们打开过」——所有据此认定
|
|
3269
|
+
* auto 的位置都必须比对 tabId(见 sidebarAutoOriginEntry / sidebarAutoOriginTabIdOf)。
|
|
3270
|
+
*/
|
|
3271
|
+
const sharedSidebarAutoOrigin = sharedAutoLedger.origin;
|
|
3272
|
+
const sharedSidebarAutoSuppressed = sharedAutoLedger.suppressed;
|
|
3273
|
+
const sharedSidebarAutoCloseAttempts = sharedAutoLedger.closeAttempts;
|
|
2680
3274
|
/**
|
|
2681
3275
|
* 跨 Panel 实例共享的「这条自动关闭重试预算归谁」账本:key → 本次预支预算的**不可复用**令牌。
|
|
2682
3276
|
*
|
|
@@ -2684,7 +3278,7 @@
|
|
|
2684
3278
|
* 旧代次手里的令牌都不可能与后来者的令牌相等。写入与删除与 closeAttempts 同步进行,并沿用同一
|
|
2685
3279
|
* 个 trimAutoKeyMap/128 上限,绝不引入第二份无界 Map。
|
|
2686
3280
|
*/
|
|
2687
|
-
const sharedSidebarAutoCloseAttemptOwners =
|
|
3281
|
+
const sharedSidebarAutoCloseAttemptOwners = sharedAutoLedger.closeAttemptOwners;
|
|
2688
3282
|
/**
|
|
2689
3283
|
* 跨 Panel 实例共享的「close 请求已经发出、等待 inventory 验证」状态:key → 验证记录
|
|
2690
3284
|
* { sessionId, rootSessionId, tabId, address, attemptToken, manual, misses, deadline, lease }。
|
|
@@ -2693,14 +3287,14 @@
|
|
|
2693
3287
|
* 时才算 verified-closed。验证链因此必须与来源账本同层共享——重挂载后的新实例继续采样、继续收口,
|
|
2694
3288
|
* 而不是把「请求完成」当成「已经关闭」。租约同时被这条记录持有,下一轮同步绝不会重复 close。
|
|
2695
3289
|
*/
|
|
2696
|
-
const sharedSidebarAutoCloseVerifications =
|
|
3290
|
+
const sharedSidebarAutoCloseVerifications = sharedAutoLedger.closeVerifications;
|
|
2697
3291
|
/**
|
|
2698
3292
|
* 跨 Panel 实例共享的自动关闭失败冷却:key → { step, until }。
|
|
2699
3293
|
*
|
|
2700
3294
|
* 与重试计数同层共享:scope remount 或同一个实例 A→B→A 绝不能绕过失败上限(否则频繁重挂载就等于
|
|
2701
3295
|
* 无限重试)。generation token 只负责阻止旧动作越权,不改变这份预算的生命周期。
|
|
2702
3296
|
*/
|
|
2703
|
-
const sharedSidebarAutoCloseCooldowns =
|
|
3297
|
+
const sharedSidebarAutoCloseCooldowns = sharedAutoLedger.closeCooldowns;
|
|
2704
3298
|
/**
|
|
2705
3299
|
* 跨 Panel 实例共享的 Auto 候选采样计数:key → { sessionId, rootSessionId, tabId, misses }。
|
|
2706
3300
|
*
|
|
@@ -2718,7 +3312,7 @@
|
|
|
2718
3312
|
* - 限容:**纯 LRU**(按最近一次采样时间排序)+ SIDEBAR_AUTO_KEY_LIMIT,绝不新增无界 Map;
|
|
2719
3313
|
* 绝不用当前实例的局部 record / 在途状态判定别的 scope 的共享条目(见 trimSharedSidebarAutoTabMisses)。
|
|
2720
3314
|
*/
|
|
2721
|
-
const sharedSidebarAutoTabMisses =
|
|
3315
|
+
const sharedSidebarAutoTabMisses = sharedAutoLedger.tabMisses;
|
|
2722
3316
|
/**
|
|
2723
3317
|
* sharedSidebarAutoTabMisses 的限容淘汰:**纯 LRU**——按「最近一次采样」排序,淘汰最久没被采样的条目。
|
|
2724
3318
|
*
|
|
@@ -2764,7 +3358,7 @@
|
|
|
2764
3358
|
* 少了这层结果信号,等待者只能看到「锁空了」:要么对已经关掉的 tab 重复 close,要么把一次失败的
|
|
2765
3359
|
* 竞争当成「有人处理了」而丢掉用户的关闭意图。
|
|
2766
3360
|
*/
|
|
2767
|
-
const sharedSidebarTabCloseInFlight =
|
|
3361
|
+
const sharedSidebarTabCloseInFlight = sharedAutoLedger.tabCloseInFlight;
|
|
2768
3362
|
|
|
2769
3363
|
function Panel(props) {
|
|
2770
3364
|
const ctx = props?.ctx || config.ctx;
|
|
@@ -3376,6 +3970,19 @@
|
|
|
3376
3970
|
};
|
|
3377
3971
|
const sidebarOpenTabsAvailable = readSidebarOpenTabs() !== null;
|
|
3378
3972
|
const snapshot = viewState.snapshot;
|
|
3973
|
+
/**
|
|
3974
|
+
* 轻量(partial)快照标记:Host 首屏返回 partial:true 表示它跳过了全部冷读,
|
|
3975
|
+
* 状态与任务名可能不完整。渲染层据此把「没有任何活动证据」的圆点与活动文案保持中性;
|
|
3976
|
+
* 完整快照(partial:false/缺失)与旧宿主一律走既有渲染路径,逐字不变。
|
|
3977
|
+
*/
|
|
3978
|
+
const partialSnapshot = snapshot?.partial === true;
|
|
3979
|
+
/**
|
|
3980
|
+
* 容器(空闲分组)在轻量快照下使用的中性运行状态:取值与模型导出的
|
|
3981
|
+
* UNKNOWN_RUNTIME_STATUS 同源,模型缺失时按同名字面量兜底,避免两处各存一份词表。
|
|
3982
|
+
*/
|
|
3983
|
+
const neutralRuntimeStatus = typeof model.UNKNOWN_RUNTIME_STATUS === "string" && model.UNKNOWN_RUNTIME_STATUS.length > 0
|
|
3984
|
+
? model.UNKNOWN_RUNTIME_STATUS
|
|
3985
|
+
: "unknown";
|
|
3379
3986
|
const sidebarSnapshotSyncRef = typeof react.useRef === "function"
|
|
3380
3987
|
? react.useRef({ rootSessionId, sidebarSessionId, snapshot })
|
|
3381
3988
|
: { current: { rootSessionId, sidebarSessionId, snapshot } };
|
|
@@ -3792,15 +4399,63 @@
|
|
|
3792
4399
|
/** 当前快照的 root 直接子节点(快照缺失时为空数组)。 */
|
|
3793
4400
|
const sidebarRootChildren = () => (Array.isArray(snapshot?.root?.children) ? snapshot.root.children : []);
|
|
3794
4401
|
|
|
4402
|
+
/**
|
|
4403
|
+
* 空闲证据规则(空闲分组与空闲批量关闭候选的**唯一**判定入口)。
|
|
4404
|
+
*
|
|
4405
|
+
* 只有 runtimeStatusOfNode(...) === "ready"(**有证据**表明当前未运行)才算空闲:
|
|
4406
|
+
* - 轻量快照(partial:true)下「没有任何活动证据」的节点被归一为中性值 unknown,
|
|
4407
|
+
* 它既不是 running 也不是 ready,因此既不得进入空闲分组、也不得成为空闲批量关闭候选——
|
|
4408
|
+
* 否则用户会依据轻量快照关掉实际仍在运行、只是首屏读不到状态的会话;
|
|
4409
|
+
* - 完整快照(partial:false)到达后不会再出现 unknown,规则自动回到既有行为,不留永久性差异;
|
|
4410
|
+
* - running 与 unknown 一样被排除,保守方向不变。
|
|
4411
|
+
*
|
|
4412
|
+
* partial 证据规则通过 options 一路传到模型的活动证据链,与行内圆点、第三行文案同源;
|
|
4413
|
+
* 这里只读当前渲染的 partialSnapshot,切换 root 或快照换代都会随闭包一起更新。
|
|
4414
|
+
*/
|
|
4415
|
+
const sidebarNodeIsIdleReady = (node, parentId, childId) =>
|
|
4416
|
+
runtimeStatusOfNode(node, parentId, childId, { partial: partialSnapshot }) === "ready";
|
|
4417
|
+
|
|
4418
|
+
/**
|
|
4419
|
+
* 空闲分组的**成员**判定(显示从宽)。
|
|
4420
|
+
*
|
|
4421
|
+
* 在严格判据之外,额外接受轻量快照下「没有任何活动证据」的 unknown 节点:它们与 ready
|
|
4422
|
+
* 一样被收纳进分组,只是圆点与文案保持中性(见 renderExpert / renderIdleGroup)。
|
|
4423
|
+
* 这样同一份数据在 partial:true 与 partial:false 下分组**成员集合与顺序完全一致**,
|
|
4424
|
+
* 消除「同一节点在两条路径之间来回跳位」的用户可见缺陷。
|
|
4425
|
+
*
|
|
4426
|
+
* 只放宽**显示**:任何会真的操作宿主资源的路径(批量关闭候选、manual 抑制清理)都必须继续
|
|
4427
|
+
* 使用严格的 sidebarNodeIsIdleReady,不得改用本函数。
|
|
4428
|
+
*/
|
|
4429
|
+
const sidebarNodeIsIdleMembership = (node, parentId, childId) => {
|
|
4430
|
+
const status = runtimeStatusOfNode(node, parentId, childId, { partial: partialSnapshot });
|
|
4431
|
+
if (status === "ready") return true;
|
|
4432
|
+
// unknown 只可能来自 partial 快照(完整快照的证据链不会返回它):此时纳入分组显示。
|
|
4433
|
+
return partialSnapshot === true && status === neutralRuntimeStatus;
|
|
4434
|
+
};
|
|
4435
|
+
|
|
3795
4436
|
/**
|
|
3796
4437
|
* 空闲分组收纳 ready 直接专家和已结算的 fan-out 父任务。
|
|
3797
|
-
* 父任务必须命中 manager
|
|
4438
|
+
* 父任务必须命中 manager 终态,不能仅凭子会话暂时未运行或未知状态提前移动(manager 的聚合
|
|
4439
|
+
* 状态本身就是真实证据,不是「没有证据」,因此不受 partial 影响)。
|
|
4440
|
+
*
|
|
4441
|
+
* 直接专家走 sidebarNodeIsIdleMembership:**显示从宽**——轻量快照下没有活动证据的专家
|
|
4442
|
+
* (unknown)也纳入分组,使它在 partial / full 下的组内相对位置完全一致。
|
|
4443
|
+
*
|
|
4444
|
+
* 为什么显示要放宽:排序键是 createdAt,而轻量快照没有冷读、拿不到专家会话的创建时间
|
|
4445
|
+
* (真实 child_models 记录里也没有该字段),因此同一批节点在 partial 与 full 下的排序结果
|
|
4446
|
+
* 本来就可能不同;若再让「是否进组」也随证据变化,同一次 partial↔full 切换会**同时**改变
|
|
4447
|
+
* 分组归属与组内下标,用户在空闲列表里看到的就是「某个 fan-out 分组每隔 2.5 秒来回跳」。
|
|
4448
|
+
* 让成员集合在两条路径下一致,位置变化就只剩「锚点补齐前的一次性收敛」,不再是反复跳动。
|
|
4449
|
+
*
|
|
4450
|
+
* 操作安全性不变:unknown 节点**仍然不参与**任何批量/清理动作——空闲批量关闭候选
|
|
4451
|
+
* (idleSidebarResourceAddresses)与 manual 抑制清理(clearManualSuppressionOfIdleResources)
|
|
4452
|
+
* 都继续用严格的 sidebarNodeIsIdleReady 判定,绝不放宽。
|
|
3798
4453
|
*/
|
|
3799
4454
|
const sidebarIdleChildren = () => sidebarRootChildren()
|
|
3800
4455
|
.filter((node) => {
|
|
3801
4456
|
if (node.kind === "fanout") return FANOUT_SETTLED_STATUSES.has(statusOf(node));
|
|
3802
4457
|
return node.kind === "expert"
|
|
3803
|
-
&&
|
|
4458
|
+
&& sidebarNodeIsIdleMembership(node, rootSessionId, text(node.sessionId, null));
|
|
3804
4459
|
});
|
|
3805
4460
|
|
|
3806
4461
|
/**
|
|
@@ -3814,8 +4469,14 @@
|
|
|
3814
4469
|
};
|
|
3815
4470
|
|
|
3816
4471
|
/**
|
|
3817
|
-
*
|
|
3818
|
-
*
|
|
4472
|
+
* 空闲分组的资源地址集合(空闲批量关闭的**唯一**候选来源)。
|
|
4473
|
+
*
|
|
4474
|
+
* 注意这里与分组**成员**判定是两条不同严格度的规则(修复 B 的「显示从宽、操作从严」):
|
|
4475
|
+
* 分组成员用 sidebarNodeIsIdleMembership(unknown 也进组显示),而候选在这里用严格的
|
|
4476
|
+
* sidebarNodeIsIdleReady 再复核一次——轻量快照下没有活动证据(unknown)的节点虽然现在会
|
|
4477
|
+
* 出现在分组里,但绝不会成为可关闭的目标。两道关口的差异是**有意**的:
|
|
4478
|
+
* 位置稳定性优先,操作安全性不变(一次误关会毁掉用户正在运行的会话,代价远高于一次跳位)。
|
|
4479
|
+
* fan-out 父任务代表其最新 attempt 的分片会话,只收录当前未运行(ready)的会话地址。
|
|
3819
4480
|
*/
|
|
3820
4481
|
const idleSidebarResourceAddresses = (nodes) => {
|
|
3821
4482
|
const addresses = new Set();
|
|
@@ -3825,7 +4486,9 @@
|
|
|
3825
4486
|
for (const shard of Array.isArray(source.shards) ? source.shards : []) {
|
|
3826
4487
|
const shardSource = object(shard);
|
|
3827
4488
|
const shardChildId = text(shardSource.childId, null);
|
|
3828
|
-
|
|
4489
|
+
// childId 缺失(Host 在轻量快照里清空了无法证明归属的 child)与 unknown 状态一样,
|
|
4490
|
+
// 都不能进候选:没有精确地址就没有可关闭的目标,没有证据就不能判定它已空闲。
|
|
4491
|
+
if (shardChildId === null || sidebarNodeIsIdleReady(shardSource, rootSessionId, shardChildId) !== true) continue;
|
|
3829
4492
|
const shardAddress = subagentChatAddress(subagentAddressOfChild(shardChildId, rootSessionId, {
|
|
3830
4493
|
...shardSource,
|
|
3831
4494
|
kind: "expert",
|
|
@@ -3836,6 +4499,9 @@
|
|
|
3836
4499
|
}
|
|
3837
4500
|
const childId = text(source.sessionId, text(source.childId, null));
|
|
3838
4501
|
if (childId === null) continue;
|
|
4502
|
+
// 防御式复核:实参与 idleChildren 的成员判定逐字相同,因此对既有成员的结果完全不变;
|
|
4503
|
+
// 即使调用方以后传进来一份非 idleChildren 的列表,unknown/running 也绝不会漏成候选。
|
|
4504
|
+
if (sidebarNodeIsIdleReady(source, rootSessionId, text(source.sessionId, null)) !== true) continue;
|
|
3839
4505
|
const address = subagentChatAddress(subagentAddressOfChild(childId, rootSessionId, source));
|
|
3840
4506
|
if (address !== null) addresses.add(address);
|
|
3841
4507
|
}
|
|
@@ -4250,6 +4916,17 @@
|
|
|
4250
4916
|
// 资源重新变成运行中:它已经开着就保持开着,重试预算、owner 身份与失败冷却一并清零。
|
|
4251
4917
|
// **绝不**丢弃仍在验证中的关闭:那条链属于「已经发出的一次关闭」,它的收口(删记录、
|
|
4252
4918
|
// 撤销来源 / 登记抑制)与重试预算无关;丢掉它会让 record 与箭头永久停在 <<。
|
|
4919
|
+
/**
|
|
4920
|
+
* 归属标记同源清理:只有在「这个 key 上确实有过一次关闭在途/预算消耗」时才清——那才是
|
|
4921
|
+
* 「资源又跑起来了,旧归属已经过期」的形状;普通的运行中 Auto tab(刚打开、没有任何关闭
|
|
4922
|
+
* 记账)绝不能被顺手清掉,否则一次刷新就会把它降级成 manual,正是本插件要修的漏收。
|
|
4923
|
+
* 清掉之后,下一次自动打开会重新打点(见 recordSidebarTab 的 auto-open 写入)。
|
|
4924
|
+
*/
|
|
4925
|
+
if (attempts.has(key) === true
|
|
4926
|
+
|| sidebarAutoCloseCooldownsRef.current.has(key) === true
|
|
4927
|
+
|| sidebarAutoCloseVerificationsRef.current.has(key) === true) {
|
|
4928
|
+
deleteSidebarAutoOwnership(sidebarSessionId, rootSessionId, entry.address, "resource-running");
|
|
4929
|
+
}
|
|
4253
4930
|
clearAutoCloseBudget(key);
|
|
4254
4931
|
continue;
|
|
4255
4932
|
|
|
@@ -4275,6 +4952,23 @@
|
|
|
4275
4952
|
clearAutoCloseAttempt(key);
|
|
4276
4953
|
continue;
|
|
4277
4954
|
}
|
|
4955
|
+
/**
|
|
4956
|
+
* 来源账本 ⇄ 记录 的 tabId 绑定闸门(与收养路径同一条判据,缺陷 1 的第二个消费点)。
|
|
4957
|
+
*
|
|
4958
|
+
* 这本账本是 realm 共享的,条目里的 tabId 是「Auto 打开/替换落地那一刻亲眼看到的 tab」。
|
|
4959
|
+
* 条目与记录指向同一个 tab 时它才证明这条关闭候选仍是我们自己开出来的那个 tab;一旦两者
|
|
4960
|
+
* 对不上(宿主重 mint 了新 id、而插件没有观察到那次替换),账本那条已经不能代表任何还在
|
|
4961
|
+
* 我们手里的 tab —— 按安全侧撤销这条自动来源,绝不按它关闭。
|
|
4962
|
+
*
|
|
4963
|
+
* 条目 tabId 为 null(这次自动打开已经发出、落地证据还没到)不触发本闸门:那种条目本身
|
|
4964
|
+
* 就不是归属证据,真正的判据在下一条(scopedRecord 的 tabId)与关闭 preflight 上。
|
|
4965
|
+
*/
|
|
4966
|
+
const boundAutoTabId = sidebarAutoOriginTabIdOf(entry);
|
|
4967
|
+
if (boundAutoTabId !== null && boundAutoTabId !== scopedRecord.tabId) {
|
|
4968
|
+
origin.delete(key);
|
|
4969
|
+
clearAutoCloseAttempt(key);
|
|
4970
|
+
continue;
|
|
4971
|
+
}
|
|
4278
4972
|
/**
|
|
4279
4973
|
* 同一个 tab 上已经有一次关闭在途(典型:旧 Panel 实例的 close Promise 还没结算,当前实例
|
|
4280
4974
|
* 刚刚从宿主 inventory 收养了它):这一轮直接让位,绝不排队第二次关闭。
|
|
@@ -4321,6 +5015,7 @@
|
|
|
4321
5015
|
records.delete(key);
|
|
4322
5016
|
origin.delete(key);
|
|
4323
5017
|
clearAutoCloseAttempt(key);
|
|
5018
|
+
deleteSidebarAutoOwnership(sidebarSessionId, rootSessionId, entry.address, "inventory-absent");
|
|
4324
5019
|
bump((value) => value + 1);
|
|
4325
5020
|
continue;
|
|
4326
5021
|
}
|
|
@@ -4328,6 +5023,7 @@
|
|
|
4328
5023
|
records.delete(key);
|
|
4329
5024
|
origin.delete(key);
|
|
4330
5025
|
clearAutoCloseBudget(key);
|
|
5026
|
+
deleteSidebarAutoOwnership(sidebarSessionId, rootSessionId, entry.address, "tab-recycled");
|
|
4331
5027
|
bump((value) => value + 1);
|
|
4332
5028
|
continue;
|
|
4333
5029
|
}
|
|
@@ -4386,13 +5082,36 @@
|
|
|
4386
5082
|
const key = sidebarTabKey(sidebarSessionId, rootSessionId, address);
|
|
4387
5083
|
// 本次级联里已经为它尝试过并失败:绝不在同一批微任务里重试(见 sidebarAutoFailedKeysRef)。
|
|
4388
5084
|
if (sidebarAutoFailedKeysRef.current.has(key) === true) continue;
|
|
4389
|
-
//
|
|
5085
|
+
// 用户手动关闭过:绝不立刻重开(抑制只由用户手动重新打开,或资源明确空闲后的清点清除)。
|
|
4390
5086
|
if (suppressed.has(key) === true) continue;
|
|
5087
|
+
/**
|
|
5088
|
+
* 持久 manual 抑制(跨整页刷新):内存里的 suppressed 在刷新之后是空的,但用户的关闭意图
|
|
5089
|
+
* 落在持久条目上(见 SIDEBAR_AUTO_OWNERSHIP_KEY)——manual 恒优先,这里必须与内存抑制同强度
|
|
5090
|
+
* 地跳过这个地址。少了这一步,「资源仍在运行 + 用户已手动关闭该 tab + 刷新」会被 Auto
|
|
5091
|
+
* 重新打开(P1-B)。
|
|
5092
|
+
*
|
|
5093
|
+
* 抑制的清除时机**只**由资源状态决定:观察到这个地址明确空闲(ready)时清除(见
|
|
5094
|
+
* clearManualSuppressionOfIdleResources)。因此刷新时正在运行、用户已关闭的 tab 不会被重开,
|
|
5095
|
+
* 而子代理真正结束之后标记消失,下一次运行仍能正常自动打开。
|
|
5096
|
+
*
|
|
5097
|
+
* 读面走 quiet 通道:这条判据每轮同步都会执行,结论由下面那条决策诊断承担。
|
|
5098
|
+
*/
|
|
5099
|
+
const persistedForOpen = readSidebarAutoOwnershipEntry(sidebarSessionId, rootSessionId, address, { quiet: true });
|
|
5100
|
+
if (persistedForOpen !== null && persistedForOpen.origin === SIDEBAR_AUTO_OWNERSHIP_ORIGIN_MANUAL) {
|
|
5101
|
+
recordSidebarAutoDecision("open", { key, kind: "open" }, "skip", "persisted-manual");
|
|
5102
|
+
continue;
|
|
5103
|
+
}
|
|
4391
5104
|
// 本次运行已经为它成功发出过打开动作:绝不重复打开(与失败无关,失败会撤销预留)。
|
|
4392
5105
|
if (origin.has(key) === true) continue;
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
5106
|
+
/**
|
|
5107
|
+
* 容量 fail-closed:内存账本或持久表装不下这条新 owner 时**不打开**(记 owner-capacity
|
|
5108
|
+
* 诊断)。绝不为了腾位淘汰别的 scope 仍然有效的 owner——到达上限是可用性代价而不是错误:
|
|
5109
|
+
* 用户仍然可以手动打开这个资源(手动打开照常写 manual 归属)。
|
|
5110
|
+
*/
|
|
5111
|
+
if (autoSidebarOwnershipHasCapacity(key, address) !== true) {
|
|
5112
|
+
recordSidebarAutoDecision("open", { key, kind: "open" }, "skip", "owner-capacity");
|
|
5113
|
+
continue;
|
|
5114
|
+
}
|
|
4396
5115
|
// 已经有一个候选为它预留:同一资源绝不并发两个动作。
|
|
4397
5116
|
if (reservations.has(key) === true) continue;
|
|
4398
5117
|
if (sidebarTabPendingRef.current.has(key) === true) continue;
|
|
@@ -4428,8 +5147,14 @@
|
|
|
4428
5147
|
* 上限淘汰(复合键跨 root 保留时的安全阀,见 SIDEBAR_AUTO_KEY_LIMIT)。
|
|
4429
5148
|
*
|
|
4430
5149
|
* activeAware = true 时优先淘汰「没有当前 record / 没有在途动作 / 没有验证 / 没有冷却」的历史条目;
|
|
4431
|
-
* 若 128 项全是活动条目,则**什么都不淘汰**(fail-closed
|
|
4432
|
-
*
|
|
5150
|
+
* 若 128 项全是活动条目,则**什么都不淘汰**(fail-closed)。false 保持既有语义(按插入顺序丢最旧)。
|
|
5151
|
+
*
|
|
5152
|
+
* 注意(缺陷 2):这里调用 sidebarAutoCloseKeyIsActive 判「活动」,而它读的是**当前这个 Panel
|
|
5153
|
+
* 实例**的在途状态与**当前 scope**的 record。因此这套淘汰只允许用在「条目重新出现时会自行重建」
|
|
5154
|
+
* 的记账上(重试预算 / owner 令牌 / 冷却 / 抑制)。**归属账本不复用它**:那种判据会把别的
|
|
5155
|
+
* root 仍然有效的 Auto owner 判成 inactive 淘汰掉(内存),那个 tab 从此永久脱离 Auto——
|
|
5156
|
+
* 归属容量改由 autoOpenCandidate 在发出 openResource 之前 fail-closed 拒绝(不淘汰任何人),
|
|
5157
|
+
* 见 autoSidebarOwnershipHasCapacity。
|
|
4433
5158
|
*/
|
|
4434
5159
|
const trimAutoKeyMap = (map, activeAware) => {
|
|
4435
5160
|
while (map.size > SIDEBAR_AUTO_KEY_LIMIT) {
|
|
@@ -4488,19 +5213,28 @@
|
|
|
4488
5213
|
clearAutoCloseAttempt(key);
|
|
4489
5214
|
};
|
|
4490
5215
|
/**
|
|
4491
|
-
*
|
|
5216
|
+
* 新 owner 能不能被**内存账本 + 持久表**同时保留(fail-closed 容量判据,缺陷 2)。
|
|
4492
5217
|
*
|
|
4493
|
-
*
|
|
4494
|
-
*
|
|
5218
|
+
* 判据刻意与当前面板的局部状态无关:内存账本只回答「还装得下吗」(size < 上限),持久表由
|
|
5219
|
+
* sidebarOwnershipAcceptsNewAuto 回答(写入之后 auto 条目数不越过上限)。**绝不**再看
|
|
5220
|
+
* 「这个 key 在别的 scope 里是否活动」来决定谁可以被淘汰:
|
|
5221
|
+
*
|
|
5222
|
+
* - 旧实现在这里扫描整本 origin,用 sidebarAutoCloseKeyIsActive(它读的是**当前这个实例**的
|
|
5223
|
+
* pending / opening / reservation / verification / cooldown 与**当前 scope**的 record)挑一个
|
|
5224
|
+
* 「不活动」的条目当可淘汰对象。别的 root / sidebar session 的条目在当前实例里天然「不活动」,
|
|
5225
|
+
* 于是 B scope 打开 128 个资源就能把 A scope 仍然有效的 owner 判成 inactive 淘汰掉——A 那个
|
|
5226
|
+
* tab 从此永久脱离 Auto(自动关闭只遍历这本账本,刷新也不能恢复)。
|
|
5227
|
+
* - 换成纯 LRU 同样不安全:淘汰依据只是「更旧」,而被淘汰的条目完全可能仍然对应一个打开的 tab。
|
|
5228
|
+
*
|
|
5229
|
+
* 因此到达上限时唯一的动作是**不开**(记录 owner-capacity 诊断,见 autoOpenCandidate):
|
|
5230
|
+
* 不打开只是可用性代价(用户仍可手动打开,手动打开照常写 manual 归属),而误淘汰是永久漏收。
|
|
5231
|
+
* owner 释放(verified-closed / inventory-absent / tab-recycled / 资源重新运行等既有收口)之后,
|
|
5232
|
+
* 新的自动打开自动恢复正常。
|
|
4495
5233
|
*/
|
|
4496
|
-
const autoSidebarOwnershipHasCapacity = (key) => {
|
|
5234
|
+
const autoSidebarOwnershipHasCapacity = (key, address) => {
|
|
4497
5235
|
const origin = sidebarAutoOriginRef.current;
|
|
4498
|
-
if (origin.has(key)
|
|
4499
|
-
|
|
4500
|
-
for (const candidate of origin.keys()) {
|
|
4501
|
-
if (sidebarAutoCloseKeyIsActive(candidate) !== true) return true;
|
|
4502
|
-
}
|
|
4503
|
-
return false;
|
|
5236
|
+
if (origin.has(key) !== true && origin.size >= SIDEBAR_AUTO_KEY_LIMIT) return false;
|
|
5237
|
+
return sidebarOwnershipAcceptsNewAuto(sidebarSessionId, rootSessionId, address);
|
|
4504
5238
|
};
|
|
4505
5239
|
/**
|
|
4506
5240
|
* 无条件收口一个 key 的共享自动关闭记账(重试预算 + owner 身份)。
|
|
@@ -4855,6 +5589,11 @@
|
|
|
4855
5589
|
if (entry.manual === true && entry.address !== null && entry.address !== undefined) {
|
|
4856
5590
|
sidebarAutoSuppressedRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, address: entry.address });
|
|
4857
5591
|
trimAutoKeyMap(sidebarAutoSuppressedRef.current);
|
|
5592
|
+
// 手动关闭:抑制语义同步落到持久归属标记上(刷新之后同样绝不再当 auto)。
|
|
5593
|
+
writeSidebarAutoOwnership(sidebarSessionId, rootSessionId, entry.address, SIDEBAR_AUTO_OWNERSHIP_ORIGIN_MANUAL, "manual-close");
|
|
5594
|
+
} else if (entry.address !== null && entry.address !== undefined) {
|
|
5595
|
+
// 验证关闭成功 / inventory 已证明这个 tabId 不在:归属标记同步删除,绝不残留陈旧 auto。
|
|
5596
|
+
deleteSidebarAutoOwnership(sidebarSessionId, rootSessionId, entry.address, "verified-closed");
|
|
4858
5597
|
}
|
|
4859
5598
|
} else if (entry.attemptToken !== null && entry.attemptToken !== undefined) {
|
|
4860
5599
|
// tabId 已经不属于这次验证:只按令牌结算本次动作自己的那一笔预算(owner 已被后来者替换时
|
|
@@ -5098,6 +5837,65 @@
|
|
|
5098
5837
|
sidebarAutoReservationRef.current.delete(key);
|
|
5099
5838
|
}
|
|
5100
5839
|
};
|
|
5840
|
+
/**
|
|
5841
|
+
* 回收「Auto 打开已经发出、tabId 始终没落地」的共享来源**占位**(缺陷 2)。
|
|
5842
|
+
*
|
|
5843
|
+
* 为什么必须有这一步:占位同时承担两件事——(a) autoOpenCandidate 靠 origin.has(key) 跳过
|
|
5844
|
+
* 「本次运行已经为它打开过」,(b) 它占着归属账本的容量槽位(128)。宿主始终没落地该 tab 时,
|
|
5845
|
+
* 这两件事都会变成长期伤害:资源明明还在 running,Auto 却既不再打开、又占着名额,一批这样的
|
|
5846
|
+
* 占位就能把账本占满,让 Auto 再也开不出任何新 tab——只有整页刷新(内存账本归零)才能恢复。
|
|
5847
|
+
*
|
|
5848
|
+
* 判据(只在期限到达之后才动,期限见 SIDEBAR_AUTO_OPEN_PLACEHOLDER_TTL_MS):
|
|
5849
|
+
* - 本 scope:先按当前 inventory **精确核对**。地址上仍然没有 tab ⇒ 释放占位,让下一轮外部同步
|
|
5850
|
+
* 重新选出这个候选(一次有界重试:至少间隔一个 TTL 窗口,同一批微任务里还有失败屏障兜底);
|
|
5851
|
+
* 地址上已经有 tab ⇒ 同样释放(归属判定已经由 record / 收养路径接管,占位不再有意义);
|
|
5852
|
+
* **inventory 读不出来 ⇒ 一律保留(fail-closed)**:读不到既不证明存在、也不证明不存在,
|
|
5853
|
+
* 绝不当成「没有 tab」去释放。
|
|
5854
|
+
* - 别的 scope:占位本身不是归属证据(tabId 为空),释放它不会授权任何关闭;而对方候选选择
|
|
5855
|
+
* 必须在发出 openResource 之前重新核对它自己的 inventory,因此也不可能因此开出重复 tab。
|
|
5856
|
+
* 为免打断另一个 surface 仍在进行中的打开,这里用 4 倍期限(FOREIGN_TTL)。
|
|
5857
|
+
*
|
|
5858
|
+
* 在途动作(pending / 本地 opening)绝不释放:那两种状态说明这次打开还在被观察,提前释放会让
|
|
5859
|
+
* 尾随同步立刻再开一次。
|
|
5860
|
+
*/
|
|
5861
|
+
const sweepAutoOpenPlaceholders = (running) => {
|
|
5862
|
+
const originMap = sidebarAutoOriginRef.current;
|
|
5863
|
+
if (originMap.size === 0) return;
|
|
5864
|
+
const now = Date.now();
|
|
5865
|
+
for (const [key, entry] of [...originMap]) {
|
|
5866
|
+
// 已落地的条目(tabId 非空)由既有收口路径负责(verified-closed / inventory-absent /
|
|
5867
|
+
// tab-recycled / resource-running),TTL 绝不去碰它们。
|
|
5868
|
+
if (sidebarAutoOriginTabIdOf(entry) !== null) continue;
|
|
5869
|
+
const age = now - sidebarAutoOriginWrittenAtOf(entry);
|
|
5870
|
+
const scoped = entry.sessionId === sidebarSessionId && entry.rootSessionId === rootSessionId;
|
|
5871
|
+
if (scoped !== true) {
|
|
5872
|
+
if (age < SIDEBAR_AUTO_OPEN_PLACEHOLDER_FOREIGN_TTL_MS) continue;
|
|
5873
|
+
originMap.delete(key);
|
|
5874
|
+
recordSidebarAutoDiag({ kind: "ownership", event: "delete", reason: "placeholder-foreign-expired", key: sidebarAutoKeyDigest(key) });
|
|
5875
|
+
continue;
|
|
5876
|
+
}
|
|
5877
|
+
if (age < SIDEBAR_AUTO_OPEN_PLACEHOLDER_TTL_MS) continue;
|
|
5878
|
+
if (sidebarTabPendingRef.current.has(key) === true) continue;
|
|
5879
|
+
if (sidebarTabOpeningRef.current.has(key) === true) continue;
|
|
5880
|
+
const state = sidebarResourceTabStateOf(entry.address);
|
|
5881
|
+
if (state.readable !== true) {
|
|
5882
|
+
// fail-closed:读不到 inventory 时保留占位,绝不把「读不到」当成「没有 tab」。
|
|
5883
|
+
recordSidebarAutoDiag({ kind: "ownership", event: "keep", reason: "placeholder-unreadable", key: sidebarAutoKeyDigest(key) });
|
|
5884
|
+
continue;
|
|
5885
|
+
}
|
|
5886
|
+
originMap.delete(key);
|
|
5887
|
+
recordSidebarAutoDiag({
|
|
5888
|
+
kind: "ownership",
|
|
5889
|
+
event: "delete",
|
|
5890
|
+
reason: state.tabId === null
|
|
5891
|
+
? (running !== null && running !== undefined && running.has(entry.address) === true
|
|
5892
|
+
? "placeholder-retry"
|
|
5893
|
+
: "placeholder-released")
|
|
5894
|
+
: "placeholder-superseded",
|
|
5895
|
+
key: sidebarAutoKeyDigest(key),
|
|
5896
|
+
});
|
|
5897
|
+
}
|
|
5898
|
+
};
|
|
5101
5899
|
/**
|
|
5102
5900
|
* 放行一个自动动作。
|
|
5103
5901
|
*
|
|
@@ -5107,11 +5905,23 @@
|
|
|
5107
5905
|
*/
|
|
5108
5906
|
const launchAutoSidebarAction = (candidate) => {
|
|
5109
5907
|
sidebarAutoBusyRef.current = true;
|
|
5908
|
+
/**
|
|
5909
|
+
* 自动打开/自动关闭决策的诊断记录(只记结论与 key 摘要,绝不记提示词/正文/令牌):
|
|
5910
|
+
* 候选选中时记一条 launch,结算/失败时再记一条,便于事后离线核对「某次构建之后这个 tab 为什么
|
|
5911
|
+
* 没被收回去」。
|
|
5912
|
+
*/
|
|
5913
|
+
recordSidebarAutoDecision("launch", candidate, "candidate");
|
|
5110
5914
|
void Promise.resolve()
|
|
5111
5915
|
// candidate 作为自动动作上下文(kind/epoch/预留)传入:动作必须在每个 await 之后
|
|
5112
5916
|
// 复核它是否仍然有效,并在收尾时无条件释放预留(见 handleSidebarAction)。
|
|
5113
5917
|
.then(() => handleSidebarAction(candidate.key, candidate.address, SIDEBAR_TAB_ORIGIN_AUTO, candidate))
|
|
5114
|
-
.
|
|
5918
|
+
.then(
|
|
5919
|
+
() => { recordSidebarAutoDecision("settle", candidate, "ok"); },
|
|
5920
|
+
(error) => {
|
|
5921
|
+
/* 打开/关闭失败只影响这一次动作:状态由既有记录与重试记账负责。 */
|
|
5922
|
+
recordSidebarAutoDecision("settle", candidate, "failed", sidebarBoundedText(error === null || error === undefined ? undefined : error.message));
|
|
5923
|
+
},
|
|
5924
|
+
)
|
|
5115
5925
|
.then(() => {
|
|
5116
5926
|
sidebarAutoBusyRef.current = false;
|
|
5117
5927
|
/**
|
|
@@ -5139,6 +5949,39 @@
|
|
|
5139
5949
|
}
|
|
5140
5950
|
});
|
|
5141
5951
|
};
|
|
5952
|
+
/**
|
|
5953
|
+
* 观察到「资源已经明确空闲」时清除这个地址的 manual 抑制(内存 + 持久两层)。
|
|
5954
|
+
*
|
|
5955
|
+
* 清除时机只按**资源状态**判定(onIdle):
|
|
5956
|
+
* - 资源仍在运行(在 running 集合里)⇒ 一律保留:这正是「刷新时正在运行、用户已手动关闭的 tab」
|
|
5957
|
+
* 的形状,清掉就等于把用户的关闭又撤销一次(P1-B);
|
|
5958
|
+
* - 资源已明确空闲(ready)⇒ 清除:这一轮运行已经结束,用户当时的关闭意图不再需要跨运行保留,
|
|
5959
|
+
* 下一次运行必须能正常自动打开(否则一次手动关闭就永久关掉了这个地址的自动行为);
|
|
5960
|
+
* - 快照里既看不到运行也看不到空闲(暂时缺项 / 状态 unknown)⇒ 一律保留:那既不证明在运行、
|
|
5961
|
+
* 也不证明已结束,按 fail-closed 不动。空闲集合由 idleSidebarResourceAddresses 给出,它用的是
|
|
5962
|
+
* **严格**证据规则(sidebarNodeIsIdleReady),因此轻量快照下虽然 unknown 节点也会显示在空闲
|
|
5963
|
+
* 分组里(修复 B 的显示从宽),它们的 manual 抑制仍然一律保留、绝不被这里清掉。
|
|
5964
|
+
*
|
|
5965
|
+
* 只清 manual:
|
|
5966
|
+
* - 内存层(sidebarAutoSuppressedRef)与持久层是同一个语义的两份存储,必须一起清,否则刷新前的
|
|
5967
|
+
* 内存抑制会继续挡住下一次运行;
|
|
5968
|
+
* - **auto 标记绝不在这里清除**(origin !== manual 直接跳过):一个正在等待收口的 auto tab 一旦
|
|
5969
|
+
* 在空闲观察里被降级成 manual,就再也不会被自动收回——auto 仍只由 verified-close /
|
|
5970
|
+
* inventory-absent / tab-recycled 三条路径清除(见 deleteSidebarAutoOwnership 的说明)。
|
|
5971
|
+
*
|
|
5972
|
+
* 限项:只遍历当前 root 的空闲地址(快照已有),因此不会跨 scope 误清别的 root / session 的键
|
|
5973
|
+
* (键由当前 sidebar session + root + 地址现算)。
|
|
5974
|
+
*/
|
|
5975
|
+
const clearManualSuppressionOfIdleResources = (idle) => {
|
|
5976
|
+
if (idle === null || idle === undefined || idle.size === 0) return;
|
|
5977
|
+
for (const address of idle) {
|
|
5978
|
+
const key = sidebarTabKey(sidebarSessionId, rootSessionId, address);
|
|
5979
|
+
if (sidebarAutoSuppressedRef.current.has(key) === true) sidebarAutoSuppressedRef.current.delete(key);
|
|
5980
|
+
const persisted = readSidebarAutoOwnershipEntry(sidebarSessionId, rootSessionId, address, { quiet: true });
|
|
5981
|
+
if (persisted === null || persisted.origin !== SIDEBAR_AUTO_OWNERSHIP_ORIGIN_MANUAL) continue;
|
|
5982
|
+
deleteSidebarAutoOwnership(sidebarSessionId, rootSessionId, address, "resource-idle", { manual: true });
|
|
5983
|
+
}
|
|
5984
|
+
};
|
|
5142
5985
|
/**
|
|
5143
5986
|
* 第二阶段:Auto(自动打开运行中的子代理侧栏)同步。
|
|
5144
5987
|
*
|
|
@@ -5160,6 +6003,17 @@
|
|
|
5160
6003
|
const known = knownSidebarResourceAddresses();
|
|
5161
6004
|
const running = runningSidebarResourceAddresses();
|
|
5162
6005
|
const enabled = autoOpenEnabled();
|
|
6006
|
+
/**
|
|
6007
|
+
* 先回收过期的打开占位(缺陷 2):它只维护插件自己的记账、绝不触碰宿主,因此与 Auto 开关无关
|
|
6008
|
+
* (与下面的记账清理同一条边界)。放在候选选择之前,被回收的容量当轮就能重新用上。
|
|
6009
|
+
*/
|
|
6010
|
+
sweepAutoOpenPlaceholders(running);
|
|
6011
|
+
/**
|
|
6012
|
+
* manual 抑制的清除只由「明确空闲」驱动,且与 Auto 开关无关(与上面的记账清理同一条边界:
|
|
6013
|
+
* 它只维护本插件自己的标记,绝不触碰宿主)。先清再选候选:清掉之后这一轮的开候选判据读到的
|
|
6014
|
+
* 就是最新状态;空闲地址本来就不在 running 集合里,因此这一步不会为本次运行重新打开任何 tab。
|
|
6015
|
+
*/
|
|
6016
|
+
clearManualSuppressionOfIdleResources(idleSidebarResourceAddresses(sidebarIdleChildren()));
|
|
5163
6017
|
let candidate = null;
|
|
5164
6018
|
if (enabled === true && sidebarAutoBusyRef.current !== true) {
|
|
5165
6019
|
candidate = autoCloseCandidate(running) ?? autoOpenCandidate(running);
|
|
@@ -5290,6 +6144,7 @@
|
|
|
5290
6144
|
if (recordScoped) {
|
|
5291
6145
|
// 用户手动打开 = 他重新要这个会话:清除抑制,并把来源收回 manual(Auto 不得再自动关它)。
|
|
5292
6146
|
record.origin = SIDEBAR_TAB_ORIGIN_MANUAL;
|
|
6147
|
+
writeSidebarAutoOwnership(sidebarSessionId, rootSessionId, entry.address, SIDEBAR_AUTO_OWNERSHIP_ORIGIN_MANUAL, "manual-open");
|
|
5293
6148
|
sidebarAutoSuppressedRef.current.delete(key);
|
|
5294
6149
|
sidebarAutoOriginRef.current.delete(key);
|
|
5295
6150
|
// 手动接管这个 key:未结算的自动关闭预算(含 owner)一并作废,Auto 不得再按旧预算重试。
|
|
@@ -5304,6 +6159,8 @@
|
|
|
5304
6159
|
// intent === "close"
|
|
5305
6160
|
if (recordScoped) {
|
|
5306
6161
|
sidebarManualIntentRef.current.delete(key);
|
|
6162
|
+
// 手动关闭信号在这里就落持久标记(抑制写入要等验证收口,太晚;手动恒优先必须立刻生效)。
|
|
6163
|
+
writeSidebarAutoOwnership(sidebarSessionId, rootSessionId, entry.address, SIDEBAR_AUTO_OWNERSHIP_ORIGIN_MANUAL, "manual-close");
|
|
5307
6164
|
void handleSidebarAction(key, entry.address, SIDEBAR_TAB_ORIGIN_MANUAL);
|
|
5308
6165
|
return;
|
|
5309
6166
|
}
|
|
@@ -5314,12 +6171,14 @@
|
|
|
5314
6171
|
*/
|
|
5315
6172
|
sidebarAutoSuppressedRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, address: entry.address });
|
|
5316
6173
|
trimAutoKeyMap(sidebarAutoSuppressedRef.current);
|
|
6174
|
+
writeSidebarAutoOwnership(sidebarSessionId, rootSessionId, entry.address, SIDEBAR_AUTO_OWNERSHIP_ORIGIN_MANUAL, "manual-close");
|
|
5317
6175
|
return;
|
|
5318
6176
|
}
|
|
5319
6177
|
// 没有可关闭的 tab:只登记抑制与撤销自动记账,绝不为了「执行关闭」先打开一个 tab。
|
|
5320
6178
|
sidebarManualIntentRef.current.delete(key);
|
|
5321
6179
|
sidebarAutoSuppressedRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, address: entry.address });
|
|
5322
6180
|
trimAutoKeyMap(sidebarAutoSuppressedRef.current);
|
|
6181
|
+
writeSidebarAutoOwnership(sidebarSessionId, rootSessionId, entry.address, SIDEBAR_AUTO_OWNERSHIP_ORIGIN_MANUAL, "manual-close");
|
|
5323
6182
|
sidebarAutoOriginRef.current.delete(key);
|
|
5324
6183
|
// 手动关闭意图接管这个 key:未结算的自动关闭预算(含 owner)一并作废。
|
|
5325
6184
|
clearAutoCloseAttempt(key);
|
|
@@ -5371,11 +6230,22 @@
|
|
|
5371
6230
|
* - `contentId === 资源地址`:同一个 kind 下也可能是另一个子代理的地址。
|
|
5372
6231
|
* 不满足时保持 >> 的打开语义(按钮照旧可再次点击打开),绝不猜测 tabId。
|
|
5373
6232
|
*
|
|
5374
|
-
* origin
|
|
5375
|
-
*
|
|
5376
|
-
*
|
|
6233
|
+
* origin 的来源优先级(缺陷 1 之后的定稿判据,**每一层都必须自带证据**):
|
|
6234
|
+
* 手动意图 / 持久 manual > 显式传入的动作来源(**且必须带因果证明**)> 已存在记录(**且 tabId
|
|
6235
|
+
* 必须相等**)> 在途打开记录(**且因果证明成立**)> realm 共享来源账本(tabId 逐字相等)>
|
|
6236
|
+
* 持久 auto(tabId 逐字相等)> **manual** 兜底。最后那条兜底正是规格要求:「外部已存在但未由
|
|
6237
|
+
* 自动动作发起的 tab 视为手动来源」,因此自动同步永远关不掉一个它没有亲手打开过的 tab。
|
|
6238
|
+
*
|
|
6239
|
+
* 为什么「本次动作声明的来源」还不够:宿主 openResource() 的公开契约里**没有返回值**(返回
|
|
6240
|
+
* void,见 lib/types/client/service.d.ts),而且默认 revealIfOpened=true —— 同一个
|
|
6241
|
+
* (kind, contentId) 已经有 tab 时它**揭示那一个**,绝不新建。所以「调用过 openResource」+
|
|
6242
|
+
* 「随后 inventory 里有一个同地址 tab」并不能证明那个 tab 是我们创建出来的:它完全可能是用户
|
|
6243
|
+
* 在等待窗口里从 DSH 其它入口打开的同一个资源。认领它就等于事后替用户关掉他的 tab。
|
|
6244
|
+
*
|
|
6245
|
+
* 因此 actionProof.autoProven 只由「发出 openResource **之前**亲眼确认 inventory 可读、且该
|
|
6246
|
+
* 地址没有已提交 tab」写成(见 handleSidebarAction 的因果证明点)。缺证明一律 manual。
|
|
5377
6247
|
*/
|
|
5378
|
-
const recordSidebarTab = (key, resourceAddress, actionOrigin) => {
|
|
6248
|
+
const recordSidebarTab = (key, resourceAddress, actionOrigin, actionProof) => {
|
|
5379
6249
|
if (closeSidebarResource === null) return false;
|
|
5380
6250
|
if (readActiveSidebarResource === null && readSidebarOpenTabs() === null) return false;
|
|
5381
6251
|
// 左箭头图标缺失时不会渲染 <<,因此也不该建立只在 << 上才有意义的记录。
|
|
@@ -5383,6 +6253,13 @@
|
|
|
5383
6253
|
const declaredOrigin = actionOrigin === SIDEBAR_TAB_ORIGIN_AUTO || actionOrigin === SIDEBAR_TAB_ORIGIN_MANUAL
|
|
5384
6254
|
? actionOrigin
|
|
5385
6255
|
: null;
|
|
6256
|
+
/**
|
|
6257
|
+
* 本次动作是否带**因果证明**:只有 handleSidebarAction 的自动路径会传它,而且只在
|
|
6258
|
+
* 「发出 openResource 之前 inventory 可读且该地址确实没有 tab」时为 true。
|
|
6259
|
+
* 手动路径带 null(不需要证明:手动动作恒按 manual 记账)。
|
|
6260
|
+
*/
|
|
6261
|
+
const declaredAutoProven = actionProof !== null && actionProof !== undefined
|
|
6262
|
+
&& actionProof.autoProven === true;
|
|
5386
6263
|
let tab = null;
|
|
5387
6264
|
const inventory = readSidebarOpenTabs();
|
|
5388
6265
|
if (inventory !== null) {
|
|
@@ -5415,6 +6292,24 @@
|
|
|
5415
6292
|
if (text(source.contentId, null) !== resourceAddress) return false;
|
|
5416
6293
|
const previous = sidebarTabRecordsRef.current.get(key);
|
|
5417
6294
|
const opening = sidebarTabOpeningRef.current.get(key);
|
|
6295
|
+
const previousScoped = previous !== undefined
|
|
6296
|
+
&& previous.sessionId === sidebarSessionId && previous.rootSessionId === rootSessionId
|
|
6297
|
+
? previous
|
|
6298
|
+
: undefined;
|
|
6299
|
+
const openingScoped = opening !== undefined
|
|
6300
|
+
&& opening.sessionId === sidebarSessionId && opening.rootSessionId === rootSessionId
|
|
6301
|
+
? opening
|
|
6302
|
+
: undefined;
|
|
6303
|
+
/**
|
|
6304
|
+
* 别的写入者证据(缺陷 1 的第二个判据):这次打开**还没有**把 openResource 发给宿主
|
|
6305
|
+
* (causalIssuedAt 还是 null),我们却已经能读到这个地址上的 tab —— 那只可能是别人先打开的
|
|
6306
|
+
* (用户从 DSH 其它入口、或另一个 surface)。这次动作从此不再是这个 tab 的因果来源,落地
|
|
6307
|
+
* 时一律按 manual 记账,并把它写进在途记录,后续任何一次落地观察都不会再翻案。
|
|
6308
|
+
*/
|
|
6309
|
+
if (openingScoped !== undefined
|
|
6310
|
+
&& (openingScoped.causalIssuedAt === null || openingScoped.causalIssuedAt === undefined)) {
|
|
6311
|
+
openingScoped.foreignWriter = true;
|
|
6312
|
+
}
|
|
5418
6313
|
/**
|
|
5419
6314
|
* 手动接管优先于调用方声明的来源:用户在那个 key 上留下过「要打开」的手动意图时,即使这次
|
|
5420
6315
|
* 打开是 Auto 发出的,最终 tab 也必须记成 manual——手动打开的最终来源必须是 manual,
|
|
@@ -5428,26 +6323,180 @@
|
|
|
5428
6323
|
* 实例内的 previous 与 opening 也都是空的;唯一能证明「这个 tab 是 Auto 亲手打开的」的
|
|
5429
6324
|
* 就是这条跨实例账本。缺了它,收养只能落到 manual 兜底,Auto 从此不再管这个 tab。
|
|
5430
6325
|
*
|
|
5431
|
-
* 只认显式记成 auto 的账本条目,并且必须同 session + 同 root
|
|
5432
|
-
*
|
|
6326
|
+
* 只认显式记成 auto 的账本条目,并且必须同 session + 同 root、且绑定的 tabId 与当前证据
|
|
6327
|
+
* 逐字相等(见下面的绑定闸门):别的 scope 的条目一律不可见,来源未知的外部 tab、其它 pane
|
|
6328
|
+
* 的同地址 tab,以及「同地址但已经换了 tabId」的 tab 都不会被误收养成 Auto 的 tab。
|
|
5433
6329
|
*/
|
|
5434
6330
|
const sharedOrigin = sidebarAutoOriginRef.current.get(key);
|
|
5435
6331
|
const sharedOriginScoped = sharedOrigin !== undefined
|
|
5436
6332
|
&& sharedOrigin.sessionId === sidebarSessionId && sharedOrigin.rootSessionId === rootSessionId
|
|
5437
6333
|
? sharedOrigin
|
|
5438
6334
|
: undefined;
|
|
5439
|
-
|
|
6335
|
+
/**
|
|
6336
|
+
* tabId 绑定闸门(缺陷 1,与持久条目那条闸门逐字同源,只是证据来自另一层):
|
|
6337
|
+
*
|
|
6338
|
+
* 共享账本的条目里记着「Auto 打开/替换落地那一刻亲眼看到的 tab」。只要求「有条目」是不够的:
|
|
6339
|
+
* 旧 tab 被用户或宿主移除而插件没有观察到时,同一个 realm 里的下一次收养(重挂载 / 记录被判
|
|
6340
|
+
* stale 后的重建)会在同地址上看到**用户新开的 tab**——它的 tabId 与条目里那个不一样。不比对
|
|
6341
|
+
* tabId 就按 auto 收养,等于替用户认领他刚打开的 tab,资源随后的 idle 就会把它自动关掉。
|
|
6342
|
+
*
|
|
6343
|
+
* 条目没有 tabId(null:这次自动打开已经发出、落地证据还没到)同样**不是** auto 证据:它只
|
|
6344
|
+
* 回答「这次运行是否已经为它发过打开」,任何归属判定都必须按「证据不足」处理。
|
|
6345
|
+
*/
|
|
6346
|
+
const sharedOriginTabId = sidebarAutoOriginTabIdOf(sharedOriginScoped);
|
|
6347
|
+
const sharedOriginBound = sharedOriginTabId !== null && sharedOriginTabId === tabId;
|
|
6348
|
+
const sharedOriginStale = sharedOriginScoped !== undefined && sharedOriginBound !== true;
|
|
6349
|
+
/**
|
|
6350
|
+
* 持久归属标记是「整页刷新 / 进程重启 / HMR 之后」唯一还活着的证据(见 SIDEBAR_AUTO_OWNERSHIP_KEY):
|
|
6351
|
+
* 命中 manual 一律 manual(手动恒优先,安全侧);命中 auto 还要过下面那条 tabId 绑定闸门才按
|
|
6352
|
+
* auto 收养;没有标记按既有 manual 兜底。内存(含 realm 共享账本)与持久层不一致时以 manual
|
|
6353
|
+
* 为准,并在下面把内存来源一并抹掉,绝不留「内存 auto / 持久 manual」的歧义。
|
|
6354
|
+
*/
|
|
6355
|
+
const persistedEntry = readSidebarAutoOwnershipEntry(sidebarSessionId, rootSessionId, resourceAddress);
|
|
6356
|
+
const persistedManual = persistedEntry !== null && persistedEntry.origin === SIDEBAR_AUTO_OWNERSHIP_ORIGIN_MANUAL;
|
|
6357
|
+
/**
|
|
6358
|
+
* tabId 绑定闸门(P1-A):持久 auto 条目只认「写入时那个 tab」。
|
|
6359
|
+
*
|
|
6360
|
+
* 条目里的 tabId 是记录这条 auto 归属时宿主 inventory 里这个地址的 tab。整页刷新之后持久条目
|
|
6361
|
+
* 是唯一还活着的证据,而右栏里同地址的 tab 完全可能是**另一次 tab 创建**出来的(旧 tab 被用户
|
|
6362
|
+
* 或宿主移除而插件没观察到、用户随后从 DSH 其它入口打开同一地址)。不比对 tabId 就按 auto 收养,
|
|
6363
|
+
* 等于替用户认领他刚打开的 tab——资源随后 idle,Auto 就会把它关掉。
|
|
6364
|
+
*
|
|
6365
|
+
* 判定顺序(三档,manual 恒优先):
|
|
6366
|
+
* 1. manual:手动意图(manualIntentOpen)/ 持久 manual 一律 manual,安全侧;
|
|
6367
|
+
* 2. 陈旧 auto:持久条目的 tabId 与当前证据不符(或旧格式条目根本没记 tabId)⇒ 它**不再**
|
|
6368
|
+
* 作为 auto 证据参与收养。若内存里也没有别的来源证据,最终落到下面的 manual 兜底,并在统一
|
|
6369
|
+
* 写入点把这条陈旧条目改写成 manual(理由 manual-stale-auto),避免它长期误导后续判断;
|
|
6370
|
+
* 3. auto:只有「内存来源证据」或「tabId 逐字相等的持久 auto」才认 auto。
|
|
6371
|
+
*
|
|
6372
|
+
* 为什么内存证据(本次动作声明的来源 / 已有记录 / 在途打开)仍然优先:它们是**本 realm 亲眼
|
|
6373
|
+
* 观察到的** Auto 动作;但「亲眼观察」必须落实到**证据**上,不能只凭「有一个 auto 来源」:
|
|
6374
|
+
* - 本次动作声明的来源(declaredOrigin):必须带因果证明(actionProof.autoProven,见上面的
|
|
6375
|
+
* 说明)——宿主 openResource 没有返回值、而且默认是「已存在就揭示」,光调用过它不足以
|
|
6376
|
+
* 证明随后出现的那个 tab 是我们创建的;
|
|
6377
|
+
* - 已有记录(previous.origin):必须 previous.tabId === 当前落地 tabId —— 记录说的是
|
|
6378
|
+
* 「T 这个 tab 归 Auto」,宿主换了一个新 id 之后这条记录已经不指向任何还在我们手里的 tab;
|
|
6379
|
+
* - 在途打开(opening):必须 causalProof === "proven"(发出之前亲眼确认地址上没有 tab)、
|
|
6380
|
+
* causalIssuedAt 非空(openResource 真的发出去过)、且 foreignWriter 未置位(期间没有
|
|
6381
|
+
* 观察到别的写入者)。
|
|
6382
|
+
* 而 realm 共享来源账本与持久条目各要多过一道 tabId 逐字相等的绑定闸门。任何一层缺证据都
|
|
6383
|
+
* 按安全侧落 manual:宁可漏收一次,也绝不误关用户刚打开的 tab。
|
|
6384
|
+
*
|
|
6385
|
+
* 取舍(刻意):本机页面刷新时宿主从它自己的持久化布局里带回**同一批 tabId**,因此正常刷新场景
|
|
6386
|
+
* 仍然恢复 auto;只有宿主真的重新 mint 了 id 时才退化成 manual——宁可漏收一次(那个 tab 留在
|
|
6387
|
+
* 右栏里交给用户手动处理,绝不会自己消失),也绝不误关一个可能是用户刚打开的 tab。
|
|
6388
|
+
*/
|
|
6389
|
+
const persistedAutoBound = persistedEntry !== null
|
|
6390
|
+
&& persistedEntry.origin === SIDEBAR_AUTO_OWNERSHIP_ORIGIN_AUTO
|
|
6391
|
+
&& persistedEntry.tabId !== null
|
|
6392
|
+
&& persistedEntry.tabId === tabId;
|
|
6393
|
+
const persistedStaleAuto = persistedEntry !== null
|
|
6394
|
+
&& persistedEntry.origin === SIDEBAR_AUTO_OWNERSHIP_ORIGIN_AUTO
|
|
6395
|
+
&& persistedAutoBound !== true;
|
|
6396
|
+
/**
|
|
6397
|
+
* 内存两层的证据闸门(缺陷 1):
|
|
6398
|
+
* - previous:记录必须**绑定当前这个 tabId**,否则那条 auto 记录描述的是另一个已经不存在的
|
|
6399
|
+
* tab(宿主重 mint / 回收),继承它就等于替用户认领他刚打开的 tab;
|
|
6400
|
+
* - opening:因果证明成立、openResource 真的发出过、期间没有别的写入者(见 causalProof /
|
|
6401
|
+
* causalIssuedAt / foreignWriter 的说明)。
|
|
6402
|
+
*/
|
|
6403
|
+
const previousUsableAuto = previousScoped !== undefined
|
|
6404
|
+
&& previousScoped.origin === SIDEBAR_TAB_ORIGIN_AUTO
|
|
6405
|
+
&& previousScoped.tabId === tabId;
|
|
6406
|
+
const openingUsableAuto = openingScoped !== undefined
|
|
6407
|
+
&& openingScoped.origin === SIDEBAR_TAB_ORIGIN_AUTO
|
|
6408
|
+
&& openingScoped.causalProof === "proven"
|
|
6409
|
+
&& openingScoped.causalIssuedAt !== null && openingScoped.causalIssuedAt !== undefined
|
|
6410
|
+
&& openingScoped.foreignWriter !== true;
|
|
6411
|
+
/**
|
|
6412
|
+
* 声明的来源也要过证据闸门:声明 auto 但缺因果证明时**直接落 manual**,绝不继续借用更弱的
|
|
6413
|
+
* 层(那正是缺陷 1 的误关形状:这次落地可能压根不是我们的动作打出来的那个 tab)。
|
|
6414
|
+
*/
|
|
6415
|
+
const declaredUsable = declaredOrigin === SIDEBAR_TAB_ORIGIN_AUTO
|
|
6416
|
+
? (declaredAutoProven === true ? SIDEBAR_TAB_ORIGIN_AUTO : SIDEBAR_TAB_ORIGIN_MANUAL)
|
|
6417
|
+
: declaredOrigin;
|
|
6418
|
+
const manualIntentOpen = manualTakeover !== undefined && manualTakeover.intent === "open";
|
|
6419
|
+
const origin = manualIntentOpen || persistedManual
|
|
5440
6420
|
? SIDEBAR_TAB_ORIGIN_MANUAL
|
|
5441
|
-
: (
|
|
5442
|
-
?? (
|
|
5443
|
-
?
|
|
5444
|
-
:
|
|
5445
|
-
|
|
5446
|
-
|
|
5447
|
-
|
|
5448
|
-
|
|
5449
|
-
?? SIDEBAR_TAB_ORIGIN_MANUAL);
|
|
6421
|
+
: (declaredUsable
|
|
6422
|
+
?? (previousUsableAuto === true
|
|
6423
|
+
? SIDEBAR_TAB_ORIGIN_AUTO
|
|
6424
|
+
: (openingUsableAuto === true
|
|
6425
|
+
? SIDEBAR_TAB_ORIGIN_AUTO
|
|
6426
|
+
: (sharedOriginBound === true
|
|
6427
|
+
? SIDEBAR_TAB_ORIGIN_AUTO
|
|
6428
|
+
: (persistedAutoBound === true ? SIDEBAR_TAB_ORIGIN_AUTO : SIDEBAR_TAB_ORIGIN_MANUAL)))));
|
|
5450
6429
|
const recordOrigin = origin === SIDEBAR_TAB_ORIGIN_AUTO ? SIDEBAR_TAB_ORIGIN_AUTO : SIDEBAR_TAB_ORIGIN_MANUAL;
|
|
6430
|
+
/**
|
|
6431
|
+
* 证据存在但 tabId 对不上(或根本没有 tabId)时,那条共享账本条目已经被这一轮判定为**不是**
|
|
6432
|
+
* 本次归属的证据,必须就地清掉:它会一直躺在 realm 账本里,在下一次收养里继续参与判断
|
|
6433
|
+
* (长期误导),也会让「本次运行已经为它发过打开」这条去重记账与真实状态脱节。
|
|
6434
|
+
*
|
|
6435
|
+
* 清掉不会造成重复打开:这个地址此刻确实有 tab(收养的前提就是 inventory 里读到了它),
|
|
6436
|
+
* autoOpenCandidate 的「inventory 里已经有这个地址的 tab」判据会照常跳过。持久 manual 与
|
|
6437
|
+
* 内存 auto 的歧义同样按 manual 清理(既有语义)。
|
|
6438
|
+
*/
|
|
6439
|
+
if (sharedOriginStale === true
|
|
6440
|
+
|| (persistedManual === true && sharedOriginScoped !== undefined)) {
|
|
6441
|
+
sidebarAutoOriginRef.current.delete(key);
|
|
6442
|
+
}
|
|
6443
|
+
/**
|
|
6444
|
+
* 归属成立(auto)时,把**当前亲眼观察到的 tabId** 写进共享来源账本:关闭候选的遍历与
|
|
6445
|
+
* 「绝不重复打开」的记账都长在这份账本上,两者本来就是同一份归属证据;缺了它,收养出来的
|
|
6446
|
+
* record 虽然是 auto,Auto 却永远不会再为它选出关闭候选(仍然漏收)。
|
|
6447
|
+
*
|
|
6448
|
+
* 这里也是**唯一**允许刷新 tabId 的位置之一(另一个是 handleSidebarAction 里 openResource
|
|
6449
|
+
* 成功落地的那次调用):tabId 一律来自本次从宿主 inventory / active 读到的证据,绝不缓存、
|
|
6450
|
+
* 绝不猜测。刻意不做任何限容淘汰——归属容量由 autoOpenCandidate 在发出 openResource 之前
|
|
6451
|
+
* fail-closed 判定(见 autoSidebarOwnershipHasCapacity),收养路径绝不允许挤掉别的 scope
|
|
6452
|
+
* 仍然有效的 owner。
|
|
6453
|
+
*/
|
|
6454
|
+
if (recordOrigin === SIDEBAR_TAB_ORIGIN_AUTO) {
|
|
6455
|
+
sidebarAutoOriginRef.current.set(
|
|
6456
|
+
key,
|
|
6457
|
+
sidebarAutoOriginEntry(sidebarSessionId, rootSessionId, resourceAddress, tabId),
|
|
6458
|
+
);
|
|
6459
|
+
}
|
|
6460
|
+
/**
|
|
6461
|
+
* 归属标记与内存账本在**同一处**收口(写入 / 删除同源):
|
|
6462
|
+
* - auto:DEG 自己成功打开(openIssued 成功落地)时写 auto-open;收养证据证明是 Auto 时写
|
|
6463
|
+
* auto-adopt。同一地址此前若是 manual,必须由这次自动打开改回 auto(这是插件新开的一次自动行为);
|
|
6464
|
+
* - manual:手动打开 / 手动接管 / 来源未知的外部 tab 一律写 manual(手动恒优先)。
|
|
6465
|
+
*/
|
|
6466
|
+
writeSidebarAutoOwnership(
|
|
6467
|
+
sidebarSessionId,
|
|
6468
|
+
rootSessionId,
|
|
6469
|
+
resourceAddress,
|
|
6470
|
+
recordOrigin === SIDEBAR_TAB_ORIGIN_AUTO ? SIDEBAR_AUTO_OWNERSHIP_ORIGIN_AUTO : SIDEBAR_AUTO_OWNERSHIP_ORIGIN_MANUAL,
|
|
6471
|
+
recordOrigin === SIDEBAR_TAB_ORIGIN_AUTO
|
|
6472
|
+
? (declaredOrigin === SIDEBAR_TAB_ORIGIN_AUTO ? "auto-open" : "auto-adopt")
|
|
6473
|
+
/**
|
|
6474
|
+
* 陈旧 auto 被降级成 manual 时用独立理由:诊断里据此能区分「用户手动接管」
|
|
6475
|
+
* (manual-takeover)与「某条 auto 证据的 tabId 与当前 tab 不符,按安全侧退让」两种完全
|
|
6476
|
+
* 不同的原因,事后排查「这个 tab 为什么没被自动收回」时才有的放矢。
|
|
6477
|
+
*
|
|
6478
|
+
* - manual-stale-auto:持久条目的 tabId 与当前 tab 不符(跨整页刷新的那条证据);
|
|
6479
|
+
* - manual-stale-origin:realm 共享来源账本的 tabId 与当前 tab 不符或缺席(同一次页面
|
|
6480
|
+
* 会话内、宿主重 mint 了 tabId 而插件没有观察到那次替换);
|
|
6481
|
+
* - manual-unproven-open:本次自动打开**没有**因果证明(发出前没能确认地址上没有 tab),
|
|
6482
|
+
* 或因果证明成立却在途期间观察到了别的写入者——落地到的这个 tab 无法与「用户自己打开的
|
|
6483
|
+
* 同一个资源」区分,按安全侧落 manual(缺陷 1)。
|
|
6484
|
+
*/
|
|
6485
|
+
: (manualIntentOpen === true
|
|
6486
|
+
? "manual-open"
|
|
6487
|
+
: (persistedStaleAuto === true
|
|
6488
|
+
? "manual-stale-auto"
|
|
6489
|
+
: (sharedOriginStale === true
|
|
6490
|
+
? "manual-stale-origin"
|
|
6491
|
+
: (declaredOrigin === SIDEBAR_TAB_ORIGIN_AUTO && declaredAutoProven !== true
|
|
6492
|
+
? "manual-unproven-open"
|
|
6493
|
+
: (openingScoped !== undefined && openingScoped.origin === SIDEBAR_TAB_ORIGIN_AUTO
|
|
6494
|
+
&& openingUsableAuto !== true && previousUsableAuto !== true
|
|
6495
|
+
? "manual-unproven-open"
|
|
6496
|
+
: "manual-takeover"))))),
|
|
6497
|
+
// 条目与内存账本同源写入当前的 tabId:下一次刷新后的收养按它比对(见上面的绑定闸门)。
|
|
6498
|
+
tabId,
|
|
6499
|
+
);
|
|
5451
6500
|
if (previous !== undefined && previous.sessionId === sidebarSessionId
|
|
5452
6501
|
&& previous.rootSessionId === rootSessionId && previous.tabId === tabId) {
|
|
5453
6502
|
previous.title = tabTitle ?? previous.title;
|
|
@@ -5478,6 +6527,21 @@
|
|
|
5478
6527
|
*/
|
|
5479
6528
|
const handleSidebarAction = async (key, resourceAddress, actionOrigin, autoAction) => {
|
|
5480
6529
|
const origin = actionOrigin === SIDEBAR_TAB_ORIGIN_AUTO ? SIDEBAR_TAB_ORIGIN_AUTO : SIDEBAR_TAB_ORIGIN_MANUAL;
|
|
6530
|
+
/**
|
|
6531
|
+
* 手动来源的动作在入口就把归属标记改写成 manual(手动恒优先,且必须在任何 await 之前落盘):
|
|
6532
|
+
* 有记录 ⇒ 用户在关闭这个 tab(manual-close);没有记录 ⇒ 用户在打开它(manual-open)。
|
|
6533
|
+
* 这条写入同时抹掉内存里的共享来源,绝不留「内存 auto / 持久 manual」的歧义。
|
|
6534
|
+
*/
|
|
6535
|
+
if (origin === SIDEBAR_TAB_ORIGIN_MANUAL) {
|
|
6536
|
+
const manualRecord = sidebarTabRecordsRef.current.get(key);
|
|
6537
|
+
writeSidebarAutoOwnership(
|
|
6538
|
+
sidebarSessionId,
|
|
6539
|
+
rootSessionId,
|
|
6540
|
+
resourceAddress,
|
|
6541
|
+
SIDEBAR_AUTO_OWNERSHIP_ORIGIN_MANUAL,
|
|
6542
|
+
manualRecord === undefined ? "manual-open" : "manual-close",
|
|
6543
|
+
);
|
|
6544
|
+
}
|
|
5481
6545
|
const autoContext = origin === SIDEBAR_TAB_ORIGIN_AUTO && autoAction !== null && typeof autoAction === "object"
|
|
5482
6546
|
? {
|
|
5483
6547
|
kind: autoAction.kind === "close" ? "close" : "open",
|
|
@@ -5723,6 +6787,7 @@
|
|
|
5723
6787
|
if (origin === SIDEBAR_TAB_ORIGIN_MANUAL) {
|
|
5724
6788
|
sidebarAutoSuppressedRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, address: resourceAddress });
|
|
5725
6789
|
trimAutoKeyMap(sidebarAutoSuppressedRef.current);
|
|
6790
|
+
writeSidebarAutoOwnership(sidebarSessionId, rootSessionId, resourceAddress, SIDEBAR_AUTO_OWNERSHIP_ORIGIN_MANUAL, "manual-close");
|
|
5726
6791
|
}
|
|
5727
6792
|
}
|
|
5728
6793
|
|
|
@@ -5745,6 +6810,17 @@
|
|
|
5745
6810
|
if (autoContext === null) return "proceed";
|
|
5746
6811
|
if (autoContext === null) return "proceed";
|
|
5747
6812
|
if (autoActionStillValid(autoContext) !== true) return "abort";
|
|
6813
|
+
/**
|
|
6814
|
+
* 这里刻意**不**再加一道「持久 manual ⇒ abort」的闸门(持久抑制的判据只落在候选选择处,
|
|
6815
|
+
* 见 autoOpenCandidate)。原因:这条路是「候选已经选中、正在等宿主布局」的在途窗口,窗口里
|
|
6816
|
+
* 出现的 manual 条目可能来自用户**手动打开同一个资源**(handleSidebarAction 的入口写入),
|
|
6817
|
+
* 那种形状的既有契约是「这一次打开照常发出,但最终 tab 记成 manual」(见 autoTakeoverStateOf
|
|
6818
|
+
* 的 "open" 分支与「等待布局期间手动打开」用例);在这里 abort 会连同用户的手动意图一起
|
|
6819
|
+
* 丢掉(那次点击不会补发 openResource)。
|
|
6820
|
+
*
|
|
6821
|
+
* 窗口里的「手动关闭」不依赖持久条目:它由 applyPendingManualIntent 立刻写抑制,并且
|
|
6822
|
+
* autoTakeoverStateOf 返回 "close" ⇒ 下面直接 abort,行为逐字不变。
|
|
6823
|
+
*/
|
|
5748
6824
|
const takeover = autoTakeoverStateOf(key);
|
|
5749
6825
|
if (takeover === "close") return "abort";
|
|
5750
6826
|
if (takeover === "open") return "manual";
|
|
@@ -5764,6 +6840,20 @@
|
|
|
5764
6840
|
resourceAddress,
|
|
5765
6841
|
expiresAt: Date.now() + SIDEBAR_TAB_OPEN_CONFIRMATION_TIMEOUT_MS,
|
|
5766
6842
|
origin,
|
|
6843
|
+
/**
|
|
6844
|
+
* 因果证明状态(缺陷 1):这一次打开到底凭什么说「随后落地的那个 tab 是我们创建的」。
|
|
6845
|
+
*
|
|
6846
|
+
* - causalProof:"pending"(还没判)→ "proven"(发出 openResource 之前亲眼确认
|
|
6847
|
+
* inventory 可读且该地址没有已提交 tab)/ "failed"(有 tab 或读不到,一律不认领);
|
|
6848
|
+
* - causalIssuedAt:openResource 真正发给宿主的时刻(null = 还没发出)。在此之前读到
|
|
6849
|
+
* 同地址 tab 只可能是**别的写入者**;
|
|
6850
|
+
* - foreignWriter:期间观察到别的写入者的证据(见 recordSidebarTab 的置位点)。
|
|
6851
|
+
*
|
|
6852
|
+
* 三个字段缺一不可:任何一层不成立都不允许把落地证据认成 auto(宁可漏收一次)。
|
|
6853
|
+
*/
|
|
6854
|
+
causalProof: "pending",
|
|
6855
|
+
causalIssuedAt: null,
|
|
6856
|
+
foreignWriter: false,
|
|
5767
6857
|
});
|
|
5768
6858
|
}
|
|
5769
6859
|
/**
|
|
@@ -5845,6 +6935,7 @@
|
|
|
5845
6935
|
// 用户要的是关闭:立刻登记抑制,Auto 不得在下一轮同步里把它重新选成候选。
|
|
5846
6936
|
sidebarAutoSuppressedRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, address: resourceAddress });
|
|
5847
6937
|
trimAutoKeyMap(sidebarAutoSuppressedRef.current);
|
|
6938
|
+
writeSidebarAutoOwnership(sidebarSessionId, rootSessionId, resourceAddress, SIDEBAR_AUTO_OWNERSHIP_ORIGIN_MANUAL, "manual-close");
|
|
5848
6939
|
}
|
|
5849
6940
|
return;
|
|
5850
6941
|
}
|
|
@@ -5892,20 +6983,118 @@
|
|
|
5892
6983
|
// 用户要的是关闭:立刻登记抑制,绝不在同一轮或下一轮同步里把这个资源重新打开。
|
|
5893
6984
|
sidebarAutoSuppressedRef.current.set(key, { sessionId: sidebarSessionId, rootSessionId, address: resourceAddress });
|
|
5894
6985
|
trimAutoKeyMap(sidebarAutoSuppressedRef.current);
|
|
6986
|
+
writeSidebarAutoOwnership(sidebarSessionId, rootSessionId, resourceAddress, SIDEBAR_AUTO_OWNERSHIP_ORIGIN_MANUAL, "manual-close");
|
|
5895
6987
|
}
|
|
5896
6988
|
return;
|
|
5897
6989
|
}
|
|
5898
6990
|
if (beforeOpenReview === "manual") effectiveOrigin = SIDEBAR_TAB_ORIGIN_MANUAL;
|
|
5899
|
-
|
|
6991
|
+
/**
|
|
6992
|
+
* 副作用之前的**因果证明**(缺陷 1,与复核点在同一个同步段里,中间没有任何 await)。
|
|
6993
|
+
*
|
|
6994
|
+
* 宿主 openResource() 的公开契约里没有返回值(void),默认 revealIfOpened=true —— 同一个
|
|
6995
|
+
* (kind, contentId) 已经有 tab 时它只是**揭示**那一个。因此「我们调用过 openResource」+
|
|
6996
|
+
* 「随后 inventory 里出现同地址 tab」并不构成因果:那个 tab 完全可能是用户刚刚从 DSH 其它
|
|
6997
|
+
* 入口打开的同一个资源。这里在发动作之前亲眼核对一次:
|
|
6998
|
+
* - inventory 可读、且该地址没有已提交 tab ⇒ 之后出现的那个 tab 只可能是本次调用打出来的
|
|
6999
|
+
* (同一帧内没有别的写入者能插进来),记为 proven;
|
|
7000
|
+
* - 已经有 tab(用户在等待窗口里打开了它)、或 inventory 读不出来(证明不了)⇒ 一律降级
|
|
7001
|
+
* manual:这次调用最多只是揭示别人的 tab,绝不认领、绝不在资源结束后把它关掉。
|
|
7002
|
+
*
|
|
7003
|
+
* 取舍(刻意):宁可漏收一次(那个 tab 留在右栏交给用户手动处理),也绝不误关用户刚打开的
|
|
7004
|
+
* tab。手动路径(autoContext === null)不需要证明:它本来就记 manual。
|
|
7005
|
+
*/
|
|
7006
|
+
let openProof = null;
|
|
7007
|
+
if (autoContext !== null) {
|
|
7008
|
+
const openWitness = sidebarResourceTabStateOf(resourceAddress);
|
|
7009
|
+
const autoProven = openWitness.readable === true && openWitness.tabId === null;
|
|
7010
|
+
openProof = { autoProven };
|
|
7011
|
+
if (autoProven !== true) {
|
|
7012
|
+
/**
|
|
7013
|
+
* 刻意**不**在这里改 effectiveOrigin:留成 auto 才能让「声明过 auto 但缺因果证明」这个
|
|
7014
|
+
* 事实一路带到来源判据(recordSidebarTab 的 declaredUsable ⇒ manual,理由
|
|
7015
|
+
* manual-unproven-open,诊断里一眼能看出是**没证明出来**而不是用户手动接管)。
|
|
7016
|
+
* 占位写入与最终归属都由 openProof 把关。
|
|
7017
|
+
*/
|
|
7018
|
+
recordSidebarAutoDecision("open", { key, kind: "open" }, "downgrade", "causal-unproven");
|
|
7019
|
+
}
|
|
7020
|
+
const pendingOpening = sidebarTabOpeningRef.current.get(key);
|
|
7021
|
+
if (pendingOpening !== undefined
|
|
7022
|
+
&& pendingOpening.sessionId === sidebarSessionId && pendingOpening.rootSessionId === rootSessionId) {
|
|
7023
|
+
pendingOpening.causalProof = autoProven === true ? "proven" : "failed";
|
|
7024
|
+
}
|
|
7025
|
+
}
|
|
7026
|
+
/**
|
|
7027
|
+
* 「已发出」标记必须写在**调用之前**、与调用处在同一个同步段里(时序加固,可用性阻塞)。
|
|
7028
|
+
*
|
|
7029
|
+
* 宿主链路(已按实现核实,见 @deepseek-ai/dsh-client-ui-sidebar-right/lib/client.js 与
|
|
7030
|
+
* client-store 的 snapshot store):openResource → store.actions.openContent → zustand setState
|
|
7031
|
+
* 的同步 forEach → adopt() 里 store.subscribe(sync) 注册的回调 → inventory.publish() →
|
|
7032
|
+
* openTabs.set → 插件在挂载期注册的 inventory 订阅回调。整条链路上没有任何 await / 微任务,
|
|
7033
|
+
* 宿主因此会在 openResource() **返回之前**同步重入本插件一次(syncSidebarTabs →
|
|
7034
|
+
* syncSidebarTabRecords → 对在途 opening 调 recordSidebarTab)。
|
|
7035
|
+
*
|
|
7036
|
+
* 若标记晚于这次重入写入(旧实现),重入结算读到的 causalIssuedAt 还是 null,于是按
|
|
7037
|
+
* 「别的写入者」置位 foreignWriter 并把 tab 记成 manual、落盘持久 manual 归属;随后的声明式
|
|
7038
|
+
* Auto 记录又被持久 manual 压制,资源结束也收不回来(不会误关,但正常自动收回失效)。
|
|
7039
|
+
*
|
|
7040
|
+
* 三件套一条都不放宽:标记只让重入结算把这次 landing 认作**本次调用**打出来的 tab,是否可作
|
|
7041
|
+
* auto 证据仍由 recordSidebarTab 判:发出前 inventory 可读且该地址无 tab(causalProof ===
|
|
7042
|
+
* "proven")+ 标记非空 + 期间没有别的写入者(foreignWriter !== true)。调用**同步抛错**时立刻
|
|
7043
|
+
* 回滚标记(下面的 try/catch),绝不留下「已经发出」的假证据。
|
|
7044
|
+
*/
|
|
7045
|
+
const issuedOpening = autoContext === null ? undefined : sidebarTabOpeningRef.current.get(key);
|
|
7046
|
+
const issuedOpeningScoped = issuedOpening !== undefined
|
|
7047
|
+
&& issuedOpening.sessionId === sidebarSessionId && issuedOpening.rootSessionId === rootSessionId
|
|
7048
|
+
? issuedOpening
|
|
7049
|
+
: undefined;
|
|
7050
|
+
// 回滚用的原值:畸形值按 null(「还没发出」)处理,绝不把脏值留在记录里。
|
|
7051
|
+
const issuedAtBefore = issuedOpeningScoped === undefined
|
|
7052
|
+
|| issuedOpeningScoped.causalIssuedAt === null || issuedOpeningScoped.causalIssuedAt === undefined
|
|
7053
|
+
? null
|
|
7054
|
+
: issuedOpeningScoped.causalIssuedAt;
|
|
7055
|
+
if (issuedOpeningScoped !== undefined) issuedOpeningScoped.causalIssuedAt = Date.now();
|
|
7056
|
+
try {
|
|
7057
|
+
openSidebarResource.call(sidebarRight, resourceAddress, openOptions);
|
|
7058
|
+
} catch (error) {
|
|
7059
|
+
/**
|
|
7060
|
+
* 同步抛错:宿主没有完成这次打开,这次调用必须回到「从未发出」的状态——否则任何一条把它们
|
|
7061
|
+
* 联系起来的后续判据(重入结算 / 重试 / 收养)都可能把失败的动作当成已发出的证据。
|
|
7062
|
+
* 回滚后原样抛出,交给外层 catch 收口(撤销来源占位、清掉在途记录)。
|
|
7063
|
+
*/
|
|
7064
|
+
if (issuedOpeningScoped !== undefined) issuedOpeningScoped.causalIssuedAt = issuedAtBefore;
|
|
7065
|
+
throw error;
|
|
7066
|
+
}
|
|
5900
7067
|
openIssued = true;
|
|
5901
7068
|
/**
|
|
5902
7069
|
* 预留 → 来源的升级点:只有 openResource 真的成功发出、且宿主具备可追踪能力(能读到 tabId、
|
|
5903
7070
|
* 能精确关闭)时,才把这次打开记成「Auto 亲手打开」。openResource 抛错时直接走 catch,
|
|
5904
7071
|
* 来源标记绝不写;能力不足时也不写——将来无法精确回收的 tab 不归 Auto 管。
|
|
5905
7072
|
*/
|
|
5906
|
-
if (effectiveOrigin === SIDEBAR_TAB_ORIGIN_AUTO && canTrackOpen === true
|
|
5907
|
-
|
|
5908
|
-
|
|
7073
|
+
if (effectiveOrigin === SIDEBAR_TAB_ORIGIN_AUTO && canTrackOpen === true
|
|
7074
|
+
&& openProof !== null && openProof.autoProven === true) {
|
|
7075
|
+
/**
|
|
7076
|
+
* 此刻落地证据还没到(tabId 未知):这里只写下「本次运行已经为它发出过一次 Auto 打开」
|
|
7077
|
+
* 这条去重记账,tabId 一律留空——它不是归属证据(见 sidebarAutoOriginEntry)。真正的
|
|
7078
|
+
* tabId 只由 recordSidebarTab 在宿主 inventory 里**亲手读到那个 tab** 时写入 / 刷新。
|
|
7079
|
+
*
|
|
7080
|
+
* 刻意不做限容淘汰:归属容量由候选选择阶段在发出 openResource 之前 fail-closed 判定
|
|
7081
|
+
* (见 autoSidebarOwnershipHasCapacity),这里绝不允许挤掉别的 scope 仍然有效的 owner。
|
|
7082
|
+
*
|
|
7083
|
+
* 覆盖闸门(时序加固):这次打开可能在**调用内部**就已经被重入结算(宿主同步发布 inventory,
|
|
7084
|
+
* 见上面「已发出」标记的说明),结算会把**真实 tabId** 写进这条账本条目。那条条目比占位强,
|
|
7085
|
+
* 绝不能用 tabId=null 的占位把它覆盖回「落地证据还没到」:覆盖会让这条账本条目短暂失去
|
|
7086
|
+
* tabId 绑定(紧接着的声明式 recordSidebarTab 通常会按当时的证据重新写下绑定,但那条写入
|
|
7087
|
+
* 依赖「此刻还读得到 tabId」;读不到时这次丢失就一直留着),此后重挂载 / 重收养只能看到共享
|
|
7088
|
+
* 账本,绑定失配即退化 manual,Auto 再也收不回这个 tab——与本次加固要修的症状同类。
|
|
7089
|
+
* 只放行「没有 tabId」的写入(含条目缺失 / 畸形值),其余一律保留现场。
|
|
7090
|
+
*/
|
|
7091
|
+
const landedOriginEntry = sidebarAutoOriginRef.current.get(key);
|
|
7092
|
+
if (sidebarAutoOriginTabIdOf(landedOriginEntry) === null) {
|
|
7093
|
+
sidebarAutoOriginRef.current.set(
|
|
7094
|
+
key,
|
|
7095
|
+
sidebarAutoOriginEntry(sidebarSessionId, rootSessionId, resourceAddress, null),
|
|
7096
|
+
);
|
|
7097
|
+
}
|
|
5909
7098
|
}
|
|
5910
7099
|
if (splitToken !== null) {
|
|
5911
7100
|
let acknowledged = true;
|
|
@@ -5919,7 +7108,8 @@
|
|
|
5919
7108
|
}
|
|
5920
7109
|
}
|
|
5921
7110
|
// 记录必须发生在 openResource 之后:它读的是宿主提交后的 active tab,是打开的证据。
|
|
5922
|
-
|
|
7111
|
+
// openProof 把「发出之前地址上没有 tab」这条因果证据一路带进来源判据(见 recordSidebarTab)。
|
|
7112
|
+
const opened = recordSidebarTab(key, resourceAddress, effectiveOrigin, openProof);
|
|
5923
7113
|
// 只要这次打开的 tab 已经落定,就不能留下 opening 记录:它的语义是「这次打开还没有确定
|
|
5924
7114
|
// 证据」(split 事务仍在等待 pane 证据、或本来就没有事务时都一样)。残留的 opening 会让
|
|
5925
7115
|
// 用户收回这个 tab 之后的下一次同步把关闭记录重新建回来(<< 复活),并把之后的点击挡成
|
|
@@ -6029,6 +7219,8 @@
|
|
|
6029
7219
|
* absent / recycled / unreadable 一律跳过这一项,绝不按 active tab 或渲染时快照猜一个 id。
|
|
6030
7220
|
*/
|
|
6031
7221
|
if (sidebarTabPresenceInInventory(tabId, candidateAddress) !== "present") continue;
|
|
7222
|
+
// 批量关闭是显式手动语义:按候选地址把持久标记改写成 manual(手动恒优先)。
|
|
7223
|
+
writeSidebarAutoOwnership(sidebarSessionId, rootSessionId, candidateAddress, SIDEBAR_AUTO_OWNERSHIP_ORIGIN_MANUAL, "manual-close");
|
|
6032
7224
|
/**
|
|
6033
7225
|
* 与单 tab 关闭共用同一个执行入口(同一把 session + root + tabId 租约 + 结算信号):
|
|
6034
7226
|
* 这个 tab 上已经有一次关闭在途时(典型:用户刚点的单项箭头还停在宿主 Promise 里),
|
|
@@ -6277,13 +7469,15 @@
|
|
|
6277
7469
|
* 的 childId),缺失时只用快照证据。运行状态本身必须继续来自目录 activity 与 DEG 元数据,
|
|
6278
7470
|
* 目录项缺失也绝不把 running 猜成 ready。
|
|
6279
7471
|
*/
|
|
6280
|
-
const runtimeStatusOfNode = (node, parentId, childId) => runtimeStatusOf(
|
|
7472
|
+
const runtimeStatusOfNode = (node, parentId, childId, options) => runtimeStatusOf(
|
|
6281
7473
|
node,
|
|
6282
7474
|
catalogEntryOf(parentId, childId ?? text(object(node).sessionId, null)),
|
|
7475
|
+
options,
|
|
6283
7476
|
);
|
|
6284
7477
|
|
|
6285
7478
|
const renderExpert = (node, index, parentId) => {
|
|
6286
|
-
|
|
7479
|
+
// 轻量快照里没有活动证据的专家行渲染中性圆点(不声明已结束),完整快照不受影响。
|
|
7480
|
+
const runtimeStatus = runtimeStatusOfNode(node, parentId, undefined, { partial: partialSnapshot });
|
|
6287
7481
|
return react.createElement("div", { key: node.id || node.sessionId || "expert-" + index, className: "deg_expert_group_node" },
|
|
6288
7482
|
react.createElement("div", { className: "deg_expert_group_row", "data-status": runtimeStatus, "data-expanded": true },
|
|
6289
7483
|
statusIcon(react, primitives, runtimeStatus),
|
|
@@ -6310,9 +7504,12 @@
|
|
|
6310
7504
|
// 空闲分组的批量候选只来自当前 idleChildren;运行中的专家或分片会被排除。
|
|
6311
7505
|
// slot 与计数标题同属第三列的 inline flex,标题按可用宽度省略,slot 不会被推到行尾。
|
|
6312
7506
|
if (trailing !== undefined && trailing !== null) idleTitleChildren.push(trailing);
|
|
7507
|
+
// 轻量快照下分组容器同样不声明「已结束」:它收纳的成员状态可能还没被完整读取,
|
|
7508
|
+
// 因此容器圆点跟随成员的中性态;完整快照仍按既有 ready 圆点渲染。
|
|
7509
|
+
const idleGroupStatus = partialSnapshot === true ? neutralRuntimeStatus : "ready";
|
|
6313
7510
|
return react.createElement("div", { className: "deg_expert_group_idle_group", "data-expanded": open },
|
|
6314
|
-
react.createElement("div", { className: "deg_expert_group_row deg_expert_group_idle_header", "data-status":
|
|
6315
|
-
statusIcon(react, primitives,
|
|
7511
|
+
react.createElement("div", { className: "deg_expert_group_row deg_expert_group_idle_header", "data-status": idleGroupStatus, "data-expanded": open },
|
|
7512
|
+
statusIcon(react, primitives, idleGroupStatus),
|
|
6316
7513
|
renderChevron(group, open),
|
|
6317
7514
|
react.createElement("div", { className: "deg_expert_group_idle_title_line" }, ...idleTitleChildren)),
|
|
6318
7515
|
open ? react.createElement("div", { className: "deg_expert_group_children deg_expert_group_idle_children" }, members.map((node, index) => node.kind === "fanout" ? renderFanout(node, index) : renderExpert(node, index, rootSessionId))) : null);
|
|
@@ -6441,23 +7638,30 @@
|
|
|
6441
7638
|
|
|
6442
7639
|
const children = Array.isArray(snapshot?.root?.children) ? snapshot.root.children : [];
|
|
6443
7640
|
// 普通专家按实际运行状态分组,fan-out 父任务则按 manager 的聚合终态进入空闲组。
|
|
7641
|
+
// 轻量快照(partial)下没有活动证据的专家是 unknown:**仍进空闲组显示**(位置与完整快照
|
|
7642
|
+
// 一致,不再因证据缺失而换组),但圆点/文案保持中性,且不参与任何批量关闭或抑制清理
|
|
7643
|
+
// (见 sidebarNodeIsIdleMembership 与 idleSidebarResourceAddresses 的分工)。
|
|
6444
7644
|
// fan-out 展示与取消/重试仍使用各自的 manager 状态,不受分组位置影响。
|
|
6445
7645
|
const idleChildren = sidebarIdleChildren();
|
|
6446
7646
|
const visibleChildren = children.filter((node) => !idleChildren.includes(node));
|
|
6447
7647
|
// 两行标题后的批量 slot 内容:主代理行只覆盖运行中的普通专家/最新分片,空闲标题行只覆盖
|
|
6448
|
-
// idleChildren
|
|
7648
|
+
// idleChildren(因此天然继承同一套证据规则:unknown 成员不在其中,绝不成为关闭候选)。
|
|
7649
|
+
// 两者都只在渲染期纯读 openTabs inventory 与 close 能力(inventory 读不出来、
|
|
6449
7650
|
// 或候选存在但 close 缺失时返回 null,不渲染任何东西);打开能力不参与这里的判定。
|
|
6450
7651
|
const rootBatchSlot = renderSidebarBatchSlot(runningSidebarResourceAddresses(), "运行中的子代理");
|
|
6451
7652
|
const idleBatchSlot = renderSidebarBatchSlot(idleSidebarResourceAddresses(idleChildren), "空闲子代理");
|
|
6452
7653
|
const root = snapshot?.root;
|
|
6453
7654
|
const rootOpen = root === null ? true : expand(root);
|
|
7655
|
+
// 根行的圆点走与专家行同一条 partial 规则:轻量快照里没有活动证据的根会话渲染中性圆点,
|
|
7656
|
+
// 而不是「已结束」的绿色圆点;完整快照与旧宿主下与既有渲染逐字一致。
|
|
7657
|
+
const rootStatus = runtimeStatusOf(root, null, { partial: partialSnapshot });
|
|
6454
7658
|
const body = viewState.loading && snapshot === null
|
|
6455
7659
|
? react.createElement("div", { className: "deg_expert_group_tree", "aria-busy": "true" }, react.createElement("div", { className: "deg_expert_group_skeleton" }), react.createElement("div", { className: "deg_expert_group_skeleton" }))
|
|
6456
7660
|
: snapshot === null
|
|
6457
7661
|
? react.createElement("div", { className: "deg_expert_group_empty" }, "暂无 Expert Group 任务")
|
|
6458
7662
|
: react.createElement("div", { className: "deg_expert_group_tree" },
|
|
6459
7663
|
react.createElement("div", { className: "deg_expert_group_node deg_expert_group_node_root", "data-root": "true" },
|
|
6460
|
-
react.createElement("div", { className: "deg_expert_group_row", "data-status":
|
|
7664
|
+
react.createElement("div", { className: "deg_expert_group_row", "data-status": rootStatus, "data-expanded": true }, statusIcon(react, primitives, rootStatus), renderChevron(root, rootOpen), react.createElement("div", null, renderThreeLines(root, { sessionId: root.sessionId, parentId: null, expanded: true, trailing: rootBatchSlot }))),
|
|
6461
7665
|
rootOpen ? react.createElement("div", { className: "deg_expert_group_children" }, [
|
|
6462
7666
|
...visibleChildren.map((node, index) => node.kind === "fanout" ? renderFanout(node, index) : renderExpert(node, index, rootSessionId)),
|
|
6463
7667
|
renderIdleGroup(idleChildren, idleBatchSlot),
|