@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 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 why = i.status === 'missing' ? '未安装' : '已安装但当前不可用(未配置或被关闭)';
990
- return `${i.pkg}(${i.what}):${why} ${i.install}`;
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
- function _resetOptionalHintOnce() { OPTIONAL_HINT_DONE = false; }
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
- * 并发合并 + TTL 的 `sessionQuery.listSessions()`。
2949
+ * 并发合并 + **目录戳失效** + 长 TTL 的 `sessionQuery.listSessions()`。
2893
2950
  *
2894
2951
  * 为什么需要它(2026-09-15 性能诊断):宿主的 listSessions 在持久化层是
2895
- * **枚举全部 artifact、逐个读 header**(本机 475 个、实测 ~1.5–2.4 s),而 /state 每 3 秒轮询;
2896
- * 客户端在飞重叠时更会 6 个请求同时跑 ⇒ 同一份枚举被重算 6 遍。
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
- * · 只服务"**已结束/已释放**的子代理"这一支(`listSubagentStatusBySession` 的兜底分支)——
2900
- * 它们的 header 不再变化,短 TTL 不会让用户看到旧状态;
2901
- * · **活代理**的状态一律走 listChildren/agents **实时读**,绝不经过这个缓存;
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 = 2000;
2906
- let LIST_SESSIONS_CACHE = { at: 0, sessions: null, inflight: null };
2907
- async function cachedListSessions(q) {
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
- if (LIST_SESSIONS_CACHE.sessions && (now - LIST_SESSIONS_CACHE.at) < LIST_SESSIONS_TTL_MS) {
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
- // 旧实现只要 `knownIds` 非空就无条件 `listSessions()`,而它在宿主侧是"枚举全部 artifact、
2960
- // 逐个读 header"(本机 475 个、~1.5–2.4 s);代理都还在活注册表里时这笔钱白花。
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
- try {
2966
- const q = (ctx && typeof ctx.get === 'function') ? ctx.get('sessionQuery') : null;
2967
- if (q && typeof q.listSessions === 'function') {
2968
- const sessions = await cachedListSessions(q);
2969
- const byId = new Map(rows.map((r) => [r.id, r]));
2970
- for (const rec of sessions || []) {
2971
- const h = rec?.header || rec;
2972
- const aid = h?.id;
2973
- if (!aid || byId.has(aid) || !knownIds.includes(aid)) continue;
2974
- // ⚠️ header 里的 createdAt / parentSession / delegationDepth **必须带出来**。
2975
- // 旧实现只取 id,把它们丢掉,导致面板上每个成员都是 createdAt=0 ⇒ 流转图
2976
- // 判为「无创建时间记录,无法分批」,尽管会话日志第一行明明写着
2977
- // {"type":"session","createdAt":…,"parentSession":…,"delegationDepth":1}。
2978
- byId.set(aid, {
2979
- id: aid, mode: 'continuable', label: '', activity: 'inactive', model: '',
2980
- createdAt: Number(h?.createdAt) || 0,
2981
- parentId: String(h?.parentSession || ''),
2982
- depth: Number(h?.delegationDepth) || 0,
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
- rows = [...byId.values()];
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 条子会话日志(角色结果**永久缓存**,几个 tick
3060
- // 内自然收敛),其余留到下一个 tick。**这不是"优化成流式",而是"把一次 20 秒的尖峰摊平"**,
3061
- // 并把"本轮被推迟"的条数如实交给调用方(rolesDeferred)⇒「还没解析」与「解析不出来」
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
- let ROLE_READS_DEFERRED = 0;
3066
- /** /state 每次进来复位预算(同一请求内多次调用共享这一份)。 */
3067
- function resetRoleReadBudget() { ROLE_READS_LEFT = ROLE_READ_BUDGET_PER_REQUEST; ROLE_READS_DEFERRED = 0; }
3068
- function roleReadBudgetSnapshot() { return { per: ROLE_READ_BUDGET_PER_REQUEST, left: ROLE_READS_LEFT, deferred: ROLE_READS_DEFERRED }; }
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
- // 预算用尽 ⇒ 本轮不读,**且不写缓存**(下一 tick 再试)。这是"本轮被推迟",
3082
- // 不是"解析不出来" —— 调用方通过 rolesDeferred 如实区分两者(两种零可区分)。
3083
- if (ROLE_READS_LEFT <= 0) { ROLE_READS_DEFERRED += 1; return ''; }
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
- // 最后兜底:子会话首条 prompt
3241
- const r = await roleFromChildLog(ctx, String(s.id || ''));
3242
- if (r) s.role = r;
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
- case 'help': return { kind: 'success', text: USAGE };
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
- // 放在加载路径上但**不 await**(探测本身是同步的,这里只是不阻塞后续注册)。
4862
- hintOptionalPluginsOnce(ctx);
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.10",
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",