@joekytc/dsh-swarm 0.5.0 → 0.5.1
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/client/ConfigSection.tsx +127 -18
- package/client/ConfigSelect.tsx +1 -0
- package/client/config-store.ts +6 -3
- package/client/config.css +89 -1
- package/cordis.patch.yml +10 -52
- package/lib/client.js +193 -35
- package/lib/config.d.ts +7 -0
- package/lib/config.js +7 -1
- package/lib/dispatcher/agent-runner.d.ts +4 -0
- package/lib/dispatcher/agent-runner.js +15 -4
- package/lib/dispatcher/model-candidates.d.ts +9 -3
- package/lib/dispatcher/model-candidates.js +27 -6
- package/lib/dispatcher/model-chain.d.ts +19 -0
- package/lib/dispatcher/model-chain.js +110 -0
- package/lib/dispatcher/session-events.d.ts +2 -0
- package/lib/dispatcher/session-events.js +13 -0
- package/lib/dispatcher/v-orchestrator.js +5 -2
- package/lib/domain/config-override.d.ts +20 -0
- package/lib/domain/config-override.js +46 -2
- package/lib/index.js +7 -0
- package/lib/routes/kanban-http.js +24 -3
- package/lib/services/config-provider.js +5 -0
- package/package.json +2 -2
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { buildModelCandidates } from './model-candidates.js';
|
|
2
|
+
import { agentPresetSelected } from './session-events.js';
|
|
3
|
+
const chainStates = new WeakMap();
|
|
4
|
+
export function registerModelChain(agent, state) {
|
|
5
|
+
chainStates.set(agent, state);
|
|
6
|
+
const session = agent.session;
|
|
7
|
+
if (session && typeof session === 'object')
|
|
8
|
+
chainStates.set(session, state);
|
|
9
|
+
}
|
|
10
|
+
export function isSwitchableModelFailure(failure) {
|
|
11
|
+
const code = String(failure.code ?? '').toLowerCase();
|
|
12
|
+
const msg = String(failure.message ?? '').toLowerCase();
|
|
13
|
+
if (code.includes('quota'))
|
|
14
|
+
return true;
|
|
15
|
+
if (failure.status === 402 || failure.status === 429)
|
|
16
|
+
return true;
|
|
17
|
+
if (/insufficient balance|quota|rate.?limit|\b429\b/.test(msg))
|
|
18
|
+
return true;
|
|
19
|
+
if (code === 'unknown_model' || code === 'unsupported_reasoning_effort')
|
|
20
|
+
return true;
|
|
21
|
+
if (code.includes('credential') || code.includes('login'))
|
|
22
|
+
return true;
|
|
23
|
+
if (msg.includes('adapter'))
|
|
24
|
+
return true;
|
|
25
|
+
if (msg.includes('missing_credential') || msg.includes('credentials') || msg.includes('login') || msg.includes('登录'))
|
|
26
|
+
return true;
|
|
27
|
+
if (msg.includes('provider') && (msg.includes('unavailable') || msg.includes('not') || msg.includes('fail')))
|
|
28
|
+
return true;
|
|
29
|
+
return msg.includes('model') && (msg.includes('unavailable') || msg.includes('not found') || msg.includes('no adapter'));
|
|
30
|
+
}
|
|
31
|
+
const ROLE_IDS = new Set(['v', 'p', 'w', 'd', 'pt', 'dt']);
|
|
32
|
+
function sessionEventsOf(session) {
|
|
33
|
+
const s = session;
|
|
34
|
+
if (typeof s?.snapshotEvents === 'function')
|
|
35
|
+
return s.snapshotEvents();
|
|
36
|
+
return s?.events ?? [];
|
|
37
|
+
}
|
|
38
|
+
function detectManualChain(agent, config) {
|
|
39
|
+
const session = agent.session;
|
|
40
|
+
const preset = agentPresetSelected(sessionEventsOf(session))
|
|
41
|
+
?? (session?.header?.agentPreset ?? null);
|
|
42
|
+
const role = typeof preset === 'string' && preset.startsWith('kanban-') ? preset.slice('kanban-'.length) : '';
|
|
43
|
+
let state = null;
|
|
44
|
+
if (ROLE_IDS.has(role)) {
|
|
45
|
+
const m = config.roles?.models?.[role];
|
|
46
|
+
if (m?.provider && m?.model) {
|
|
47
|
+
const candidates = buildModelCandidates(config, role);
|
|
48
|
+
if (candidates.length > 0)
|
|
49
|
+
state = { candidates, index: 0 };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
chainStates.set(agent, state);
|
|
53
|
+
return state;
|
|
54
|
+
}
|
|
55
|
+
function resolveState(agent, config) {
|
|
56
|
+
const session = agent.session;
|
|
57
|
+
for (const key of [agent, session]) {
|
|
58
|
+
if (key && typeof key === 'object' && chainStates.has(key))
|
|
59
|
+
return chainStates.get(key) ?? null;
|
|
60
|
+
}
|
|
61
|
+
return detectManualChain(agent, config);
|
|
62
|
+
}
|
|
63
|
+
export function installModelChainHooks(scope, deps) {
|
|
64
|
+
const scoped = scope;
|
|
65
|
+
const disposeRequest = scoped.on('agent/request', async (payload, next) => {
|
|
66
|
+
const resolved = (await next());
|
|
67
|
+
const agent = payload.agent;
|
|
68
|
+
if (!agent || typeof agent !== 'object')
|
|
69
|
+
return resolved;
|
|
70
|
+
const state = resolveState(agent, deps.getConfig());
|
|
71
|
+
const cur = state?.candidates[state.index];
|
|
72
|
+
if (!cur)
|
|
73
|
+
return resolved;
|
|
74
|
+
return { ...resolved, provider: cur.provider, model: cur.model, reasoningEffort: cur.reasoningEffort ?? 'high' };
|
|
75
|
+
});
|
|
76
|
+
const disposeError = scoped.on('agent/request-error', async (payload, next) => {
|
|
77
|
+
const p = payload;
|
|
78
|
+
const agent = p.agent;
|
|
79
|
+
if (!agent || typeof agent !== 'object')
|
|
80
|
+
return next();
|
|
81
|
+
const state = resolveState(agent, deps.getConfig());
|
|
82
|
+
if (!state)
|
|
83
|
+
return next();
|
|
84
|
+
if (!isSwitchableModelFailure(p.failure ?? {}))
|
|
85
|
+
return next();
|
|
86
|
+
const nextIndex = state.index + 1;
|
|
87
|
+
if (nextIndex >= state.candidates.length)
|
|
88
|
+
return next();
|
|
89
|
+
const prev = state.candidates[state.index];
|
|
90
|
+
state.index = nextIndex;
|
|
91
|
+
const cur = state.candidates[nextIndex];
|
|
92
|
+
const detail = String(p.failure?.code ?? '') + ' ' + String(p.failure?.message ?? '');
|
|
93
|
+
const line = '[model-fallback] ' + prev.provider + '/' + prev.model + ' → ' + cur.provider + '/' + cur.model
|
|
94
|
+
+ '(reasoningEffort=' + (cur.reasoningEffort ?? 'high') + '): ' + detail;
|
|
95
|
+
console.error('[dsh-swarm][debug] ' + line);
|
|
96
|
+
deps.log?.(line);
|
|
97
|
+
if (state.taskId && state.comment) {
|
|
98
|
+
try {
|
|
99
|
+
await state.comment('[model-fallback] 主模型不可用,静默切换 ' + cur.provider + '/' + cur.model
|
|
100
|
+
+ '(reasoningEffort=' + (cur.reasoningEffort ?? 'high') + ')');
|
|
101
|
+
}
|
|
102
|
+
catch { /* 证据记录失败不影响切换 */ }
|
|
103
|
+
}
|
|
104
|
+
return { kind: 'retry' };
|
|
105
|
+
});
|
|
106
|
+
return () => {
|
|
107
|
+
disposeRequest();
|
|
108
|
+
disposeError();
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -48,6 +48,8 @@ export interface TurnEndInfo {
|
|
|
48
48
|
message: string | null;
|
|
49
49
|
}
|
|
50
50
|
export declare function turnEndOf(e: unknown): TurnEndInfo | null;
|
|
51
|
+
/** 会话内最后一次 agent-preset/selected 的 preset id(如 'kanban-w');无该事件返回 null。 */
|
|
52
|
+
export declare function agentPresetSelected(events: ReadonlyArray<unknown>): string | null;
|
|
51
53
|
/** 本轮增量(seq > fromSeq)内最后一轮 turn/end——拒答重试会产生第二轮,取 seq 最大者。
|
|
52
54
|
* 无 turn/end(测试桩/宿主变体)→ null,调用方按「无轮信息」回退原判据,不改变既有行为。 */
|
|
53
55
|
export declare function lastTurnEnd(events: ReadonlyArray<unknown>, fromSeq: number): TurnEndInfo | null;
|
|
@@ -86,6 +86,19 @@ export function turnEndOf(e) {
|
|
|
86
86
|
message: typeof reason?.error?.message === 'string' ? reason.error.message : null,
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
|
+
/** 会话内最后一次 agent-preset/selected 的 preset id(如 'kanban-w');无该事件返回 null。 */
|
|
90
|
+
export function agentPresetSelected(events) {
|
|
91
|
+
let found = null;
|
|
92
|
+
for (const e of events) {
|
|
93
|
+
if (eventType(e) !== 'agent-preset/selected')
|
|
94
|
+
continue;
|
|
95
|
+
const rec = e;
|
|
96
|
+
const p = rec?.agentPreset ?? rec?.data?.agentPreset;
|
|
97
|
+
if (typeof p === 'string')
|
|
98
|
+
found = p;
|
|
99
|
+
}
|
|
100
|
+
return found;
|
|
101
|
+
}
|
|
89
102
|
/** 本轮增量(seq > fromSeq)内最后一轮 turn/end——拒答重试会产生第二轮,取 seq 最大者。
|
|
90
103
|
* 无 turn/end(测试桩/宿主变体)→ null,调用方按「无轮信息」回退原判据,不改变既有行为。 */
|
|
91
104
|
export function lastTurnEnd(events, fromSeq) {
|
|
@@ -6,6 +6,7 @@ import { missingParentDelivery } from '../domain/delivery-contract.js';
|
|
|
6
6
|
import { buildRepoSlug } from '../domain/memory.js';
|
|
7
7
|
import { toolArgs, toolName, replayModel } from './session-events.js';
|
|
8
8
|
import { buildModelCandidates, isModelUnavailableError } from './model-candidates.js';
|
|
9
|
+
import { registerModelChain } from './model-chain.js';
|
|
9
10
|
import { attachSessionToWorkspace, resolveOrCreateWorkspace } from './workspace-attach.js';
|
|
10
11
|
export const R20_PHASE_ORDER = ['p', 'pt', 'w2', 'd', 'dt', 'w3', 'summary'];
|
|
11
12
|
/** 每 phase 的期望建卡(pt 由 P 交付 pt_decision.needed=true 触发;dt 固定)。 */
|
|
@@ -697,9 +698,11 @@ export class VOrchestrator {
|
|
|
697
698
|
if (candidates.length === 0)
|
|
698
699
|
return spawnWith({});
|
|
699
700
|
let lastErr = null;
|
|
700
|
-
for (const candidate of candidates) {
|
|
701
|
+
for (const [candidateIndex, candidate] of candidates.entries()) {
|
|
701
702
|
try {
|
|
702
|
-
|
|
703
|
+
const spawned = await spawnWith({ agentOptions: candidate });
|
|
704
|
+
registerModelChain(spawned, { candidates, index: candidateIndex });
|
|
705
|
+
return spawned;
|
|
703
706
|
}
|
|
704
707
|
catch (err) {
|
|
705
708
|
lastErr = err;
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import type { KanbanConfig } from '../config.js';
|
|
2
2
|
import type { Role } from './types.js';
|
|
3
3
|
export declare const ROLES: readonly Role[];
|
|
4
|
+
export interface ModelFallbackInput {
|
|
5
|
+
provider?: string;
|
|
6
|
+
model?: string;
|
|
7
|
+
reasoningEffort?: string;
|
|
8
|
+
}
|
|
4
9
|
export interface EditableModelInput {
|
|
5
10
|
provider?: string;
|
|
6
11
|
model?: string;
|
|
7
12
|
reasoningEffort?: string;
|
|
13
|
+
fallbacks?: ModelFallbackInput[];
|
|
8
14
|
}
|
|
9
15
|
export interface EditableOverride {
|
|
10
16
|
wikiVault?: {
|
|
@@ -25,10 +31,16 @@ export interface EditableOverride {
|
|
|
25
31
|
fallbackBotId?: string;
|
|
26
32
|
};
|
|
27
33
|
}
|
|
34
|
+
export interface ModelFallbackSnapshot {
|
|
35
|
+
provider: string;
|
|
36
|
+
model: string;
|
|
37
|
+
reasoningEffort: string;
|
|
38
|
+
}
|
|
28
39
|
export interface EditableModelSnapshot {
|
|
29
40
|
provider: string;
|
|
30
41
|
model: string;
|
|
31
42
|
reasoningEffort: string;
|
|
43
|
+
fallbacks: ModelFallbackSnapshot[];
|
|
32
44
|
}
|
|
33
45
|
export interface EditableSnapshot {
|
|
34
46
|
wikiVault: {
|
|
@@ -38,6 +50,12 @@ export interface EditableSnapshot {
|
|
|
38
50
|
roles: {
|
|
39
51
|
models: Partial<Record<Role, EditableModelSnapshot>>;
|
|
40
52
|
};
|
|
53
|
+
/** 官方兜底(全局)——只读投影:编辑入口在部署配置(bundle patch),面板仅展示链尾。 */
|
|
54
|
+
chainFallback: {
|
|
55
|
+
provider: string;
|
|
56
|
+
model: string;
|
|
57
|
+
reasoningEffort: string;
|
|
58
|
+
};
|
|
41
59
|
reviewEngine: {
|
|
42
60
|
mode: 'delegate' | 'managed';
|
|
43
61
|
managed: {
|
|
@@ -51,6 +69,8 @@ export interface EditableSnapshot {
|
|
|
51
69
|
}
|
|
52
70
|
export type ConfigSource = 'override' | 'inherited';
|
|
53
71
|
export type SourceMap = Record<string, ConfigSource>;
|
|
72
|
+
/** 单角色降级候选上限(与 config schema .max(2)、GUI 上限同口径)。 */
|
|
73
|
+
export declare const MAX_FALLBACKS = 2;
|
|
54
74
|
export declare function mergeConfig(baseline: KanbanConfig, override: EditableOverride | undefined): KanbanConfig;
|
|
55
75
|
export declare function computeSources(override: EditableOverride | undefined): SourceMap;
|
|
56
76
|
export declare function projectEditable(effective: KanbanConfig): EditableSnapshot;
|
|
@@ -2,6 +2,18 @@ export const ROLES = ['v', 'p', 'w', 'd', 'pt', 'dt'];
|
|
|
2
2
|
/** botId 形态(与 dsh-im 的 id 校验同口径):非空时只允许 id 安全字符。 */
|
|
3
3
|
const BOT_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
4
4
|
const MODEL_FIELDS = ['provider', 'model', 'reasoningEffort'];
|
|
5
|
+
/** 单角色降级候选上限(与 config schema .max(2)、GUI 上限同口径)。 */
|
|
6
|
+
export const MAX_FALLBACKS = 2;
|
|
7
|
+
/** 降级候选归一化:全空行丢弃(GUI 占位行)、effort 空白补 'high'、部分填写行保留(交校验报错)。 */
|
|
8
|
+
function normalizeFallbacks(list) {
|
|
9
|
+
return (list ?? [])
|
|
10
|
+
.filter((f) => (f?.provider ?? '').trim() || (f?.model ?? '').trim())
|
|
11
|
+
.map((f) => ({
|
|
12
|
+
provider: (f.provider ?? '').trim(),
|
|
13
|
+
model: (f.model ?? '').trim(),
|
|
14
|
+
reasoningEffort: f.reasoningEffort?.trim() ? f.reasoningEffort : 'high',
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
5
17
|
export function mergeConfig(baseline, override) {
|
|
6
18
|
const wiki = { ...baseline.wikiVault };
|
|
7
19
|
if (override?.wikiVault?.baseUrl !== undefined)
|
|
@@ -32,12 +44,15 @@ export function mergeConfig(baseline, override) {
|
|
|
32
44
|
// 防御:空字符串 reasoningEffort 视为"未设置",永不覆盖 baseline/默认(读点回退 'high')。
|
|
33
45
|
if (over.reasoningEffort !== undefined && over.reasoningEffort.trim() !== '')
|
|
34
46
|
cur.reasoningEffort = over.reasoningEffort;
|
|
47
|
+
if (over.fallbacks !== undefined)
|
|
48
|
+
cur.fallbacks = normalizeFallbacks(over.fallbacks);
|
|
35
49
|
if (cur.provider && cur.model)
|
|
36
50
|
models[role] = cur;
|
|
37
51
|
}
|
|
38
52
|
const imDelivery = { ...baseline.imDelivery };
|
|
39
53
|
if (override?.imDelivery?.fallbackBotId !== undefined)
|
|
40
54
|
imDelivery.fallbackBotId = override.imDelivery.fallbackBotId;
|
|
55
|
+
// chainFallback 只属 baseline(部署配置),override 不触碰。
|
|
41
56
|
return { ...baseline, wikiVault: wiki, roles: { ...baseline.roles, models }, reviewEngine, imDelivery };
|
|
42
57
|
}
|
|
43
58
|
export function computeSources(override) {
|
|
@@ -51,6 +66,7 @@ export function computeSources(override) {
|
|
|
51
66
|
const hit = override?.roles?.models?.[role]?.[f] !== undefined;
|
|
52
67
|
src[`roles.models.${role}.${f}`] = hit ? 'override' : 'inherited';
|
|
53
68
|
}
|
|
69
|
+
src[`roles.models.${role}.fallbacks`] = override?.roles?.models?.[role]?.fallbacks !== undefined ? 'override' : 'inherited';
|
|
54
70
|
}
|
|
55
71
|
src['reviewEngine.mode'] = override?.reviewEngine?.mode !== undefined ? 'override' : 'inherited';
|
|
56
72
|
src['reviewEngine.managed.provider'] = override?.reviewEngine?.managed?.provider !== undefined ? 'override' : 'inherited';
|
|
@@ -62,12 +78,21 @@ export function projectEditable(effective) {
|
|
|
62
78
|
const models = {};
|
|
63
79
|
for (const role of ROLES) {
|
|
64
80
|
const m = effective.roles.models?.[role];
|
|
65
|
-
if (m?.provider && m?.model)
|
|
66
|
-
models[role] = {
|
|
81
|
+
if (m?.provider && m?.model) {
|
|
82
|
+
models[role] = {
|
|
83
|
+
provider: m.provider, model: m.model, reasoningEffort: m.reasoningEffort ?? 'high',
|
|
84
|
+
fallbacks: normalizeFallbacks(m.fallbacks),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
67
87
|
}
|
|
88
|
+
const cf = effective.roles.chainFallback;
|
|
68
89
|
return {
|
|
69
90
|
wikiVault: { baseUrl: effective.wikiVault.baseUrl, pagePrefix: effective.wikiVault.pagePrefix },
|
|
70
91
|
roles: { models },
|
|
92
|
+
chainFallback: {
|
|
93
|
+
provider: cf?.provider ?? '', model: cf?.model ?? '',
|
|
94
|
+
reasoningEffort: cf?.reasoningEffort?.trim() ? cf.reasoningEffort : 'high',
|
|
95
|
+
},
|
|
71
96
|
reviewEngine: {
|
|
72
97
|
mode: effective.reviewEngine?.mode ?? 'delegate',
|
|
73
98
|
managed: { provider: effective.reviewEngine?.managed?.provider ?? '', model: effective.reviewEngine?.managed?.model ?? '' },
|
|
@@ -95,6 +120,18 @@ export function validateConfig(snapshot) {
|
|
|
95
120
|
errs.push(`roles.models.${role}.provider`);
|
|
96
121
|
if (!m.model?.trim())
|
|
97
122
|
errs.push(`roles.models.${role}.model`);
|
|
123
|
+
const fbs = m.fallbacks ?? [];
|
|
124
|
+
if (fbs.length > MAX_FALLBACKS)
|
|
125
|
+
errs.push(`roles.models.${role}.fallbacks`);
|
|
126
|
+
fbs.forEach((f, i) => {
|
|
127
|
+
const p = (f?.provider ?? '').trim();
|
|
128
|
+
const mm = (f?.model ?? '').trim();
|
|
129
|
+
// 全空行 = GUI 占位(合法);部分填写 = 未完成编辑,报错阻断。
|
|
130
|
+
if (!p && !mm)
|
|
131
|
+
return;
|
|
132
|
+
if (!p || !mm)
|
|
133
|
+
errs.push(`roles.models.${role}.fallbacks.${i}`);
|
|
134
|
+
});
|
|
98
135
|
}
|
|
99
136
|
// 默认机器人:留空=未设默认(合法,语义为「未命中就交互」);非空必须形如 dsh-im 的 botId。
|
|
100
137
|
const fb = (snapshot.imDelivery?.fallbackBotId ?? '').trim();
|
|
@@ -124,6 +161,13 @@ export function diffOverride(baseline, snapshot) {
|
|
|
124
161
|
const snapEffort = snap?.reasoningEffort?.trim() ? snap.reasoningEffort : undefined;
|
|
125
162
|
if (snapEffort !== undefined && snapEffort !== (base?.reasoningEffort ?? 'high'))
|
|
126
163
|
cur.reasoningEffort = snapEffort;
|
|
164
|
+
// 降级链按归一化后整体比较:变化才整组写入。缺字段=显式空链(清空语义);
|
|
165
|
+
// 旧客户端兼容由 HTTP 保存口回填 effective 链,不在此特判。
|
|
166
|
+
if (snap) {
|
|
167
|
+
const snapFbs = normalizeFallbacks(snap.fallbacks);
|
|
168
|
+
if (JSON.stringify(snapFbs) !== JSON.stringify(normalizeFallbacks(base?.fallbacks)))
|
|
169
|
+
cur.fallbacks = snapFbs;
|
|
170
|
+
}
|
|
127
171
|
if (Object.keys(cur).length)
|
|
128
172
|
models[role] = cur;
|
|
129
173
|
}
|
package/lib/index.js
CHANGED
|
@@ -6,6 +6,8 @@ import { installRolePresets } from './roles/preset-installer.js';
|
|
|
6
6
|
import { installLlWikiSkill } from './roles/skill-installer.js';
|
|
7
7
|
import { registerKanbanHttp } from './routes/kanban-http.js';
|
|
8
8
|
import { startDispatcher } from './dispatcher/dispatcher.js';
|
|
9
|
+
import { installModelChainHooks } from './dispatcher/model-chain.js';
|
|
10
|
+
import { logToDispatcherLog } from './dispatcher/agent-runner.js';
|
|
9
11
|
import { ConfigProvider } from './services/config-provider.js';
|
|
10
12
|
import { wireImDelivery } from './services/im-delivery.js';
|
|
11
13
|
export const name = 'dsh-swarm';
|
|
@@ -47,6 +49,11 @@ export function apply(ctx, config) {
|
|
|
47
49
|
// imDelivery.enabled=false(默认)时事件处理器零开销早退;dshIm 服务缺失显式降级留痕。
|
|
48
50
|
const disposeImDelivery = wireImDelivery(ctx, provider.service, configProvider);
|
|
49
51
|
ctx.on('dispose', disposeImDelivery);
|
|
52
|
+
const disposeModelChain = installModelChainHooks(ctx, {
|
|
53
|
+
getConfig: () => configProvider.getEffective(),
|
|
54
|
+
log: (line) => logToDispatcherLog(configProvider, line),
|
|
55
|
+
});
|
|
56
|
+
ctx.on('dispose', disposeModelChain);
|
|
50
57
|
// LLM 运行时延迟取用:registerKanbanHttp 内部消费(llm-catalog 枚举),接线时 llm 服务已就绪。
|
|
51
58
|
const llm = () => ctx.get('llm');
|
|
52
59
|
// 可选服务接线均延迟到服务可用后:
|
|
@@ -309,12 +309,27 @@ export function registerKanbanHttp(ctx, provider, configProvider, llm, config, o
|
|
|
309
309
|
if (req.method === 'PUT' && req.url?.startsWith('/kanban/config')) {
|
|
310
310
|
const raw = JSON.parse((await readBody(req)) || '{}');
|
|
311
311
|
// 归一化缺省字段,保证缺字段走 400 校验失败而非 500。
|
|
312
|
-
// reviewEngine/imDelivery 缺省时回退当前 effective(再兜底默认):旧客户端
|
|
312
|
+
// reviewEngine/imDelivery/chainFallback 缺省时回退当前 effective(再兜底默认):旧客户端
|
|
313
|
+
// bundle 不带这些字段时保存不清空既有配置;chainFallback 属部署配置只读,原样回填。
|
|
313
314
|
const cur = configProvider.getEffective();
|
|
314
315
|
const fb = cur.reviewEngine;
|
|
316
|
+
const cfb = cur.roles.chainFallback;
|
|
315
317
|
const snapshot = {
|
|
316
318
|
wikiVault: { baseUrl: raw.wikiVault?.baseUrl ?? '', pagePrefix: raw.wikiVault?.pagePrefix ?? '' },
|
|
317
|
-
roles: {
|
|
319
|
+
roles: {
|
|
320
|
+
models: Object.fromEntries(Object.entries(raw.roles?.models ?? {}).map(([k, m]) => [
|
|
321
|
+
k,
|
|
322
|
+
// 旧客户端 bundle 不带 fallbacks 字段:回填当前 effective 链,避免保存时误清降级链。
|
|
323
|
+
m.fallbacks === undefined
|
|
324
|
+
? { ...m, fallbacks: cur.roles.models?.[k]?.fallbacks ?? [] }
|
|
325
|
+
: m,
|
|
326
|
+
])),
|
|
327
|
+
},
|
|
328
|
+
chainFallback: {
|
|
329
|
+
provider: raw.chainFallback?.provider ?? cfb?.provider ?? '',
|
|
330
|
+
model: raw.chainFallback?.model ?? cfb?.model ?? '',
|
|
331
|
+
reasoningEffort: raw.chainFallback?.reasoningEffort ?? cfb?.reasoningEffort ?? 'high',
|
|
332
|
+
},
|
|
318
333
|
reviewEngine: {
|
|
319
334
|
mode: raw.reviewEngine?.mode ?? fb?.mode ?? 'delegate',
|
|
320
335
|
managed: {
|
|
@@ -331,8 +346,14 @@ export function registerKanbanHttp(ctx, provider, configProvider, llm, config, o
|
|
|
331
346
|
const toProbe = [];
|
|
332
347
|
for (const role of ROLES) {
|
|
333
348
|
const m = snapshot.roles.models[role];
|
|
334
|
-
if (m?.provider?.trim() && m?.model?.trim())
|
|
349
|
+
if (m?.provider?.trim() && m?.model?.trim()) {
|
|
335
350
|
toProbe.push({ provider: m.provider, model: m.model, reasoningEffort: m.reasoningEffort });
|
|
351
|
+
// 降级候选同口径预检:死组合保存时拦下,不流入派发。
|
|
352
|
+
for (const f of m.fallbacks ?? []) {
|
|
353
|
+
if (f.provider?.trim() && f.model?.trim())
|
|
354
|
+
toProbe.push({ provider: f.provider, model: f.model, reasoningEffort: f.reasoningEffort });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
336
357
|
}
|
|
337
358
|
if (snapshot.reviewEngine.mode === 'managed' && snapshot.reviewEngine.managed.provider.trim() && snapshot.reviewEngine.managed.model.trim()) {
|
|
338
359
|
toProbe.push({ provider: snapshot.reviewEngine.managed.provider, model: snapshot.reviewEngine.managed.model });
|
|
@@ -80,6 +80,11 @@ export class ConfigProvider extends Service {
|
|
|
80
80
|
if (a !== b)
|
|
81
81
|
keys.add(`roles.models.${role}.${f}`);
|
|
82
82
|
}
|
|
83
|
+
// 降级链整组审计(实例必不同,按内容比较)。
|
|
84
|
+
const af = prev.roles?.models?.[role]?.fallbacks;
|
|
85
|
+
const bf = next.roles?.models?.[role]?.fallbacks;
|
|
86
|
+
if (JSON.stringify(af ?? []) !== JSON.stringify(bf ?? []))
|
|
87
|
+
keys.add(`roles.models.${role}.fallbacks`);
|
|
83
88
|
}
|
|
84
89
|
if (prev.reviewEngine?.mode !== next.reviewEngine?.mode)
|
|
85
90
|
keys.add('reviewEngine.mode');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joekytc/dsh-swarm",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "A governed swarm of six specialist DSH agents (orchestrator, planner, knowledge-base bridge, developer and two reviewers) that turns a requirement into a strict phase pipeline with machine-verified delivery evidence, review-gated merges, a full audit-log event stream and a live kanban tab; design inspired by the Hermes Agent kanban",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "joekytc",
|
|
@@ -96,4 +96,4 @@
|
|
|
96
96
|
"typescript": "^5.8.0",
|
|
97
97
|
"vitest": "^3.0.0"
|
|
98
98
|
}
|
|
99
|
-
}
|
|
99
|
+
}
|