@yangdcm/dsh-expert-team 1.3.15 → 1.3.17
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 +113 -0
- package/README.en.md +32 -27
- package/README.md +32 -27
- package/client.js +133 -22
- package/lib/artifact-redirect-watch.js +118 -0
- package/lib/command.js +316 -37
- package/lib/effort-preflight.js +135 -0
- package/package.json +2 -2
package/lib/command.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { randomUUID } from 'node:crypto';
|
|
16
16
|
import { execFile as execFileCb } from 'node:child_process';
|
|
17
|
-
import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises';
|
|
17
|
+
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
18
18
|
import { readFileSync } from 'node:fs';
|
|
19
19
|
import { promisify } from 'node:util';
|
|
20
20
|
import { homedir } from 'node:os';
|
|
@@ -22,9 +22,19 @@ import { join, dirname, basename } from 'node:path';
|
|
|
22
22
|
import { fileURLToPath } from 'node:url';
|
|
23
23
|
import { createArtifactWriter, nodeFsPort, cordisFsPort, DEFAULT_SCHEMA_GUARDS } from './artifact-writer.js';
|
|
24
24
|
import { createBoundaryInterceptor } from './interception.js';
|
|
25
|
-
import { createOwnershipGate } from './artifact-ownership.js';
|
|
25
|
+
import { createOwnershipGate, ARTIFACT_OWNERS } from './artifact-ownership.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* **run 模板文件清单**(单一真源)。
|
|
29
|
+
* 谁在用:① 建 run 时按它复制 `skills/expert-team/assets/templates/`;② R1 绕过检测
|
|
30
|
+
* (`bash` 里重定向写 run 工件 ⇒ 只留痕)需要"已知工件名"集合。**不要再复制一份** ——
|
|
31
|
+
* 本仓为"同一事实多份定义"专门有 `vocab-consistency` / `artifact-ownership` 断言。
|
|
32
|
+
*/
|
|
33
|
+
export const ARTIFACT_TEMPLATES = Object.freeze(['TASK.md', 'ROSTER.json', 'STATE.json', '任务看板.md', 'SPEC.md', 'PLAN.md', 'RESEARCH.md', 'TASKS.json', 'REVIEW.md', 'TEST.md', 'SUMMARY.md', 'RETRO.md', 'AUTHORITY.md']);
|
|
26
34
|
// 设计稿 §十二 第 3 步:并发写留痕(只记录、只告警,绝不阻断)。
|
|
27
35
|
import { createWriteTracer, formatConflict } from './write-tracer.js';
|
|
36
|
+
import { createEffortPreflight, effortPreflightPlan, declaredEffortsFromPresetSource, EFFORT_PROBE_DELAYS_MS } from './effort-preflight.js';
|
|
37
|
+
import { createArtifactRedirectWatcher } from './artifact-redirect-watch.js';
|
|
28
38
|
import { createLoopGuard } from './loop-guard.js';
|
|
29
39
|
// C 线第 17 项:派工即回写(派工成功后由**代码**把任务置 in_progress + owner,不靠模型记得改)。
|
|
30
40
|
import { createDispatchLedger } from './dispatch-ledger.js';
|
|
@@ -1059,6 +1069,46 @@ function scheduleOptionalPluginCheck(ctx, delays) {
|
|
|
1059
1069
|
}
|
|
1060
1070
|
return timers;
|
|
1061
1071
|
}
|
|
1072
|
+
// ── 会话模型 effort 预检(2026-09-15,D 项)────────────────────────────────────
|
|
1073
|
+
// 只告警、不阻断:宿主在**任何网络 I/O 之前**就会因"模型未声明 reasoningEfforts"拒绝带 effort 的
|
|
1074
|
+
// 角色派工(`dsh-llm` 的 resolveCallWithInfo)。插件改不了宿主行为,只能**提前说清** + 给出修法。
|
|
1075
|
+
// 纪律:读不到 ⇒ 静默;一次加载最多一行;服务晚挂则就绪后重探(同 OPTIONAL_* 那套)。
|
|
1076
|
+
let EFFORT_PREFLIGHT = null;
|
|
1077
|
+
let EFFORT_TIMERS = [];
|
|
1078
|
+
function scheduleEffortPreflight(ctx, delays) {
|
|
1079
|
+
const plan = Array.isArray(delays) ? delays : EFFORT_PROBE_DELAYS_MS;
|
|
1080
|
+
// 实例**按 ctx 创建**(不搞模块级 ctx 全局):读模型能力的入口在闭包里直接捕获 ctx。
|
|
1081
|
+
EFFORT_PREFLIGHT = createEffortPreflight({
|
|
1082
|
+
readPresetSource: async () => readFile(new URL('../presets/expert-team/agent.cordis.yml', import.meta.url), 'utf8'),
|
|
1083
|
+
readSelection: (c) => {
|
|
1084
|
+
const svc = c && typeof c.get === 'function' ? c.get('agentDefaultModel') : null;
|
|
1085
|
+
const sel = svc && typeof svc.currentSelection === 'function' ? svc.currentSelection() : null;
|
|
1086
|
+
return sel && sel.provider && sel.model ? { provider: String(sel.provider), model: String(sel.model) } : null;
|
|
1087
|
+
},
|
|
1088
|
+
readModelInfo: async (provider, model) => {
|
|
1089
|
+
const llm = ctx && typeof ctx.get === 'function' ? ctx.get('llm') : null;
|
|
1090
|
+
if (!llm || typeof llm.resolveModelInfo !== 'function') throw new Error('llm.resolveModelInfo 不可用');
|
|
1091
|
+
return llm.resolveModelInfo(provider, model);
|
|
1092
|
+
},
|
|
1093
|
+
onEvent: (type, payload) => {
|
|
1094
|
+
try { console.warn('[expert-team] ' + type, JSON.stringify(payload)); } catch { /* 观测失败不影响加载 */ }
|
|
1095
|
+
},
|
|
1096
|
+
});
|
|
1097
|
+
plan.forEach((ms, i) => {
|
|
1098
|
+
const t = setTimeout(() => { void EFFORT_PREFLIGHT(ctx, { isLast: i === plan.length - 1 }); }, ms);
|
|
1099
|
+
if (t && typeof t.unref === 'function') t.unref();
|
|
1100
|
+
EFFORT_TIMERS.push(t);
|
|
1101
|
+
});
|
|
1102
|
+
// 事件驱动兜底:llm / agentDefaultModel 任一晚挂,服务一出现就立刻重探(不出现则永不触发)。
|
|
1103
|
+
for (const svc of ['llm', 'agentDefaultModel']) {
|
|
1104
|
+
try { if (ctx && typeof ctx.inject === 'function') ctx.inject([svc], () => { void EFFORT_PREFLIGHT(ctx, { isLast: true }); }); } catch { /* best-effort */ }
|
|
1105
|
+
}
|
|
1106
|
+
return EFFORT_TIMERS;
|
|
1107
|
+
}
|
|
1108
|
+
function _resetEffortPreflight() {
|
|
1109
|
+
for (const t of EFFORT_TIMERS) { try { clearTimeout(t); } catch { /* ignore */ } }
|
|
1110
|
+
EFFORT_TIMERS = [];
|
|
1111
|
+
}
|
|
1062
1112
|
function _resetOptionalHintOnce() {
|
|
1063
1113
|
OPTIONAL_HINT_DONE = false;
|
|
1064
1114
|
for (const t of OPTIONAL_PROBE_TIMERS) { try { clearTimeout(t); } catch { /* ignore */ } }
|
|
@@ -1149,7 +1199,7 @@ async function scaffoldRun(cwd, mode) {
|
|
|
1149
1199
|
// 测试里的 14 项)**不是同一个集合**:那边多一个 `RUN.log.md`(由 run log 写入器创建)、这边就是
|
|
1150
1200
|
// 模板全集。两者关系由 `artifact-ownership.test.mjs` 的「工件清单一致」断言钉住,避免下次又被
|
|
1151
1201
|
// 当成同一件事去"对齐"(2026-09-15 阶段 C 的口径核查)。
|
|
1152
|
-
const templates =
|
|
1202
|
+
const templates = ARTIFACT_TEMPLATES;
|
|
1153
1203
|
for (const t of templates) {
|
|
1154
1204
|
try {
|
|
1155
1205
|
await ARTIFACT.must(join(runDir, t), await readFile(new URL(t, TEMPLATES_SRC)));
|
|
@@ -1685,6 +1735,44 @@ function deriveMemberEntries(subById, existing) {
|
|
|
1685
1735
|
}
|
|
1686
1736
|
return { entries: out, changed };
|
|
1687
1737
|
}
|
|
1738
|
+
/**
|
|
1739
|
+
* "这串东西像不像 session id"的判据 —— **按角色名精确排除**,不按长度猜。
|
|
1740
|
+
*
|
|
1741
|
+
* 为什么不用长度:可读性好的测试/宿主可能用短 id(`ended-x`、`live-1`),长度阈值会**误伤它们**
|
|
1742
|
+
* ⇒ 把真 id 过滤掉,那才是真丢数据。而"角色名"是可以精确判定的:
|
|
1743
|
+
* · 等于某个角色 id(`backend`)⇒ 排除;
|
|
1744
|
+
* · 首个 `-` 段就是角色 id(`frontend-F4` / `reviewer-R1`,角色名带轮次后缀)⇒ 排除。
|
|
1745
|
+
* 真实 session id 是 UUID(首段 8 位十六进制)或任意非角色名 ⇒ 保留。
|
|
1746
|
+
* 只用于"过滤出真 id",**不**用于判定归属(归属看 `STATE.members` 与 `artifact-ownership`)。
|
|
1747
|
+
*/
|
|
1748
|
+
const ROLE_NAME_SET = new Set(DEFAULT_ROLES.map((r) => String(r).toLowerCase()));
|
|
1749
|
+
export function isAgentIdLike(v) {
|
|
1750
|
+
const s = String(v || '').trim();
|
|
1751
|
+
if (!s || /\s/.test(s)) return false;
|
|
1752
|
+
const lower = s.toLowerCase();
|
|
1753
|
+
if (ROLE_NAME_SET.has(lower)) return false;
|
|
1754
|
+
const head = lower.split('-')[0];
|
|
1755
|
+
if (head && ROLE_NAME_SET.has(head)) return false;
|
|
1756
|
+
return true;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/**
|
|
1760
|
+
* `STATE.members` → **只含 agent id** 的清单。
|
|
1761
|
+
*
|
|
1762
|
+
* 为什么必须单独有它(2026-09-15 性能修复 #5,真机 profile 定位):`membersFromState().byRole`
|
|
1763
|
+
* 这个 Map **同时**塞了 `role→agentId` 与 `agentId→role` 两种键 ⇒ 直接取 `.values()` 会**混进角色名**
|
|
1764
|
+
* (`backend` / `reviewer` …)。而角色名永远不可能是某个会话 header 的 id ⇒
|
|
1765
|
+
* `missingIds` **永久非空** ⇒ `/state` 每次都认为"还有人查不到" ⇒ **每个请求都重新枚举
|
|
1766
|
+
* 475 个 artifact**(真机 subs 段实测 2.4 s),`SUB_HEADER_MEMO` 因此形同虚设。
|
|
1767
|
+
*
|
|
1768
|
+
* ⇒ 需要 id 的地方一律走这里,**不要**再对 `byRole` 取 `.values()`。
|
|
1769
|
+
*/
|
|
1770
|
+
export function memberAgentIds(members) {
|
|
1771
|
+
const { byRole } = membersFromState(members);
|
|
1772
|
+
const out = [];
|
|
1773
|
+
for (const k of byRole.keys()) if (isAgentIdLike(k)) out.push(k);
|
|
1774
|
+
return out;
|
|
1775
|
+
}
|
|
1688
1776
|
function membersFromState(members) {
|
|
1689
1777
|
const byRole = new Map(), names = new Map();
|
|
1690
1778
|
for (const item of Array.isArray(members) ? members : []) {
|
|
@@ -1713,6 +1801,9 @@ function buildRoleSubMap(subs, stateMembers, wfLabels) {
|
|
|
1713
1801
|
if (precise.byRole.size) {
|
|
1714
1802
|
const m = new Map();
|
|
1715
1803
|
for (const [role, id] of precise.byRole) {
|
|
1804
|
+
// ⚠️ byRole 是**双键** Map(role→id 与 id→role 都在)⇒ 这里必须跳过 id 形态的键,
|
|
1805
|
+
// 否则会把 "agentId→sub" 也当成一条角色映射塞进去(2026-09-15 与 subs 段同一根因)。
|
|
1806
|
+
if (isAgentIdLike(role)) continue;
|
|
1716
1807
|
const hit = subs.find((s) => String(s.id || '') === id);
|
|
1717
1808
|
if (hit) m.set(role, hit);
|
|
1718
1809
|
}
|
|
@@ -2819,24 +2910,123 @@ async function registeredWorkspaces() {
|
|
|
2819
2910
|
}
|
|
2820
2911
|
|
|
2821
2912
|
// List runs under a single workspace; each has a `workspace`.
|
|
2913
|
+
// ── run 列表的**逐 run 戳缓存**(2026-09-15 性能修复 #6:冷启动)────────────────────
|
|
2914
|
+
// 实测:重启后**第一次** `?section=summary` 的 `runs+select` 段 = 876 ms(每个 run 都要读
|
|
2915
|
+
// STATE.json + TASKS.json 再算 health/violations/owner)。而这段数据在"run 目录没动"时**完全可复用**。
|
|
2916
|
+
//
|
|
2917
|
+
// 失效键 = **每个 run 自己的** `STATE.json` 与 `TASKS.json` 的 `(mtimeMs, size)`:
|
|
2918
|
+
// · 改一次 STATE(阶段推进/任务回写)⇒ 该 run 的戳变 ⇒ 只重算**那一个** run;
|
|
2919
|
+
// · 新增 run ⇒ 缓存里没有 ⇒ 计算并写入(**新 run 一律可见**,这是功能不是可牺牲项);
|
|
2920
|
+
// · 删 run ⇒ readdir 里没有 ⇒ 不会被读出(顺带在保存时剪掉)。
|
|
2921
|
+
// ⚠️ 为什么**不能**只戳 run 目录:改文件**不会**改父目录 mtime(只有增删条目会)⇒ 那样会读到旧
|
|
2922
|
+
// 阶段/旧 done 数。戳到文件本身才是"看到的就是真的"。
|
|
2923
|
+
// 落盘($DSH_HOME/expert-team/runs-index.json)是为了**重启后第一次**也不必从零算:本进程只需
|
|
2924
|
+
// stat 校验 + 命中;没有索引文件的那一次仍要算一遍(这是它的性质,如实记档)。
|
|
2925
|
+
const RUNS_INDEX_MAX = 500;
|
|
2926
|
+
let RUNS_INDEX = null; // Map<absRunDir, {stamp, row}>
|
|
2927
|
+
let RUNS_INDEX_LOAD = null; // 载入中的 promise(同刻只读一次盘)
|
|
2928
|
+
let RUNS_INDEX_SAVE_TIMER = null;
|
|
2929
|
+
const RUNS_INDEX_STATS = { hits: 0, misses: 0, loads: 0, saves: 0, saveErrors: 0, computes: 0 };
|
|
2930
|
+
/**
|
|
2931
|
+
* 索引文件位置。可用 `DSH_EXPERT_TEAM_RUNS_INDEX` 覆盖 —— 两个用途:
|
|
2932
|
+
* ① 测试(沙箱里 `~/.dsh` 可能不可写,指到临时目录即可做完整往返验证);
|
|
2933
|
+
* ② 运维(把索引放到更快的盘/容器可写卷)。
|
|
2934
|
+
*/
|
|
2935
|
+
function runsIndexPath() {
|
|
2936
|
+
const override = String((typeof process !== 'undefined' && process.env && process.env.DSH_EXPERT_TEAM_RUNS_INDEX) || '').trim();
|
|
2937
|
+
return override || join(dshHome(), 'expert-team', 'runs-index.json');
|
|
2938
|
+
}
|
|
2939
|
+
async function loadRunsIndex() {
|
|
2940
|
+
if (RUNS_INDEX) return RUNS_INDEX;
|
|
2941
|
+
if (RUNS_INDEX_LOAD) return RUNS_INDEX_LOAD;
|
|
2942
|
+
RUNS_INDEX_STATS.loads += 1;
|
|
2943
|
+
RUNS_INDEX_LOAD = (async () => {
|
|
2944
|
+
let entries = {};
|
|
2945
|
+
try {
|
|
2946
|
+
const raw = await readFile(runsIndexPath(), 'utf8');
|
|
2947
|
+
const j = JSON.parse(raw);
|
|
2948
|
+
if (j && typeof j === 'object' && j.entries && typeof j.entries === 'object') entries = j.entries;
|
|
2949
|
+
} catch { /* 没有索引 / 坏了 ⇒ 空表(下次保存自愈)*/ }
|
|
2950
|
+
RUNS_INDEX = new Map(Object.entries(entries).filter(([, v]) => v && typeof v.stamp === 'string' && v.row && typeof v.row === 'object'));
|
|
2951
|
+
return RUNS_INDEX;
|
|
2952
|
+
})();
|
|
2953
|
+
try { return await RUNS_INDEX_LOAD; } finally { RUNS_INDEX_LOAD = null; }
|
|
2954
|
+
}
|
|
2955
|
+
function scheduleRunsIndexSave() {
|
|
2956
|
+
if (RUNS_INDEX_SAVE_TIMER) return;
|
|
2957
|
+
RUNS_INDEX_SAVE_TIMER = setTimeout(() => {
|
|
2958
|
+
RUNS_INDEX_SAVE_TIMER = null;
|
|
2959
|
+
(async () => {
|
|
2960
|
+
try {
|
|
2961
|
+
const map = RUNS_INDEX || new Map();
|
|
2962
|
+
// 剪枝:按 map 的插入顺序保留最近 RUNS_INDEX_MAX 条(Map 保序 ⇒ 旧的先丢)
|
|
2963
|
+
const entries = {};
|
|
2964
|
+
const keys = [...map.keys()];
|
|
2965
|
+
for (const k of keys.slice(Math.max(0, keys.length - RUNS_INDEX_MAX))) entries[k] = map.get(k);
|
|
2966
|
+
const tmp = runsIndexPath() + '.tmp';
|
|
2967
|
+
await mkdir(dirname(runsIndexPath()), { recursive: true });
|
|
2968
|
+
await writeFile(tmp, JSON.stringify({ version: 1, entries }), 'utf8');
|
|
2969
|
+
await rename(tmp, runsIndexPath());
|
|
2970
|
+
RUNS_INDEX_STATS.saves += 1;
|
|
2971
|
+
} catch { RUNS_INDEX_STATS.saveErrors += 1; /* 索引写不进去只是下次多算一遍,绝不影响功能 */ }
|
|
2972
|
+
})();
|
|
2973
|
+
}, 1500);
|
|
2974
|
+
if (RUNS_INDEX_SAVE_TIMER && typeof RUNS_INDEX_SAVE_TIMER.unref === 'function') RUNS_INDEX_SAVE_TIMER.unref();
|
|
2975
|
+
}
|
|
2976
|
+
/** 仅供测试:丢内存索引并复位计数(不清盘上的索引文件)。 */
|
|
2977
|
+
function _resetRunsIndex() {
|
|
2978
|
+
RUNS_INDEX = null; RUNS_INDEX_LOAD = null;
|
|
2979
|
+
if (RUNS_INDEX_SAVE_TIMER) { clearTimeout(RUNS_INDEX_SAVE_TIMER); RUNS_INDEX_SAVE_TIMER = null; }
|
|
2980
|
+
RUNS_INDEX_STATS.hits = 0; RUNS_INDEX_STATS.misses = 0; RUNS_INDEX_STATS.loads = 0;
|
|
2981
|
+
RUNS_INDEX_STATS.saves = 0; RUNS_INDEX_STATS.saveErrors = 0; RUNS_INDEX_STATS.computes = 0;
|
|
2982
|
+
}
|
|
2983
|
+
async function fileStampOf(p) {
|
|
2984
|
+
try { const st = await stat(p); return Math.round(Number(st.mtimeMs) || 0) + ':' + (Number(st.size) || 0); }
|
|
2985
|
+
catch { return null; }
|
|
2986
|
+
}
|
|
2987
|
+
/** 单个 run 的列表行:戳没变就复用缓存(落盘索引 ⇒ 重启后第一次也能命中)。 */
|
|
2988
|
+
async function cachedRunRow(ws, dir, n) {
|
|
2989
|
+
await loadRunsIndex();
|
|
2990
|
+
const statePath = join(dir, 'STATE.json');
|
|
2991
|
+
const stampState = await fileStampOf(statePath);
|
|
2992
|
+
const stampTasks = await fileStampOf(join(dir, 'TASKS.json'));
|
|
2993
|
+
const stamp = stampState + '|' + stampTasks;
|
|
2994
|
+
const rec = RUNS_INDEX.get(dir);
|
|
2995
|
+
// 缺 STATE.json 时(stampState === null)**不进缓存**:坏 run 每次如实重算,代价是一次失败的读。
|
|
2996
|
+
if (rec && stampState !== null && rec.stamp === stamp) { RUNS_INDEX_STATS.hits += 1; return { ...rec.row }; }
|
|
2997
|
+
RUNS_INDEX_STATS.misses += 1;
|
|
2998
|
+
const st0 = stampState === null ? null : await readJsonSafe(statePath);
|
|
2999
|
+
let row;
|
|
3000
|
+
if (st0) {
|
|
3001
|
+
const tasks0 = taskList(stampTasks === null ? null : await readJsonSafe(join(dir, 'TASKS.json')));
|
|
3002
|
+
const done0 = tasks0.filter((t) => ['completed', 'done'].includes(t.status)).length;
|
|
3003
|
+
const owners = new Set((tasks0.map((t) => t.owner)).filter(Boolean));
|
|
3004
|
+
const h0 = runHealth(st0);
|
|
3005
|
+
row = { runId: n, workspace: ws, phase: st0.phase, status: st0.status, updatedAt: st0.updatedAt, goal: '', done: done0, total: tasks0.length, members: owners.size, violations: checkTasks(tasks0, st0.phase, st0).length, health: h0.health, healthReason: h0.reason, ownerSession: String(st0.ownerSession || ''), ownerResolved: !!st0.ownerSession };
|
|
3006
|
+
} else {
|
|
3007
|
+
// B1:面板的 run 下拉过去同样静默跳过没有 STATE.json 的目录 —— 用户在界面上
|
|
3008
|
+
// **完全看不到**这些遗留/损坏的 run。现在如实列出并标 `health: 'broken'`。
|
|
3009
|
+
row = { runId: n, workspace: ws, phase: '', status: '', updatedAt: '', goal: '', done: 0, total: 0, members: 0, violations: 0, health: 'broken', healthReason: '缺 STATE.json(或不可解析)' };
|
|
3010
|
+
}
|
|
3011
|
+
if (stampState !== null) {
|
|
3012
|
+
RUNS_INDEX_STATS.computes += 1;
|
|
3013
|
+
RUNS_INDEX.set(dir, { stamp, row });
|
|
3014
|
+
scheduleRunsIndexSave();
|
|
3015
|
+
}
|
|
3016
|
+
return { ...row };
|
|
3017
|
+
}
|
|
2822
3018
|
async function listRunsInWorkspace(ws) {
|
|
2823
3019
|
const root0 = teamRoot(ws);
|
|
2824
|
-
let
|
|
2825
|
-
|
|
3020
|
+
let ents0 = [];
|
|
3021
|
+
// ⚠️ 只要**目录**:`team/` 根下还住着普通文件(`CODEINDEX.json`、`LEARNINGS.md`、`REPOWIKI.md`…),
|
|
3022
|
+
// 旧实现把它们也当 run 名去读 `<name>/STATE.json` ⇒ 必然失败 ⇒ 面板的 run 下拉里出现
|
|
3023
|
+
// 一串"broken run"(画布上真能看到这种条目)。run 是目录,这个判据才是对的。
|
|
3024
|
+
try { ents0 = await readdir(root0, { withFileTypes: true }); } catch { return []; }
|
|
2826
3025
|
const out0 = [];
|
|
2827
|
-
for (const
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
const done0 = tasks0.filter((t) => ['completed', 'done'].includes(t.status)).length;
|
|
2832
|
-
const owners = new Set((tasks0.map((t) => t.owner)).filter(Boolean));
|
|
2833
|
-
const h0 = runHealth(st0);
|
|
2834
|
-
out0.push({ runId: n, workspace: ws, phase: st0.phase, status: st0.status, updatedAt: st0.updatedAt, goal: '', done: done0, total: tasks0.length, members: owners.size, violations: checkTasks(tasks0, st0.phase, st0).length, health: h0.health, healthReason: h0.reason, ownerSession: String(st0.ownerSession || ''), ownerResolved: !!st0.ownerSession });
|
|
2835
|
-
} else {
|
|
2836
|
-
// B1:面板的 run 下拉过去同样静默跳过没有 STATE.json 的目录 —— 用户在界面上
|
|
2837
|
-
// **完全看不到**这些遗留/损坏的 run。现在如实列出并标 `health: 'broken'`。
|
|
2838
|
-
out0.push({ runId: n, workspace: ws, phase: '', status: '', updatedAt: '', goal: '', done: 0, total: 0, members: 0, violations: 0, health: 'broken', healthReason: '缺 STATE.json(或不可解析)' });
|
|
2839
|
-
}
|
|
3026
|
+
for (const ent of ents0) {
|
|
3027
|
+
if (!ent || ent.isDirectory?.() !== true) continue;
|
|
3028
|
+
const n = ent.name;
|
|
3029
|
+
out0.push(await cachedRunRow(ws, join(root0, n), n));
|
|
2840
3030
|
}
|
|
2841
3031
|
return out0;
|
|
2842
3032
|
}
|
|
@@ -3070,15 +3260,27 @@ function _resetListSessionsCache() { LIST_SESSIONS_CACHE = { at: 0, stamp: null,
|
|
|
3070
3260
|
// 旧实现每请求都要枚举 475 个 artifact 才能回答"这些人是何时建的、谁派工的"——
|
|
3071
3261
|
// 有了备忘,第二个请求起直接查表。
|
|
3072
3262
|
const SUB_HEADER_MEMO = new Map();
|
|
3073
|
-
|
|
3263
|
+
// `cut`(2026-09-15 有界化)如实记录这一段被截断/节流的原因,供调用方进 `degraded`:
|
|
3264
|
+
// '' = 未截断|'throttled' = 本轮在节流窗口内、未枚举|'deadline' = 枚举到期限被截断
|
|
3265
|
+
const SUB_HEADER_STATS = { memoHits: 0, memoWrites: 0, enumCalls: 0, cut: '' };
|
|
3074
3266
|
function _resetSubHeaderMemo() {
|
|
3075
3267
|
SUB_HEADER_MEMO.clear();
|
|
3076
3268
|
SUB_HEADER_STATS.memoHits = 0;
|
|
3077
3269
|
SUB_HEADER_STATS.memoWrites = 0;
|
|
3078
3270
|
SUB_HEADER_STATS.enumCalls = 0;
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3271
|
+
SUB_HEADER_STATS.cut = '';
|
|
3272
|
+
}
|
|
3273
|
+
|
|
3274
|
+
async function listSubagentStatusBySession(ctx, sid, knownIds, opts) {
|
|
3275
|
+
// 只认真 id:调用方若传进角色名(历史 bug,见 `memberAgentIds` 的注释),枚举分支会**永久**
|
|
3276
|
+
// 认为"还有人查不到" ⇒ 每请求重枚举。这里再兜一道,防止未来又有人把角色名传进来。
|
|
3277
|
+
const ids = (Array.isArray(knownIds) ? knownIds : []).filter(isAgentIdLike);
|
|
3278
|
+
// 有界化(2026-09-15):枚举整棵 sessions 树是最贵的一步(真机实测 2.4–3.6 s,且它是
|
|
3279
|
+
// **全库**扫描)。调用方可以(a)在节流窗口内**禁止枚举**、(b)给一个期限。两者都不命中时
|
|
3280
|
+
// 保持原行为(默认允许 + 无期限)。被挡住/截断时**不丢成员**:那些 id 只是没有 header ⇒
|
|
3281
|
+
// 既有口径已如实显示为"细节不可得"(`hasTimestamp:false`),不假装 0。
|
|
3282
|
+
const allowEnum = !(opts && opts.allowEnum === false);
|
|
3283
|
+
const enumDeadlineAt = (opts && Number.isFinite(opts.enumDeadlineAt)) ? opts.enumDeadlineAt : Infinity;
|
|
3082
3284
|
const root = sid ? await rootSessionId(ctx, sid) : '';
|
|
3083
3285
|
let rows = [];
|
|
3084
3286
|
if (root) {
|
|
@@ -3093,14 +3295,14 @@ async function listSubagentStatusBySession(ctx, sid, knownIds) {
|
|
|
3093
3295
|
// Cross-session enrich: the run's registered agent ids may belong to a
|
|
3094
3296
|
// DIFFERENT session (viewing run A from session B) — the live `agents`
|
|
3095
3297
|
// registry still reports their status/model keyed by session id.
|
|
3096
|
-
if (
|
|
3298
|
+
if (ids && ids.length) {
|
|
3097
3299
|
try {
|
|
3098
3300
|
const reg = (ctx && typeof ctx.get === 'function') ? ctx.get('agents') : null;
|
|
3099
3301
|
if (reg && typeof reg.list === 'function') {
|
|
3100
3302
|
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
3101
3303
|
for (const ag of reg.list() || []) {
|
|
3102
3304
|
const aid = ag?.session?.header?.id;
|
|
3103
|
-
if (!aid || byId.has(aid) || !
|
|
3305
|
+
if (!aid || byId.has(aid) || !ids.includes(aid)) continue;
|
|
3104
3306
|
byId.set(aid, { id: aid, mode: 'continuable', label: '', activity: ag.status === 'running' ? 'running' : 'idle', model: ag?.options?.model || '' });
|
|
3105
3307
|
}
|
|
3106
3308
|
rows = [...byId.values()];
|
|
@@ -3114,7 +3316,7 @@ async function listSubagentStatusBySession(ctx, sid, knownIds) {
|
|
|
3114
3316
|
// 第一轮:只有**真的缺人**才花这笔钱(旧实现无条件 listSessions());
|
|
3115
3317
|
// 第二轮:缺的那些**先查 `SUB_HEADER_MEMO`**(已结束会话的 header 不可变 ⇒ 永久备忘),
|
|
3116
3318
|
// 只有仍然未知的才去枚举 ⇒ 第二个请求起通常**零枚举**。
|
|
3117
|
-
const missingIds = (
|
|
3319
|
+
const missingIds = (ids || []).filter((id) => !rows.some((r) => r.id === id));
|
|
3118
3320
|
if (missingIds.length) {
|
|
3119
3321
|
const memoById = new Map(rows.map((r) => [r.id, r]));
|
|
3120
3322
|
const stillUnknown = [];
|
|
@@ -3129,15 +3331,18 @@ async function listSubagentStatusBySession(ctx, sid, knownIds) {
|
|
|
3129
3331
|
}
|
|
3130
3332
|
rows = [...memoById.values()];
|
|
3131
3333
|
if (stillUnknown.length) {
|
|
3132
|
-
|
|
3334
|
+
if (!allowEnum) {
|
|
3335
|
+
SUB_HEADER_STATS.cut = SUB_HEADER_STATS.cut || 'throttled';
|
|
3336
|
+
} else try {
|
|
3133
3337
|
const q = (ctx && typeof ctx.get === 'function') ? ctx.get('sessionQuery') : null;
|
|
3134
3338
|
if (q && typeof q.listSessions === 'function') {
|
|
3135
3339
|
const sessions = await cachedListSessions(q, await sessionsRootStamp());
|
|
3136
3340
|
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
3137
3341
|
for (const rec of sessions || []) {
|
|
3342
|
+
if (Date.now() > enumDeadlineAt) { SUB_HEADER_STATS.cut = 'deadline'; break; }
|
|
3138
3343
|
const h = rec?.header || rec;
|
|
3139
3344
|
const aid = h?.id;
|
|
3140
|
-
if (!aid || byId.has(aid) || !
|
|
3345
|
+
if (!aid || byId.has(aid) || !ids.includes(aid)) continue;
|
|
3141
3346
|
// ⚠️ header 里的 createdAt / parentSession / delegationDepth **必须带出来**。
|
|
3142
3347
|
// 旧实现只取 id,把它们丢掉,导致面板上每个成员都是 createdAt=0 ⇒ 流转图
|
|
3143
3348
|
// 判为「无创建时间记录,无法分批」,尽管会话日志第一行明明写着
|
|
@@ -3241,6 +3446,28 @@ const ROLE_READ_BUDGET_PER_REQUEST = 4;
|
|
|
3241
3446
|
let ROLE_READS_LEFT = ROLE_READ_BUDGET_PER_REQUEST;
|
|
3242
3447
|
/** 待解析队列(跨请求保留进度):元素是子会话 id。 */
|
|
3243
3448
|
let ROLE_PENDING = [];
|
|
3449
|
+
|
|
3450
|
+
// ── 有界化策略(2026-09-15 第三轮)─────────────────────────────────────────────
|
|
3451
|
+
// 真机基线(30+ 子代理的重会话):`people,feed` 单发 5.4–9.2 s,其中 subs 2.9–3.7 s、roles 2.4–5.5 s,
|
|
3452
|
+
// **两段都没有上限**。本轮的目标不是"更快",而是"**有界**":
|
|
3453
|
+
// · subs :枚举整棵 sessions 树很贵 ⇒ 两次之间至少 N 秒(默认 30 s),窗口内不枚举;
|
|
3454
|
+
// · roles :读 MB 级子会话日志很贵且**不紧急** ⇒ 实时路径默认**不读**,未知角色如实显示为
|
|
3455
|
+
// "待解析"(`rolesPending`),解析交给低频后台(每轮 ≤N 条、两次间隔 ≥N 秒);
|
|
3456
|
+
// 已解析结果**永久缓存**(已结束会话的角色不可变)⇒ pending 单调下降并收敛到 0。
|
|
3457
|
+
// 纪律不变:截断一律进 `degraded`;"还没解析"(pending)与"解析不出来"(unresolved)仍是两个数。
|
|
3458
|
+
const SUBS_ENUM_MIN_INTERVAL_MS = Math.max(0, Number((typeof process !== 'undefined' && process.env && process.env.DSH_EXPERT_TEAM_SUBS_ENUM_MIN_INTERVAL_MS) || 0) || 30000);
|
|
3459
|
+
const SUBS_ENUM_DEADLINE_MS = Math.max(50, Number((typeof process !== 'undefined' && process.env && process.env.DSH_EXPERT_TEAM_SUBS_DEADLINE_MS) || 0) || 800);
|
|
3460
|
+
const ROLES_READ_MIN_INTERVAL_MS = Math.max(0, Number((typeof process !== 'undefined' && process.env && process.env.DSH_EXPERT_TEAM_ROLES_MIN_INTERVAL_MS) || 0) || 30000);
|
|
3461
|
+
const ROLES_READ_PER_BURST = Math.max(1, Number((typeof process !== 'undefined' && process.env && process.env.DSH_EXPERT_TEAM_ROLES_PER_BURST) || 0) || 1);
|
|
3462
|
+
const ROLES_READ_DEADLINE_MS = Math.max(50, Number((typeof process !== 'undefined' && process.env && process.env.DSH_EXPERT_TEAM_ROLES_DEADLINE_MS) || 0) || 600);
|
|
3463
|
+
let SUBS_ENUM_LAST_AT = 0;
|
|
3464
|
+
let ROLES_READ_LAST_AT = 0;
|
|
3465
|
+
const ROLE_READ_STATS = { cut: '' };
|
|
3466
|
+
/** 本轮允许枚举吗(节流窗口)? */
|
|
3467
|
+
function subsEnumAllowed(now) { return (Number(now) - SUBS_ENUM_LAST_AT) >= SUBS_ENUM_MIN_INTERVAL_MS; }
|
|
3468
|
+
/** 本轮允许读几条角色日志(低频后台;0 = 如实显示"待解析")。 */
|
|
3469
|
+
function roleReadAllowance(now) { return (Number(now) - ROLES_READ_LAST_AT) >= ROLES_READ_MIN_INTERVAL_MS ? ROLES_READ_PER_BURST : 0; }
|
|
3470
|
+
function _resetBoundedState() { SUBS_ENUM_LAST_AT = 0; ROLES_READ_LAST_AT = 0; ROLE_READ_STATS.cut = ''; SUB_HEADER_STATS.cut = ''; }
|
|
3244
3471
|
/** /state 每次进来复位**本请求的读取额度**(队列进度**不**复位)。 */
|
|
3245
3472
|
function resetRoleReadBudget() { ROLE_READS_LEFT = ROLE_READ_BUDGET_PER_REQUEST; }
|
|
3246
3473
|
/**
|
|
@@ -3438,7 +3665,11 @@ async function workflowRuns(ctx, sid, subs) {
|
|
|
3438
3665
|
* 结果是**恰好反了**——能解析的全部留空,解析不出的那批反而被下一段子会话日志兜底填上。
|
|
3439
3666
|
* (2026-09-11 真实踩到:30 个子代理里只有 5 个有角色,正是 wfLabels 解析不出的那 5 个。)
|
|
3440
3667
|
*/
|
|
3441
|
-
async function resolveSubRoles(ctx, subs, wfLabels) {
|
|
3668
|
+
async function resolveSubRoles(ctx, subs, wfLabels, opts) {
|
|
3669
|
+
// 有界化:调用方决定本轮最多读几条(0 = 只查便宜来源与缓存,缺的如实显示为"待解析")
|
|
3670
|
+
// 以及墙上期限;不传则保持原行为(每请求 ROLE_READ_BUDGET_PER_REQUEST 条、无期限)。
|
|
3671
|
+
const maxReads = (opts && Number.isFinite(opts.maxReads)) ? opts.maxReads : ROLE_READ_BUDGET_PER_REQUEST;
|
|
3672
|
+
const deadlineAt = (opts && Number.isFinite(opts.deadlineAt)) ? opts.deadlineAt : Infinity;
|
|
3442
3673
|
const byId = new Map();
|
|
3443
3674
|
for (const s of subs || []) { const id = String((s && s.id) || ''); if (id) byId.set(id, s); }
|
|
3444
3675
|
// ① 便宜来源(自带 label / 父会话事件流 label)与已缓存结果 —— **不花预算、不读日志**
|
|
@@ -3458,14 +3689,18 @@ async function resolveSubRoles(ctx, subs, wfLabels) {
|
|
|
3458
3689
|
// 这样 `deferred`(队列长度)只减不增,除非真的又新派了人 —— 旧实现每请求从零重算,
|
|
3459
3690
|
// 预算永远喂给队首同几条,新派的人永远轮不到(实测 deferred 16 → 40)。
|
|
3460
3691
|
syncRolePending(subs);
|
|
3461
|
-
|
|
3692
|
+
let reads = 0;
|
|
3693
|
+
while (reads < maxReads && ROLE_READS_LEFT > 0 && ROLE_PENDING.length) {
|
|
3694
|
+
if (Date.now() > deadlineAt) { ROLE_READ_STATS.cut = 'deadline'; break; }
|
|
3462
3695
|
const id = ROLE_PENDING.shift();
|
|
3463
3696
|
if (SUB_ROLE_LOG_CACHE.has(id)) continue;
|
|
3464
3697
|
const r = await roleFromChildLog(ctx, id);
|
|
3465
3698
|
if (!SUB_ROLE_LOG_CACHE.has(id)) { ROLE_PENDING.unshift(id); break; } // 预算耗尽(未写缓存)⇒ 放回队首,下一轮继续
|
|
3466
3699
|
const s = byId.get(id);
|
|
3467
3700
|
if (s && r && !s.role) s.role = r;
|
|
3701
|
+
reads += 1;
|
|
3468
3702
|
}
|
|
3703
|
+
if (reads) ROLES_READ_LAST_AT = Date.now(); // 本轮流过日志 ⇒ 进入节流窗口(下次至少 N 秒后)
|
|
3469
3704
|
return subs;
|
|
3470
3705
|
}
|
|
3471
3706
|
// sub rows → Map<role, sub> (first match wins; unknown labels map to '' and
|
|
@@ -5039,7 +5274,7 @@ function warnLeadToolFaceOnce(status, detail) {
|
|
|
5039
5274
|
LEAD_TOOLFACE_WARNED.add(status);
|
|
5040
5275
|
console.warn(`[expert-team] lead 工具面**未**收窄(${status}):${detail}`);
|
|
5041
5276
|
}
|
|
5042
|
-
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, parseStateSections, 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 };
|
|
5277
|
+
export const _live = { pushActivity, phaseAccountingViolations, loggedPhases, authorityViolations, WRITE_TRACER, createWriteTracer, formatConflict, summarizeTool, parseLogLine, roleOfSub, mapRoleToSub, membersFromState, memberAgentIds, isAgentIdLike, 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, parseStateSections, listRunsInWorkspace, runsIndexPath, RUNS_INDEX_STATS, _resetRunsIndex, 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, scheduleEffortPreflight, _resetEffortPreflight, effortPreflightPlan, declaredEffortsFromPresetSource, 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, SUBS_ENUM_MIN_INTERVAL_MS, SUBS_ENUM_DEADLINE_MS, ROLES_READ_MIN_INTERVAL_MS, ROLES_READ_PER_BURST, ROLES_READ_DEADLINE_MS, subsEnumAllowed, roleReadAllowance, ROLE_READ_STATS, _resetBoundedState, ROLE_READ_LOG_CACHE: SUB_ROLE_LOG_CACHE };
|
|
5043
5278
|
|
|
5044
5279
|
export function apply(ctx, config) {
|
|
5045
5280
|
// 留一份 config:设置在运行时改变(官方面板 / 浮层)时要**用同一份 config** 重算上限与档位门,
|
|
@@ -5089,6 +5324,8 @@ export function apply(ctx, config) {
|
|
|
5089
5324
|
// 里还没有它们 ⇒ 已装且可用的插件会被误判成"已安装但当前未就绪"(2026-09-15 真实假警报)。
|
|
5090
5325
|
// 现在改为**就绪后重探**(有界延迟 + `ctx.inject` 事件驱动,见 scheduleOptionalPluginCheck)。
|
|
5091
5326
|
scheduleOptionalPluginCheck(ctx);
|
|
5327
|
+
// D 项:会话模型 effort 预检(缺 reasoningEfforts ⇒ 提前一行告警,不阻断)
|
|
5328
|
+
scheduleEffortPreflight(ctx);
|
|
5092
5329
|
void loadSessionRuns(); // session→run memory for overlay auto-select
|
|
5093
5330
|
ctx.commands.register({
|
|
5094
5331
|
name: 'team',
|
|
@@ -5191,6 +5428,23 @@ export function apply(ctx, config) {
|
|
|
5191
5428
|
ctx.on('tools/pre-execute', ownershipGate);
|
|
5192
5429
|
} catch { /* 门禁挂不上 ⇒ 退回纯协议;绝不让它影响插件加载或工具调用 */ }
|
|
5193
5430
|
|
|
5431
|
+
// ── R1 绕过检测(2026-09-15):**只报不拦**。R1 门禁只覆盖 write/edit;持 bash 的角色
|
|
5432
|
+
// 可以 `cat > SPEC.md` 绕过 —— 静默绕过违背本仓纪律,但静态判断 bash 写目标不可靠
|
|
5433
|
+
// (重定向/变量/子命令),一旦误判就是把正常命令判成越权。⇒ 折中:**留痕**,用真实命中
|
|
5434
|
+
// 频率决定以后是否收紧。口径与"宁可漏报"的理由写在 `lib/artifact-redirect-watch.js` 文件头。
|
|
5435
|
+
try {
|
|
5436
|
+
const knownArtifacts = new Set([...ARTIFACT_TEMPLATES, ...Object.keys(ARTIFACT_OWNERS)]);
|
|
5437
|
+
const redirectWatch = createArtifactRedirectWatcher({
|
|
5438
|
+
knownArtifacts,
|
|
5439
|
+
cwdFor: (exec) => cwdFromSession(ctx, exec?.agent?.session?.header?.id || exec?.agent?.session?.id),
|
|
5440
|
+
teamRootFor: (cwd) => teamRoot(cwd),
|
|
5441
|
+
onEvent: (type, payload) => {
|
|
5442
|
+
try { console.warn('[expert-team] ' + type, JSON.stringify(payload)); } catch { /* 观测失败不影响工具 */ }
|
|
5443
|
+
},
|
|
5444
|
+
});
|
|
5445
|
+
ctx.on('tools/post-execute', redirectWatch);
|
|
5446
|
+
} catch { /* 观测器挂不上 ⇒ 只是少一条留痕;绝不影响插件加载或工具调用 */ }
|
|
5447
|
+
|
|
5194
5448
|
// ── C 线第 16 项(收窄版):**振荡检测**。同工具重复调用由宿主
|
|
5195
5449
|
// `@deepseek-ai/dsh-repeat-tool-reminder` 负责(dsh-base 已启用,thresholds [3,5,8]),
|
|
5196
5450
|
// 不重写;这里只补它明确声明不覆盖的那一种 —— A→B→A→B 在两方案间来回。
|
|
@@ -5419,19 +5673,36 @@ export function apply(ctx, config) {
|
|
|
5419
5673
|
? { sid: sel.stateOwnerSession, ownerResolved: true }
|
|
5420
5674
|
: runOwnerSession(sel.runId);
|
|
5421
5675
|
let peopleSid = owner.sid || sid;
|
|
5422
|
-
|
|
5676
|
+
// ⚠️ **只能用 id**(`memberAgentIds`,不是 `byRole` 的 values 视图):后者混着角色名,
|
|
5677
|
+
// 会让"还有谁查不到"永久非空 ⇒ 每请求重枚举 475 个 artifact(2026-09-15 真机定位)。
|
|
5678
|
+
const knownIds = memberAgentIds(sel.stateMembers);
|
|
5423
5679
|
// 只有 people 分节才解析人员:这一步要枚举子会话 durable 清单(重会话里这是整条链上
|
|
5424
5680
|
// 最大的一块)。首屏用 summary 时不付这个成本 —— `agents` 返回空数组,
|
|
5425
5681
|
// 由客户端 `sections` 字段知道自己拿的是"摘要",UI 如实显示"正在加载成员"。
|
|
5426
5682
|
// `feed` 也必须先有 subs(它按子会话遍历活动缓存)。若只想要 feed 却跳过枚举,
|
|
5427
5683
|
// feed 会**静默变空** —— 那正是本仓最忌讳的"两种零分不清"(缺块 ≠ 空数据)。
|
|
5428
5684
|
const needSubs = want('people') || want('feed');
|
|
5429
|
-
|
|
5685
|
+
// 有界化:本轮是否允许枚举(节流窗口)+ 枚举期限;结果经 SUB_HEADER_STATS.cut 上报。
|
|
5686
|
+
const subsStartedAt = Date.now();
|
|
5687
|
+
const allowEnumNow = subsEnumAllowed(subsStartedAt);
|
|
5688
|
+
SUB_HEADER_STATS.cut = '';
|
|
5689
|
+
const subsOpts = { allowEnum: allowEnumNow, enumDeadlineAt: subsStartedAt + SUBS_ENUM_DEADLINE_MS };
|
|
5690
|
+
let subs = needSubs ? await listSubagentStatusBySession(ctx, peopleSid, knownIds, subsOpts) : [];
|
|
5430
5691
|
// 归属会话拿不到人时退回请求会话(否则面板整块空掉),但**如实标注**这不是归属会话的人。
|
|
5431
5692
|
if (needSubs && !subs.length && peopleSid !== sid) {
|
|
5432
|
-
const fallback = await listSubagentStatusBySession(ctx, sid, knownIds);
|
|
5693
|
+
const fallback = await listSubagentStatusBySession(ctx, sid, knownIds, subsOpts);
|
|
5433
5694
|
if (fallback.length) { subs = fallback; peopleSid = sid; }
|
|
5434
5695
|
}
|
|
5696
|
+
// 有界化上报:本轮流过枚举 ⇒ 进入节流窗口;被节流/被期限截断一律**如实**进 degraded
|
|
5697
|
+
// (缺块 ≠ 空数据:被挡住的 id 只是没有 header,成员本身不丢)。
|
|
5698
|
+
if (needSubs && allowEnumNow) SUBS_ENUM_LAST_AT = Date.now();
|
|
5699
|
+
// ⚠️ 噪声纪律:**节流不是失败**,不能每轮都进 `degraded`(节流窗口内每次轮询都会命中
|
|
5700
|
+
// ⇒ 面板会长期挂一个降级标记,把真告警一起降权)。"某些成员细节不可得"这一事实由
|
|
5701
|
+
// `subsPending` 诚实表达(与 `rolesPending` 对称);只有"开始枚举却被期限截断"算
|
|
5702
|
+
// **部分结果**,才进 `degraded`。
|
|
5703
|
+
const idsReal = (Array.isArray(knownIds) ? knownIds : []).filter(isAgentIdLike);
|
|
5704
|
+
const subsPending = needSubs ? idsReal.filter((id) => !subs.some((r) => r.id === id)).length : 0;
|
|
5705
|
+
if (needSubs && SUB_HEADER_STATS.cut === 'deadline') degraded.push('subs:deadline');
|
|
5435
5706
|
// 供面板如实提示:resolve=false 时人员名单可能来自错误的会话
|
|
5436
5707
|
sel.peopleSession = peopleSid;
|
|
5437
5708
|
sel.peopleSessionIsOwner = peopleSid === (owner.sid || '');
|
|
@@ -5456,13 +5727,21 @@ export function apply(ctx, config) {
|
|
|
5456
5727
|
// ① 只在 people 分节里做;② 硬上限 `MAX_ROLE_SUBS`(超限只解析前 N 条)。
|
|
5457
5728
|
// 被上限挡住的条数**如实进 `degraded`**;"还没解析"(rolesDeferred,队列长度)与
|
|
5458
5729
|
// "解析不出来"(unresolved)仍然分得开(两种零可区分)。
|
|
5730
|
+
// 有界化:角色解析移出实时路径 —— 默认**不读**(缺的如实显示为"待解析"),
|
|
5731
|
+
// 只在节流窗口到点后读 ≤ROLES_READ_PER_BURST 条,并给墙上期限。
|
|
5732
|
+
const rolesAllowance = roleReadAllowance(Date.now());
|
|
5733
|
+
const rolesOpts = { maxReads: rolesAllowance, deadlineAt: Date.now() + ROLES_READ_DEADLINE_MS };
|
|
5734
|
+
ROLE_READ_STATS.cut = '';
|
|
5459
5735
|
if (want('people')) {
|
|
5460
5736
|
if (subs.length > MAX_ROLE_SUBS) {
|
|
5461
|
-
await resolveSubRoles(ctx, subs.slice(0, MAX_ROLE_SUBS), wfLabels);
|
|
5737
|
+
await resolveSubRoles(ctx, subs.slice(0, MAX_ROLE_SUBS), wfLabels, rolesOpts);
|
|
5462
5738
|
degraded.push('roles:' + (subs.length - MAX_ROLE_SUBS));
|
|
5463
5739
|
} else {
|
|
5464
|
-
await resolveSubRoles(ctx, subs, wfLabels);
|
|
5740
|
+
await resolveSubRoles(ctx, subs, wfLabels, rolesOpts);
|
|
5465
5741
|
}
|
|
5742
|
+
// 期限截断如实上报;**节流不报 degraded** —— 它不是失败,而是"待解析"这一诚实
|
|
5743
|
+
// 状态,由 `rolesPending` 表达(否则每请求都刷 degraded,把告警变成噪声)。
|
|
5744
|
+
if (ROLE_READ_STATS.cut === 'deadline') degraded.push('roles:deadline');
|
|
5466
5745
|
}
|
|
5467
5746
|
mark('roles');
|
|
5468
5747
|
// R12 + R13 + R17(F-1 · P0 跨 run 成员串号):**显示与写盘都只吃过滤后的 `subById`**。
|
|
@@ -5663,11 +5942,11 @@ export function apply(ctx, config) {
|
|
|
5663
5942
|
persistSessionRuns();
|
|
5664
5943
|
mark('tail');
|
|
5665
5944
|
// profile 只在 DSH_EXPERT_TEAM_STATE_PROFILE=1 时出现(默认不含该字段,
|
|
5666
|
-
// 线上响应体不变);
|
|
5945
|
+
// 线上响应体不变);rolesPending = "**还没解析**"的子会话条数(与本轮是否节流无关),
|
|
5667
5946
|
// 让调用方能把"还没解析"与"解析不出来"分开(两种零可区分)。
|
|
5668
5947
|
const budget = roleReadBudgetSnapshot();
|
|
5669
5948
|
json(200, Object.assign(
|
|
5670
|
-
{ ok: true, runs, workspaces, cwd, agents: sel.agents,
|
|
5949
|
+
{ ok: true, runs, workspaces, cwd, agents: sel.agents, rolesPending: budget.deferred, subsPending },
|
|
5671
5950
|
sel,
|
|
5672
5951
|
// 新增字段(只在相关时出现,默认负载与老客户端保持不变):
|
|
5673
5952
|
// sections —— 显式要了分节时,告诉调用方"这份负载包含哪些块"(缺的块 ≠ 空数据,
|