@yangdcm/dsh-expert-team 1.3.10 → 1.3.11
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/CHANGELOG.md +67 -0
- package/lib/command.js +223 -65
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,73 @@
|
|
|
3
3
|
本包遵循[语义化版本](https://semver.org/lang/zh-CN/)。dsh 宿主版本线的对应关系写在
|
|
4
4
|
`package.json` 的 `engines.dsh` 与 `dsh.compatibility` 里,插件市场按它判断"这个插件跟你的宿主兼不兼容"。
|
|
5
5
|
|
|
6
|
+
## 1.3.11
|
|
7
|
+
|
|
8
|
+
**`/state` 的 `subs` / `roles` 两段收口 —— 这次不靠预测,靠 1.3.10 留下的分步计时开关实测定位。**
|
|
9
|
+
父会话在真机上带 `DSH_EXPERT_TEAM_STATE_PROFILE=1` 复测,拿到确切归属(同一台机器、同一 session、
|
|
10
|
+
连续两次请求):
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
totalMs 2222 / 2047
|
|
14
|
+
profile.runs+select 21 / 15 ms ✓ 便宜
|
|
15
|
+
profile.subs 1327 / 1388 ms ✗ 主犯
|
|
16
|
+
profile.wfLabels 0 / 0 ms ✓
|
|
17
|
+
profile.roles 869 / 638 ms ✗ 次犯
|
|
18
|
+
profile.tail 3 / 3 ms ✓
|
|
19
|
+
rolesBudget: {per:4, left:0, deferred: 16 → 40} ← deferred 在**涨**
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### ① `subs` 段:按 session id 备忘(并纠正两处"看起来像缓存、其实没用"的设置)
|
|
23
|
+
|
|
24
|
+
- **根因**:1.3.10 的"只在真缺人时才查"**没真正省下钱** —— 已结束/历史子会话本来就不在活注册表里,
|
|
25
|
+
`missingIds` 每请求都非空 ⇒ 每请求都退化成对 **475 个 artifact 逐个读 header** 的枚举;
|
|
26
|
+
而当时设的 **2 s TTL 恰好等于真实请求间隔**(轮询 + 退避),**必然过期** ⇒ 等于没有缓存。
|
|
27
|
+
- **改法**:新增 `SUB_HEADER_MEMO`(按 session id 永久记住 `createdAt/parentId/depth`)——
|
|
28
|
+
这条分支上的 id 恰恰是"活注册表里查不到"的那些,其 header 不可变,记住它们不会让用户看到旧状态;
|
|
29
|
+
枚举本身的主失效键改为 **`(sessions 根目录, mtimeMs, size)`**(`sessionsRootStamp()`,实测 0.56 ms),
|
|
30
|
+
TTL 只作兜底并放宽到 **60 s**。
|
|
31
|
+
- **口径不变**:**活代理一律实时读,绝不进任何缓存**(有断言守着:活代理不写入备忘)。
|
|
32
|
+
- **折算前后**(单位成本取自上面的真机 profile:一次枚举 ≈ 1500–2400 ms,本机取 1800 ms):
|
|
33
|
+
**旧:每请求 ≈ 1800 ms;新:第 1 次 1800 ms,第 2 次起 0.02–0.09 ms** ⇒ 热态 `subs` ≈ **0 ms**。
|
|
34
|
+
|
|
35
|
+
### ② `roles` 段:限次摊平改成**跨请求推进的队列**
|
|
36
|
+
|
|
37
|
+
- **根因**:1.3.10 每请求都把候选集**从零重算**,预算永远喂给队首同几条,而新派的子代理不断出现在队尾
|
|
38
|
+
⇒ **进度不前进、`deferred` 只增不减**(实测 16 → 40)。"限次摊平"退化成"永远摊不完"。
|
|
39
|
+
- **改法**:模块级 `ROLE_PENDING` 队列 —— 每请求按"当前 subs 里仍未缓存"的顺序**重建队列**
|
|
40
|
+
(已缓存的自动掉队、既有顺序保留、新人追加队尾),再从队首消费 ≤ 4 条;角色结果**永久缓存**
|
|
41
|
+
(对已结束会话不可变)。`resetRoleReadBudget()` 只复位**本请求额度**,**不丢队列进度**。
|
|
42
|
+
- **两种零仍然分得开**:`deferred` = **还没解析**(队列长度,单调不增);`unresolved` = **解析不出来**
|
|
43
|
+
(读过但日志里没有角色)—— 新增这个数,正是为了不让两者混成一个。
|
|
44
|
+
- **实测(21 个子会话,每次 4 条预算)**:`deferred` **17 → 13 → 9 → 5 → 1 → 0**;
|
|
45
|
+
6 个 tick 后**零次日志读**。折算:**旧 860 ms/请求且永不收敛 → 收敛后 ≈ 0 ms**。
|
|
46
|
+
|
|
47
|
+
### ③ 推荐插件自检的**假警报**(用户贴的启动日志直接坐实)
|
|
48
|
+
|
|
49
|
+
- **现象**:日志说 `@vectorize-io/hindsight-coding-agents` 与 `dsh-cost-meter`"已安装但当前不可用 ⇒
|
|
50
|
+
`dsh plugin … add`",**紧跟着 `dsh-cost-meter` 自己就加载成功了** ⇒ 判定是错的、建议是误导的。
|
|
51
|
+
- **根因(两条)**:① 我们的 `apply()` 跑在其它插件之前,那一刻 `ctx.get('costMeter')`/工具表里当然还没有它们;
|
|
52
|
+
② 对 `installed-not-ready` 也拼了安装命令,等于叫用户装一个**已经装好**的东西。
|
|
53
|
+
- **宿主没有 app 级 ready 事件**(已核源码):`dsh-app-boot` 的 `boot()` 顺序是
|
|
54
|
+
`mountRootInclude → await ctx.get('loader').await() → assertEntriesActivated → return`,
|
|
55
|
+
期间不 emit 任何"就绪"事件;而 `loader.await()` 我们**不能用** —— 那棵树包含我们自己的挂载任务,
|
|
56
|
+
在 init 里 await 它会**自等死锁**。
|
|
57
|
+
- **改法**:`scheduleOptionalPluginCheck()` —— **有界延迟重探**(250/1000/3000 ms,最后一次才下结论)
|
|
58
|
+
+ **`ctx.inject([服务])` 事件驱动**(服务一出现立刻重探;服务始终不出现则回调不触发,零副作用);
|
|
59
|
+
外加 **`/team help` 懒重探**(刚装上/刚修好配置不必为了这行提示再重启)。
|
|
60
|
+
- **文案分档**:`missing` ⇒ 给安装命令;`installed-not-ready` ⇒ 只说"已安装但当前未就绪(可能未配置或被关闭)",
|
|
61
|
+
**不再给安装命令**;`unknown` ⇒ 不出现(四态与"两种零可区分"的语义不变)。
|
|
62
|
+
|
|
63
|
+
### 验证与口径
|
|
64
|
+
|
|
65
|
+
- `npm run test:all` **EXIT=0**(**80** 个测试文件;`state-perf-guard` 扩到 37 条断言:备忘只枚举一次、
|
|
66
|
+
活代理不入备忘、目录戳失效、队列跨请求推进、deferred 单调不增、额度复位不丢进度、
|
|
67
|
+
`installed-not-ready` 不含安装命令、服务晚挂不再误报、就绪后重探与懒重探的接线)。
|
|
68
|
+
- **诚实边界**:上面"折算前后"的数字是**用父会话真机实测的单位成本 × 新代码的调用次数**折算的
|
|
69
|
+
(本机宿主仍跑 1.3.10,我无法把新代码装进运行中的进程)。**端到端 `totalMs` 请装完 1.3.11 后带
|
|
70
|
+
`DSH_EXPERT_TEAM_STATE_PROFILE=1` 复测**:预期 `subs`/`roles` 两段都接近 0,
|
|
71
|
+
`totalMs` 由 `runs+select`(~20 ms)+ `tail`(~3 ms)主导。
|
|
72
|
+
|
|
6
73
|
## 1.3.10
|
|
7
74
|
|
|
8
75
|
**四项一次收口:推荐插件自检 · 画布轮询也走 single-flight · 底盘缓存 · 角色解析限次。**
|
package/lib/command.js
CHANGED
|
@@ -985,16 +985,27 @@ function detectOptionalPlugins(ctx) {
|
|
|
985
985
|
const needsAttention = items.filter((i) => i.status === 'missing' || i.status === 'installed-not-ready');
|
|
986
986
|
let hint = '';
|
|
987
987
|
if (needsAttention.length) {
|
|
988
|
+
// 分档文案(2026-09-15 真实踩到):对 `installed-not-ready` **不能**再给安装命令 ——
|
|
989
|
+
// 那等于叫用户装一个已经装好的东西(用户机器上 dsh-cost-meter 明明紧接着自己加载成功了,
|
|
990
|
+
// 我们却提示他去 `dsh plugin add dsh-cost-meter` ⇒ 假警报 + 误导)。
|
|
991
|
+
// · missing(确认没装)⇒ 给安装命令;
|
|
992
|
+
// · installed-not-ready(装了但此刻拿不到)⇒ 只说事实与可能原因,**不给命令**;
|
|
993
|
+
// · unknown ⇒ 不进 needsAttention,不出现。
|
|
988
994
|
const parts = needsAttention.map((i) => {
|
|
989
|
-
const
|
|
990
|
-
return
|
|
995
|
+
const head = `${i.pkg}(${i.what})`;
|
|
996
|
+
return i.status === 'missing'
|
|
997
|
+
? `${head}:未安装 ⇒ ${i.install}`
|
|
998
|
+
: `${head}:已安装但当前未就绪(可能未配置或被关闭;若刚装上,稍后或重启后再看)`;
|
|
991
999
|
});
|
|
992
1000
|
hint = `[expert-team] 推荐插件未就绪(不影响使用,只是少了对应体验):${parts.join(';')}`;
|
|
993
1001
|
}
|
|
994
1002
|
return { items, needsAttention, hint };
|
|
995
1003
|
}
|
|
996
1004
|
let OPTIONAL_HINT_DONE = false;
|
|
997
|
-
/**
|
|
1005
|
+
/**
|
|
1006
|
+
* 提示一次(**装了/都齐则完全不吭声**;进程内最多一行)。
|
|
1007
|
+
* 语义保持:只在"真的有需要关注的东西"时打印,且只打印一次。
|
|
1008
|
+
*/
|
|
998
1009
|
function hintOptionalPluginsOnce(ctx) {
|
|
999
1010
|
if (OPTIONAL_HINT_DONE) return '';
|
|
1000
1011
|
try {
|
|
@@ -1005,7 +1016,53 @@ function hintOptionalPluginsOnce(ctx) {
|
|
|
1005
1016
|
return r.hint;
|
|
1006
1017
|
} catch { return ''; }
|
|
1007
1018
|
}
|
|
1008
|
-
|
|
1019
|
+
/** 懒重探(`/team help` 等"真正用到"的时刻调用):结果更新,但同一次进程里仍最多打印一行。 */
|
|
1020
|
+
function recheckOptionalPlugins(ctx) {
|
|
1021
|
+
try { return detectOptionalPlugins(ctx); } catch { return { items: [], needsAttention: [], hint: '' }; }
|
|
1022
|
+
}
|
|
1023
|
+
// ── 探测时机(2026-09-15 第二轮修复)─────────────────────────────────────────────
|
|
1024
|
+
// **问题(用户贴的启动日志直接坐实)**:我们的 `apply()` 跑在其它插件之前,
|
|
1025
|
+
// 那一刻 `ctx.get('costMeter')` / 工具表里当然还没有它们 ⇒ 已装且可用的插件被误判成
|
|
1026
|
+
// `installed-not-ready`,日志里紧跟着 `dsh-cost-meter` 自己就加载成功了 ⇒ **假警报**。
|
|
1027
|
+
//
|
|
1028
|
+
// **宿主没有 app 级 ready 事件**(已核源码):`dsh-app-boot` 的 `boot()` 顺序是
|
|
1029
|
+
// `await mountRootInclude(...)` → `await ctx.get('loader')?.await()` → `assertEntriesActivated` → return,
|
|
1030
|
+
// 期间**不 emit** 任何"就绪"事件(只 emit `loader/config-update`)。
|
|
1031
|
+
// 而 `loader.await()` 我们也**不能**用:那棵树包含我们自己的挂载任务 ⇒ 在 init 里 await 它会自等死锁。
|
|
1032
|
+
// ⇒ 采用两条**不会有死锁**的机制:
|
|
1033
|
+
// ① 有界延迟重探(下面的 delays):每次重探都拿最新事实,直到"无待关注项"或重试用尽;
|
|
1034
|
+
// ② `ctx.inject([服务])` 事件驱动(服务/工具一旦出现立刻重探)—— 这是 cordis 的正确姿势,
|
|
1035
|
+
// 服务始终不出现时回调**只是不触发**,不会报错、也不阻塞。
|
|
1036
|
+
// 结果:**已装且可用的插件不再被误报**;刚装上的插件也不必为了这行提示再重启一次(懒重探兜底)。
|
|
1037
|
+
const OPTIONAL_PROBE_DELAYS_MS = [250, 1000, 3000];
|
|
1038
|
+
let OPTIONAL_PROBE_TIMERS = [];
|
|
1039
|
+
function scheduleOptionalPluginCheck(ctx, delays) {
|
|
1040
|
+
const plan = Array.isArray(delays) ? delays : OPTIONAL_PROBE_DELAYS_MS;
|
|
1041
|
+
const timers = [];
|
|
1042
|
+
const run = (isLast) => {
|
|
1043
|
+
if (OPTIONAL_HINT_DONE) return;
|
|
1044
|
+
let r;
|
|
1045
|
+
try { r = detectOptionalPlugins(ctx); } catch { return; }
|
|
1046
|
+
if (!r.needsAttention.length) { OPTIONAL_HINT_DONE = true; return; } // 都就绪 ⇒ 一声不吭(且不再打扰)
|
|
1047
|
+
if (isLast) { OPTIONAL_HINT_DONE = true; console.log(r.hint); }
|
|
1048
|
+
};
|
|
1049
|
+
plan.forEach((ms, i) => {
|
|
1050
|
+
const t = setTimeout(() => run(i === plan.length - 1), ms);
|
|
1051
|
+
if (t && typeof t.unref === 'function') t.unref();
|
|
1052
|
+
timers.push(t);
|
|
1053
|
+
});
|
|
1054
|
+
OPTIONAL_PROBE_TIMERS = timers;
|
|
1055
|
+
// 事件驱动兜底:服务一出现就重探(不出现则永不触发,零副作用)。
|
|
1056
|
+
for (const svc of ['costMeter', 'tools']) {
|
|
1057
|
+
try { if (ctx && typeof ctx.inject === 'function') ctx.inject([svc], () => run(true)); } catch { /* best-effort */ }
|
|
1058
|
+
}
|
|
1059
|
+
return timers;
|
|
1060
|
+
}
|
|
1061
|
+
function _resetOptionalHintOnce() {
|
|
1062
|
+
OPTIONAL_HINT_DONE = false;
|
|
1063
|
+
for (const t of OPTIONAL_PROBE_TIMERS) { try { clearTimeout(t); } catch { /* ignore */ } }
|
|
1064
|
+
OPTIONAL_PROBE_TIMERS = [];
|
|
1065
|
+
}
|
|
1009
1066
|
|
|
1010
1067
|
function warnInstallFailureOnce(kind) {
|
|
1011
1068
|
if (INSTALL_FAILURE_WARNED.has(kind)) return;
|
|
@@ -2889,39 +2946,66 @@ function subHeaderIndex(ctx, subs, wfLabels) {
|
|
|
2889
2946
|
}
|
|
2890
2947
|
|
|
2891
2948
|
/**
|
|
2892
|
-
* 并发合并 +
|
|
2949
|
+
* 并发合并 + **目录戳失效** + 长 TTL 的 `sessionQuery.listSessions()`。
|
|
2893
2950
|
*
|
|
2894
2951
|
* 为什么需要它(2026-09-15 性能诊断):宿主的 listSessions 在持久化层是
|
|
2895
|
-
* **枚举全部 artifact、逐个读 header**(本机 475 个、实测 ~1.5–2.4 s),而 /state 每 3
|
|
2896
|
-
*
|
|
2952
|
+
* **枚举全部 artifact、逐个读 header**(本机 475 个、实测 ~1.5–2.4 s),而 /state 每 3 秒轮询。
|
|
2953
|
+
*
|
|
2954
|
+
* **2026-09-15 第二轮(profile 实测 subs 段 1.3–1.4 s 之后的纠正)**:
|
|
2955
|
+
* ① **2 s TTL 等于没有**:真实请求间隔就在 2 s 上下(轮询 + 退避),必然过期 ⇒ 每请求重算。
|
|
2956
|
+
* 现在 TTL 只作**兜底**(60 s),主失效键改成 `(sessions 根目录, mtimeMs, size)`
|
|
2957
|
+
* —— 新增/删除会话必然改动目录 mtime/size ⇒ **立刻失效**,不必靠 TTL 猜新鲜度。
|
|
2958
|
+
* ② **真正省时的是下面的 `SUB_HEADER_MEMO`**:按 session id 记住**不可变**的 header 字段,
|
|
2959
|
+
* 第二个请求起**根本不再枚举**。这才是把 subs 段打到近 0 的手段,而不是调 TTL 骗自己。
|
|
2897
2960
|
*
|
|
2898
2961
|
* **缓存纪律(本仓对"看到的就是真的"很敏感,所以写死在这里)**:
|
|
2899
|
-
* · 只服务"
|
|
2900
|
-
*
|
|
2901
|
-
* ·
|
|
2902
|
-
* · 失效条件:2 s TTL(兜底)+ 并发合并(同刻只跑一次)。TTL 之后必然重算,
|
|
2903
|
-
* 所以"新生效的会话"最多晚 2 秒可见 —— 这是**有界的**,且不会让在飞代理看起来停滞。
|
|
2962
|
+
* · 只服务"活注册表里**查不到**的子代理"这一支 ⇒ 都是**已结束/历史**会话,其 header 不再变化;
|
|
2963
|
+
* · **活代理**的状态一律走 listChildren/agents **实时读**,绝不经过这里的任何缓存;
|
|
2964
|
+
* · 目录戳取不到时退化为 60 s TTL 兜底(有界:新生效会话最多晚 60 s 可见,且只在 stat 失败时)。
|
|
2904
2965
|
*/
|
|
2905
|
-
const LIST_SESSIONS_TTL_MS =
|
|
2906
|
-
let LIST_SESSIONS_CACHE = { at: 0, sessions: null, inflight: null };
|
|
2907
|
-
|
|
2966
|
+
const LIST_SESSIONS_TTL_MS = 60000;
|
|
2967
|
+
let LIST_SESSIONS_CACHE = { at: 0, stamp: null, sessions: null, inflight: null };
|
|
2968
|
+
/** 会话目录的廉价失效戳(mtime + size)。取不到 ⇒ null ⇒ 只用 TTL 兜底。 */
|
|
2969
|
+
async function sessionsRootStamp() {
|
|
2970
|
+
try {
|
|
2971
|
+
const st = await stat(join(dshHome(), 'sessions'));
|
|
2972
|
+
return `${Math.round(Number(st.mtimeMs) || 0)}:${Number(st.size) || 0}`;
|
|
2973
|
+
} catch { return null; }
|
|
2974
|
+
}
|
|
2975
|
+
async function cachedListSessions(q, stamp) {
|
|
2908
2976
|
const now = Date.now();
|
|
2909
|
-
|
|
2977
|
+
const stampOk = (stamp === undefined || stamp === null) ? true : (LIST_SESSIONS_CACHE.stamp === stamp);
|
|
2978
|
+
if (LIST_SESSIONS_CACHE.sessions && (now - LIST_SESSIONS_CACHE.at) < LIST_SESSIONS_TTL_MS && stampOk) {
|
|
2910
2979
|
return LIST_SESSIONS_CACHE.sessions;
|
|
2911
2980
|
}
|
|
2912
2981
|
if (LIST_SESSIONS_CACHE.inflight) return LIST_SESSIONS_CACHE.inflight; // 同刻只有一个枚举在跑
|
|
2982
|
+
SUB_HEADER_STATS.enumCalls += 1;
|
|
2913
2983
|
const p = Promise.resolve()
|
|
2914
2984
|
.then(() => q.listSessions())
|
|
2915
2985
|
.then((sessions) => {
|
|
2916
|
-
LIST_SESSIONS_CACHE = { at: Date.now(), sessions: Array.isArray(sessions) ? sessions : [], inflight: null };
|
|
2986
|
+
LIST_SESSIONS_CACHE = { at: Date.now(), stamp: (stamp === undefined ? null : stamp), sessions: Array.isArray(sessions) ? sessions : [], inflight: null };
|
|
2917
2987
|
return LIST_SESSIONS_CACHE.sessions;
|
|
2918
2988
|
})
|
|
2919
|
-
.catch((e) => { LIST_SESSIONS_CACHE = { at: 0, sessions: null, inflight: null }; throw e; });
|
|
2989
|
+
.catch((e) => { LIST_SESSIONS_CACHE = { at: 0, stamp: null, sessions: null, inflight: null }; throw e; });
|
|
2920
2990
|
LIST_SESSIONS_CACHE.inflight = p;
|
|
2921
2991
|
return p;
|
|
2922
2992
|
}
|
|
2923
2993
|
/** 仅供测试:清空枚举缓存并复位(生产代码不需要调用它)。 */
|
|
2924
|
-
function _resetListSessionsCache() { LIST_SESSIONS_CACHE = { at: 0, sessions: null, inflight: null }; }
|
|
2994
|
+
function _resetListSessionsCache() { LIST_SESSIONS_CACHE = { at: 0, stamp: null, sessions: null, inflight: null }; }
|
|
2995
|
+
|
|
2996
|
+
// ── 已结束子会话的 header 备忘(按 session id,**永久**)─────────────────────────
|
|
2997
|
+
// createdAt / parentId / depth 对**已结束**会话是不可变的;而用到备忘的这条分支,其 id 恰恰是
|
|
2998
|
+
// "活注册表里查不到"的那些 ⇒ 记住它们**不会**让用户看到旧状态(活代理走实时路径,永不写入这里)。
|
|
2999
|
+
// 旧实现每请求都要枚举 475 个 artifact 才能回答"这些人是何时建的、谁派工的"——
|
|
3000
|
+
// 有了备忘,第二个请求起直接查表。
|
|
3001
|
+
const SUB_HEADER_MEMO = new Map();
|
|
3002
|
+
const SUB_HEADER_STATS = { memoHits: 0, memoWrites: 0, enumCalls: 0 };
|
|
3003
|
+
function _resetSubHeaderMemo() {
|
|
3004
|
+
SUB_HEADER_MEMO.clear();
|
|
3005
|
+
SUB_HEADER_STATS.memoHits = 0;
|
|
3006
|
+
SUB_HEADER_STATS.memoWrites = 0;
|
|
3007
|
+
SUB_HEADER_STATS.enumCalls = 0;
|
|
3008
|
+
}
|
|
2925
3009
|
|
|
2926
3010
|
async function listSubagentStatusBySession(ctx, sid, knownIds) {
|
|
2927
3011
|
const root = sid ? await rootSessionId(ctx, sid) : '';
|
|
@@ -2955,36 +3039,52 @@ async function listSubagentStatusBySession(ctx, sid, knownIds) {
|
|
|
2955
3039
|
// Fallback: completed subagents may be disposed from the live registry —
|
|
2956
3040
|
// the durable session projection (sessionQuery) still lists them.
|
|
2957
3041
|
//
|
|
2958
|
-
// ⚠️ 2026-09-15
|
|
2959
|
-
//
|
|
2960
|
-
//
|
|
2961
|
-
//
|
|
2962
|
-
// 并发合并与 TTL 见 cachedListSessions() 的注释(缓存纪律写在那里)。
|
|
3042
|
+
// ⚠️ 2026-09-15 两轮性能修复:
|
|
3043
|
+
// 第一轮:只有**真的缺人**才花这笔钱(旧实现无条件 listSessions());
|
|
3044
|
+
// 第二轮:缺的那些**先查 `SUB_HEADER_MEMO`**(已结束会话的 header 不可变 ⇒ 永久备忘),
|
|
3045
|
+
// 只有仍然未知的才去枚举 ⇒ 第二个请求起通常**零枚举**。
|
|
2963
3046
|
const missingIds = (knownIds || []).filter((id) => !rows.some((r) => r.id === id));
|
|
2964
3047
|
if (missingIds.length) {
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
3048
|
+
const memoById = new Map(rows.map((r) => [r.id, r]));
|
|
3049
|
+
const stillUnknown = [];
|
|
3050
|
+
for (const id of missingIds) {
|
|
3051
|
+
const memo = SUB_HEADER_MEMO.get(id);
|
|
3052
|
+
if (!memo) { stillUnknown.push(id); continue; }
|
|
3053
|
+
SUB_HEADER_STATS.memoHits += 1;
|
|
3054
|
+
memoById.set(id, {
|
|
3055
|
+
id, mode: 'continuable', label: '', activity: 'inactive', model: '',
|
|
3056
|
+
createdAt: memo.createdAt, parentId: memo.parentId, depth: memo.depth,
|
|
3057
|
+
});
|
|
3058
|
+
}
|
|
3059
|
+
rows = [...memoById.values()];
|
|
3060
|
+
if (stillUnknown.length) {
|
|
3061
|
+
try {
|
|
3062
|
+
const q = (ctx && typeof ctx.get === 'function') ? ctx.get('sessionQuery') : null;
|
|
3063
|
+
if (q && typeof q.listSessions === 'function') {
|
|
3064
|
+
const sessions = await cachedListSessions(q, await sessionsRootStamp());
|
|
3065
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
3066
|
+
for (const rec of sessions || []) {
|
|
3067
|
+
const h = rec?.header || rec;
|
|
3068
|
+
const aid = h?.id;
|
|
3069
|
+
if (!aid || byId.has(aid) || !knownIds.includes(aid)) continue;
|
|
3070
|
+
// ⚠️ header 里的 createdAt / parentSession / delegationDepth **必须带出来**。
|
|
3071
|
+
// 旧实现只取 id,把它们丢掉,导致面板上每个成员都是 createdAt=0 ⇒ 流转图
|
|
3072
|
+
// 判为「无创建时间记录,无法分批」,尽管会话日志第一行明明写着
|
|
3073
|
+
// {"type":"session","createdAt":…,"parentSession":…,"delegationDepth":1}。
|
|
3074
|
+
const entry = {
|
|
3075
|
+
createdAt: Number(h?.createdAt) || 0,
|
|
3076
|
+
parentId: String(h?.parentSession || ''),
|
|
3077
|
+
depth: Number(h?.delegationDepth) || 0,
|
|
3078
|
+
};
|
|
3079
|
+
// 只有"活注册表里查不到"的 id 会走到这里(活代理走实时路径)⇒ header 不可变 ⇒ 备忘安全。
|
|
3080
|
+
SUB_HEADER_MEMO.set(aid, entry);
|
|
3081
|
+
SUB_HEADER_STATS.memoWrites += 1;
|
|
3082
|
+
byId.set(aid, { id: aid, mode: 'continuable', label: '', activity: 'inactive', model: '', ...entry });
|
|
3083
|
+
}
|
|
3084
|
+
rows = [...byId.values()];
|
|
2984
3085
|
}
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
} catch { /* best-effort */ }
|
|
3086
|
+
} catch { /* best-effort */ }
|
|
3087
|
+
}
|
|
2988
3088
|
}
|
|
2989
3089
|
return Array.isArray(rows) ? rows : [];
|
|
2990
3090
|
}
|
|
@@ -3056,16 +3156,52 @@ const SUB_ROLE_LOG_CACHE = new Map();
|
|
|
3056
3156
|
// 投影/限量/流式参数 —— `projectionMode` 只存在于内部 `corpus.read`,公开方法不暴露;
|
|
3057
3157
|
// `filterEvents`/`readEvent` 也都是"先把整条日志 load 进内存再筛"。要"只读首帧"只能绕过
|
|
3058
3158
|
// 公开接口去碰持久化层的内部方法(`readFirstZstdLine` 之类),那是**非契约、脆的**。
|
|
3059
|
-
// ⇒ 采用**有界**做法:每次 /state 最多读 N
|
|
3060
|
-
//
|
|
3061
|
-
//
|
|
3062
|
-
//
|
|
3159
|
+
// ⇒ 采用**有界**做法:每次 /state 最多读 N 条子会话日志(角色结果**永久缓存**),其余留到下一个 tick。
|
|
3160
|
+
// **这不是"优化成流式",而是"把一次 20 秒的尖峰摊平"**,并把"本轮被推迟"的条数如实交给
|
|
3161
|
+
// 调用方(rolesDeferred)⇒「还没解析」与「解析不出来」两件事分得清
|
|
3162
|
+
// (本仓纪律:两种零必须可区分),**不臆造角色**。
|
|
3163
|
+
//
|
|
3164
|
+
// ⚠️ 2026-09-15 第二轮(profile 实测 roles 段 0.6–0.9 s、且 `deferred` 从 16 涨到 40 之后):
|
|
3165
|
+
// 旧实现每请求都把候选集**从零重算**,预算永远花在队首那几条上,而新派的人不断出现在队尾
|
|
3166
|
+
// ⇒ **进度不前进、deferred 只增不减**("限次摊平"退化成"永远摊不完")。
|
|
3167
|
+
// 现在改成**跨请求保留进度**:每次按"当前 subs 里仍未缓存"的顺序**重建队列**(已缓存的自动掉队、
|
|
3168
|
+
// 既有顺序保留),再从队首消费 ≤N 条 ⇒ 进度单调推进,`deferred` 单调不增(除非真的又新派了人)。
|
|
3063
3169
|
const ROLE_READ_BUDGET_PER_REQUEST = 4;
|
|
3064
3170
|
let ROLE_READS_LEFT = ROLE_READ_BUDGET_PER_REQUEST;
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
function
|
|
3171
|
+
/** 待解析队列(跨请求保留进度):元素是子会话 id。 */
|
|
3172
|
+
let ROLE_PENDING = [];
|
|
3173
|
+
/** /state 每次进来复位**本请求的读取额度**(队列进度**不**复位)。 */
|
|
3174
|
+
function resetRoleReadBudget() { ROLE_READS_LEFT = ROLE_READ_BUDGET_PER_REQUEST; }
|
|
3175
|
+
/**
|
|
3176
|
+
* 按当前 subs 重建待解析队列:保留既有顺序里"仍在场且仍未缓存"的,再把新出现的未缓存 id 追加到队尾。
|
|
3177
|
+
* 幂等、无副作用;已缓存(含"解析不出来"缓存为 '')的条目自动掉队。
|
|
3178
|
+
*/
|
|
3179
|
+
function syncRolePending(subs) {
|
|
3180
|
+
const present = new Set();
|
|
3181
|
+
for (const s of subs || []) { const id = String((s && s.id) || ''); if (id) present.add(id); }
|
|
3182
|
+
const seen = new Set();
|
|
3183
|
+
const next = [];
|
|
3184
|
+
for (const id of ROLE_PENDING) {
|
|
3185
|
+
if (SUB_ROLE_LOG_CACHE.has(id) || !present.has(id) || seen.has(id)) continue;
|
|
3186
|
+
seen.add(id); next.push(id);
|
|
3187
|
+
}
|
|
3188
|
+
for (const s of subs || []) {
|
|
3189
|
+
const id = String((s && s.id) || '');
|
|
3190
|
+
if (!id || seen.has(id) || SUB_ROLE_LOG_CACHE.has(id)) continue;
|
|
3191
|
+
seen.add(id); next.push(id);
|
|
3192
|
+
}
|
|
3193
|
+
ROLE_PENDING = next;
|
|
3194
|
+
return ROLE_PENDING;
|
|
3195
|
+
}
|
|
3196
|
+
/** 仅供测试:清空待解析队列(生产代码不需要调用它)。 */
|
|
3197
|
+
function _resetRolePending() { ROLE_PENDING = []; }
|
|
3198
|
+
function roleReadBudgetSnapshot() {
|
|
3199
|
+
// deferred = **还没解析**(队列里等着读的);unresolved = **解析不出来**(读过但日志里没有角色)。
|
|
3200
|
+
// 两者必须是两个数:本仓纪律"两种零要分得清"。
|
|
3201
|
+
let unresolved = 0;
|
|
3202
|
+
for (const v of SUB_ROLE_LOG_CACHE.values()) if (!v) unresolved += 1;
|
|
3203
|
+
return { per: ROLE_READ_BUDGET_PER_REQUEST, left: ROLE_READS_LEFT, deferred: ROLE_PENDING.length, unresolved };
|
|
3204
|
+
}
|
|
3069
3205
|
/**
|
|
3070
3206
|
* Infer a child's role from its own session log. **Only the first `user/message`
|
|
3071
3207
|
* counts** — it is the delegation prompt the lead passed, e.g.
|
|
@@ -3078,9 +3214,9 @@ function roleReadBudgetSnapshot() { return { per: ROLE_READ_BUDGET_PER_REQUEST,
|
|
|
3078
3214
|
async function roleFromChildLog(ctx, id) {
|
|
3079
3215
|
if (!id) return '';
|
|
3080
3216
|
if (SUB_ROLE_LOG_CACHE.has(id)) return SUB_ROLE_LOG_CACHE.get(id);
|
|
3081
|
-
// 预算用尽 ⇒
|
|
3082
|
-
// 不是"解析不出来" —— 调用方通过 rolesDeferred
|
|
3083
|
-
if (ROLE_READS_LEFT <= 0) {
|
|
3217
|
+
// 预算用尽 ⇒ 本轮不读,**且不写缓存**(下一个 tick 再试)。这是"本轮被推迟",
|
|
3218
|
+
// 不是"解析不出来" —— 调用方通过 rolesDeferred(队列长度)如实区分两者(两种零可区分)。
|
|
3219
|
+
if (ROLE_READS_LEFT <= 0) { return ''; }
|
|
3084
3220
|
ROLE_READS_LEFT -= 1;
|
|
3085
3221
|
let role = '';
|
|
3086
3222
|
try {
|
|
@@ -3232,16 +3368,33 @@ async function workflowRuns(ctx, sid, subs) {
|
|
|
3232
3368
|
* (2026-09-11 真实踩到:30 个子代理里只有 5 个有角色,正是 wfLabels 解析不出的那 5 个。)
|
|
3233
3369
|
*/
|
|
3234
3370
|
async function resolveSubRoles(ctx, subs, wfLabels) {
|
|
3371
|
+
const byId = new Map();
|
|
3372
|
+
for (const s of subs || []) { const id = String((s && s.id) || ''); if (id) byId.set(id, s); }
|
|
3373
|
+
// ① 便宜来源(自带 label / 父会话事件流 label)与已缓存结果 —— **不花预算、不读日志**
|
|
3235
3374
|
for (const s of subs || []) {
|
|
3236
3375
|
try {
|
|
3237
|
-
// ① 自带 label(显式角色/括号 id/关键词)与 ③ 父会话事件流的 workflow 派工 label
|
|
3238
3376
|
const known = roleOfSub(s, wfLabels);
|
|
3239
3377
|
if (known) { if (!s.role) s.role = known; continue; }
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
if (
|
|
3378
|
+
const id = String((s && s.id) || '');
|
|
3379
|
+
if (!id) continue;
|
|
3380
|
+
if (SUB_ROLE_LOG_CACHE.has(id)) {
|
|
3381
|
+
const cached = SUB_ROLE_LOG_CACHE.get(id);
|
|
3382
|
+
if (cached && !s.role) s.role = cached;
|
|
3383
|
+
}
|
|
3243
3384
|
} catch (e) { /* best-effort */ }
|
|
3244
3385
|
}
|
|
3386
|
+
// ② 跨请求推进:重建待解析队列(保留进度、已缓存的掉队、新人追加到队尾),再消费本请求额度。
|
|
3387
|
+
// 这样 `deferred`(队列长度)只减不增,除非真的又新派了人 —— 旧实现每请求从零重算,
|
|
3388
|
+
// 预算永远喂给队首同几条,新派的人永远轮不到(实测 deferred 16 → 40)。
|
|
3389
|
+
syncRolePending(subs);
|
|
3390
|
+
while (ROLE_READS_LEFT > 0 && ROLE_PENDING.length) {
|
|
3391
|
+
const id = ROLE_PENDING.shift();
|
|
3392
|
+
if (SUB_ROLE_LOG_CACHE.has(id)) continue;
|
|
3393
|
+
const r = await roleFromChildLog(ctx, id);
|
|
3394
|
+
if (!SUB_ROLE_LOG_CACHE.has(id)) { ROLE_PENDING.unshift(id); break; } // 预算耗尽(未写缓存)⇒ 放回队首,下一轮继续
|
|
3395
|
+
const s = byId.get(id);
|
|
3396
|
+
if (s && r && !s.role) s.role = r;
|
|
3397
|
+
}
|
|
3245
3398
|
return subs;
|
|
3246
3399
|
}
|
|
3247
3400
|
// sub rows → Map<role, sub> (first match wins; unknown labels map to '' and
|
|
@@ -4767,7 +4920,10 @@ async function executeTeamCommand(ctx, invocation) {
|
|
|
4767
4920
|
const memRun = (mem && mem.workspace === cwd) ? mem.runId : '';
|
|
4768
4921
|
const runArg = (s) => s || memRun;
|
|
4769
4922
|
switch (c.kind) {
|
|
4770
|
-
|
|
4923
|
+
// 懒重探兜底(2026-09-15):加载当刻的探测可能"太早"(那时别人的服务还没挂上)。
|
|
4924
|
+
// `/team help` 是"用户真的来看能做什么"的时刻 ⇒ 顺手重探一次并更新结论,
|
|
4925
|
+
// 这样刚装上/刚修好配置的插件**不必为了这一行提示再重启**。
|
|
4926
|
+
case 'help': recheckOptionalPlugins(ctx); return { kind: 'success', text: USAGE };
|
|
4771
4927
|
case 'status': return renderStatus(cwd);
|
|
4772
4928
|
case 'members': return renderMembers(cwd);
|
|
4773
4929
|
case 'models': return renderModels(cwd, runArg(c.run));
|
|
@@ -4812,7 +4968,7 @@ function warnLeadToolFaceOnce(status, detail) {
|
|
|
4812
4968
|
LEAD_TOOLFACE_WARNED.add(status);
|
|
4813
4969
|
console.warn(`[expert-team] lead 工具面**未**收窄(${status}):${detail}`);
|
|
4814
4970
|
}
|
|
4815
|
-
export const _live = { pushActivity, phaseAccountingViolations, loggedPhases, authorityViolations, WRITE_TRACER, createWriteTracer, formatConflict, summarizeTool, parseLogLine, roleOfSub, mapRoleToSub, membersFromState, buildRoleSubMap, resolveSubRoles, childSessionTiming, subHeaderIndex, sessionExists, SUB_HEADER_CACHE, workflowEventIndex, workflowChildLabels, workflowChildMeta, workflowRuns, WF_EVENT_CACHE, rememberSessionRun, sessionRunFor, runOwnerSession, SESSION_RUNS, parseTeamCommand, deriveMemberEntries, schemaViolations, runHealth, RUN_STALL_MS, scaffoldFingerprint, SCAFFOLD_REQUIRED, strandedTasks, settleStranded, IN_FLIGHT_STATUSES, normalizeCoverage, SCHEMA_WARN_SEEN, pushActivityEvent, DEFAULT_LIMITS, LIMITS, resolveLimits, capacityViolations, DEFAULT_ROUND_LIMITS, ROUND_LIMITS, ROUND_LIMIT_ENV, resolveRoundLimits, ROUND_LIMIT_OF_KIND, roundOf, isQualityTask, normTitle, roundLimitViolations, reworkLoopWriteGuard, mutateTasks, readStandingRules, appendStandingRule, rulesRun, scopeOverlapWarnings, applyTaskStatus, waitRun, eventFamily, verdictFromToken, normalizeRoleName, truncateCodepoints, filterRunScopedSubs, runCreatedAtMs, runLogTail, liveFiles, LIVE_FILES_CACHE, DEFAULT_ROLES, resolveTierGate, TIER_GATE_ENV, snapshotRun, settingsPath, loadSettingsSync, currentSettings, limitsBaseFromSettings, roundLimitsBaseFromSettings, effectiveTierGate: () => TIER_GATE, ensureSkillInstalled, ensurePresetInstalled, uninstallInstalled, buildSkillRegistration, parseSkillMarkdown, PLUGIN_VERSION, INSTALL_STAMP, runtimeSkillRegistered: () => RUNTIME_SKILL_REGISTERED, agentScopedToolNames, warnLeadToolFaceOnce, resolveLeadToolFace, LEAD_TOOLFACE_ENV, effectiveLeadToolFace: () => LEAD_TOOLFACE, resolveLoopGuard, LOOP_GUARD_ENV, effectiveLoopGuard: () => LOOP_GUARD_ENABLED, resolveRosterDefaults, rosterSettings, createRun, watchScript, canvasPollMs, installHostSettings, hostValues, hostScope, hostSettingsNote, updateHostSettings, pickFileOnly, pickHostExpressible, buildHostSchema, hostBase, hostSchemaPaths, reapplySettingsDerived, currentSettings, mergeSettings, detectOptionalPlugins, OPTIONAL_PLUGINS, hintOptionalPluginsOnce, _resetOptionalHintOnce, loaderEntryNames, hindsightToolReady, costMeterReady, listSubagentStatusBySession, cachedListSessions, _resetListSessionsCache, LIST_SESSIONS_TTL_MS, resetRoleReadBudget, roleReadBudgetSnapshot, ROLE_READ_BUDGET_PER_REQUEST, ROLE_READ_LOG_CACHE: SUB_ROLE_LOG_CACHE };
|
|
4971
|
+
export const _live = { pushActivity, phaseAccountingViolations, loggedPhases, authorityViolations, WRITE_TRACER, createWriteTracer, formatConflict, summarizeTool, parseLogLine, roleOfSub, mapRoleToSub, membersFromState, buildRoleSubMap, resolveSubRoles, childSessionTiming, subHeaderIndex, sessionExists, SUB_HEADER_CACHE, workflowEventIndex, workflowChildLabels, workflowChildMeta, workflowRuns, WF_EVENT_CACHE, rememberSessionRun, sessionRunFor, runOwnerSession, SESSION_RUNS, parseTeamCommand, deriveMemberEntries, schemaViolations, runHealth, RUN_STALL_MS, scaffoldFingerprint, SCAFFOLD_REQUIRED, strandedTasks, settleStranded, IN_FLIGHT_STATUSES, normalizeCoverage, SCHEMA_WARN_SEEN, pushActivityEvent, DEFAULT_LIMITS, LIMITS, resolveLimits, capacityViolations, DEFAULT_ROUND_LIMITS, ROUND_LIMITS, ROUND_LIMIT_ENV, resolveRoundLimits, ROUND_LIMIT_OF_KIND, roundOf, isQualityTask, normTitle, roundLimitViolations, reworkLoopWriteGuard, mutateTasks, readStandingRules, appendStandingRule, rulesRun, scopeOverlapWarnings, applyTaskStatus, waitRun, eventFamily, verdictFromToken, normalizeRoleName, truncateCodepoints, filterRunScopedSubs, runCreatedAtMs, runLogTail, liveFiles, LIVE_FILES_CACHE, DEFAULT_ROLES, resolveTierGate, TIER_GATE_ENV, snapshotRun, settingsPath, loadSettingsSync, currentSettings, limitsBaseFromSettings, roundLimitsBaseFromSettings, effectiveTierGate: () => TIER_GATE, ensureSkillInstalled, ensurePresetInstalled, uninstallInstalled, buildSkillRegistration, parseSkillMarkdown, PLUGIN_VERSION, INSTALL_STAMP, runtimeSkillRegistered: () => RUNTIME_SKILL_REGISTERED, agentScopedToolNames, warnLeadToolFaceOnce, resolveLeadToolFace, LEAD_TOOLFACE_ENV, effectiveLeadToolFace: () => LEAD_TOOLFACE, resolveLoopGuard, LOOP_GUARD_ENV, effectiveLoopGuard: () => LOOP_GUARD_ENABLED, resolveRosterDefaults, rosterSettings, createRun, watchScript, canvasPollMs, installHostSettings, hostValues, hostScope, hostSettingsNote, updateHostSettings, pickFileOnly, pickHostExpressible, buildHostSchema, hostBase, hostSchemaPaths, reapplySettingsDerived, currentSettings, mergeSettings, detectOptionalPlugins, OPTIONAL_PLUGINS, hintOptionalPluginsOnce, _resetOptionalHintOnce, scheduleOptionalPluginCheck, recheckOptionalPlugins, OPTIONAL_PROBE_DELAYS_MS, loaderEntryNames, hindsightToolReady, costMeterReady, listSubagentStatusBySession, cachedListSessions, _resetListSessionsCache, LIST_SESSIONS_TTL_MS, SUB_HEADER_MEMO, SUB_HEADER_STATS, _resetSubHeaderMemo, sessionsRootStamp, resetRoleReadBudget, roleReadBudgetSnapshot, syncRolePending, _resetRolePending, ROLE_READ_BUDGET_PER_REQUEST, ROLE_READ_LOG_CACHE: SUB_ROLE_LOG_CACHE };
|
|
4816
4972
|
|
|
4817
4973
|
export function apply(ctx, config) {
|
|
4818
4974
|
// 留一份 config:设置在运行时改变(官方面板 / 浮层)时要**用同一份 config** 重算上限与档位门,
|
|
@@ -4858,8 +5014,10 @@ export function apply(ctx, config) {
|
|
|
4858
5014
|
// skill 已有运行时注册时这里立即返回(零成本);只有宿主没有 skill 注册表时才真复制。
|
|
4859
5015
|
installInBackground('skill', () => ensureSkillInstalled());
|
|
4860
5016
|
// 推荐插件自检:纯读、缺了只打一行、装了完全不打扰(见 detectOptionalPlugins 的注释)。
|
|
4861
|
-
//
|
|
4862
|
-
|
|
5017
|
+
// ⚠️ **不能在加载当刻就下结论**:我们的 apply() 跑在其它插件之前,那一刻 costMeter/工具表
|
|
5018
|
+
// 里还没有它们 ⇒ 已装且可用的插件会被误判成"已安装但当前未就绪"(2026-09-15 真实假警报)。
|
|
5019
|
+
// 现在改为**就绪后重探**(有界延迟 + `ctx.inject` 事件驱动,见 scheduleOptionalPluginCheck)。
|
|
5020
|
+
scheduleOptionalPluginCheck(ctx);
|
|
4863
5021
|
void loadSessionRuns(); // session→run memory for overlay auto-select
|
|
4864
5022
|
ctx.commands.register({
|
|
4865
5023
|
name: 'team',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yangdcm/dsh-expert-team",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.11",
|
|
4
4
|
"description": "dsh「专家团」bundle:一句自然语言自动组建/持久化一支 12 角色多智能体团队,共享工作区协议 + 阶段门控编排 + 结构化交接 + 质量门禁/自动调度,实现者直接改代码并产出持久工件;带 live 团队浮层(质量门禁/覆盖率/工件预览)。 · Role-based multi-agent expert team for DeepSeek Harness: one sentence in, a staged and gated team delivery out.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|