@ran-sh/dsh-crew 1.3.2 → 1.5.0
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/.claude-plugin/plugin.json +1 -1
- package/lib/client.js +502 -12
- package/package.json +1 -1
- package/src/client/history-panel.tsx +4 -4
- package/src/client/index.tsx +159 -7
- package/src/history/cleanup-plan.mjs +30 -5
- package/src/history/runner-detach.mjs +34 -0
- package/src/history/runtime.mjs +9 -2
- package/src/history/service.mjs +13 -2
- package/src/hub/index.mjs +55 -0
- package/src/install/install-legacy.mjs +8 -0
- package/src/install/install.mjs +13 -5
- package/src/jobs.mjs +24 -2
- package/src/model-routing.mjs +250 -147
- package/src/model-schedule.mjs +207 -0
- package/src/policy.mjs +69 -62
- package/src/runtime-identity.mjs +1 -1
- package/src/server.mjs +6 -1
- package/src/session-origins.mjs +60 -0
package/src/client/index.tsx
CHANGED
|
@@ -18,6 +18,11 @@ import { CREW_UI_SURFACES, classifyCrewSurface, surfaceResponsibilities } from '
|
|
|
18
18
|
import { aggregateModelInvocations } from './task-telemetry.mjs';
|
|
19
19
|
import { PanelHeader, PanelStyles } from './panel-chrome';
|
|
20
20
|
import { HistoryPanel } from './history-panel';
|
|
21
|
+
// The schedule contract is shared, not mirrored: model-schedule.mjs imports
|
|
22
|
+
// nothing, so the panel can use the same normalization and peak test the router
|
|
23
|
+
// does. A hand-maintained copy could accept less than the server and then write
|
|
24
|
+
// the difference back on save.
|
|
25
|
+
import { normalizeModelSchedule, isPeakAt } from '../model-schedule.mjs';
|
|
21
26
|
|
|
22
27
|
export const inject = ['slots', 'locale'];
|
|
23
28
|
|
|
@@ -42,6 +47,7 @@ function normalizeAdaptive(value: any): AdaptiveConfig {
|
|
|
42
47
|
};
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
|
|
45
51
|
const COPY = {
|
|
46
52
|
zh: {
|
|
47
53
|
label: 'DSH Crew',
|
|
@@ -57,7 +63,7 @@ const COPY = {
|
|
|
57
63
|
expandAll: '全部展开', collapseAll: '全部折叠',
|
|
58
64
|
modelCount: (count: number) => `${count} 个模型`, providerCount: (count: number) => `${count} 个 Provider`,
|
|
59
65
|
jobCount: (count: number) => `${count} 个任务`, runningCount: (count: number) => `${count} 个运行中`,
|
|
60
|
-
sectionNames: { integrations: 'Codex / Claude / ZCode 集成状态', workflow: 'Crew 工作流设置', flash: 'Worker / Flash', pro: 'Reviewer / Pro', dispatch: '模型优先级与派发', adaptive: '自适应路由', runtime: '运行 / 生效边界', multimodal: '视觉与生图', harnessProviders: 'Harness Providers', providers: '多模态适配器', jobs: '任务状态' },
|
|
66
|
+
sectionNames: { integrations: 'Codex / Claude / ZCode 集成状态', workflow: 'Crew 工作流设置', flash: 'Worker / Flash', pro: 'Reviewer / Pro', dispatch: '模型优先级与派发', schedule: '波峰波谷调度', adaptive: '自适应路由', runtime: '运行 / 生效边界', multimodal: '视觉与生图', harnessProviders: 'Harness Providers', providers: '多模态适配器', jobs: '任务状态' },
|
|
61
67
|
harnessHint: 'DeepSeek 官方 Harness 的 dsh-crew profile:Provider、Harness Models 与运行时配置',
|
|
62
68
|
hostReadiness: '宿主集成就绪度', hostReadinessHint: '只使用结构化安装与运行时证据;缺少证据不会显示 READY。',
|
|
63
69
|
readinessLabels: { codex_mcp: 'Codex MCP', ds_worker: 'ds-worker', ds_reviewer: 'ds-reviewer', claude_plugin: 'Claude plugin', zcode_mcp: 'ZCode MCP', crew_harness: 'Crew plugin profile', official_bridge: 'Official bridge' },
|
|
@@ -163,8 +169,14 @@ const COPY = {
|
|
|
163
169
|
save: '保存', saved: '已保存', jobs: 'Worker 任务', empty: '当前没有 Worker / Reviewer 任务。',
|
|
164
170
|
adaptiveTitle: '自适应模型路由(实验)', adaptiveHint: '默认关闭;只重排系统自动候选,手动模型优先级始终保持原序。健康信号仅来自本进程已观察到的成功、失败、超时与粗粒度延迟,重启后清空。',
|
|
165
171
|
adaptiveEnabled: '启用自适应路由', adaptiveWindow: '健康窗口', adaptiveSamples: '最少样本', adaptiveBoundary: '下一个工作流生效',
|
|
166
|
-
|
|
167
|
-
|
|
172
|
+
scheduleTitle: '波峰波谷调度', scheduleHint: '按本地时间限制指定模型:波峰时被“阻止”的模型会被跳过,由下一个候选接替任务;“提醒”的仍会使用并在选择轨迹中标记。未列出的模型不受限制。默认时段为 DeepSeek 公布的波峰时间(工作日 09:00-12:00、14:00-18:00)。',
|
|
173
|
+
scheduleTimezone: '时区(相对 UTC)', scheduleWindows: '波峰时段', schedulePeak: '现在处于波峰', scheduleOffPeak: '现在处于波谷',
|
|
174
|
+
scheduleAddWindow: '添加时段', scheduleStart: '开始', scheduleEnd: '结束',
|
|
175
|
+
scheduleBlock: '阻止', scheduleWarn: '提醒', scheduleOff: '关闭',
|
|
176
|
+
scheduleRestricted: '受限模型', scheduleNoRestricted: '还没有受限模型;未列出的模型不受时间限制。',
|
|
177
|
+
scheduleWeekdays: '生效工作日', scheduleBoundary: '下次派发生效',
|
|
178
|
+
modelActivity: '模型活动概览', modelActivityHint: '聚合本 Hub 内存中最近 500 个含真实调用证据的任务;每个模型按任务计数(不是推测底层轮次),最多显示 50 个模型。不保存提示词、结果或凭据。', noModelActivity: '还没有真实模型活动。',
|
|
179
|
+
calls: '任务数', invocationSource: '任务来源', routingSource: '路由来源', lastCalled: '最近调用', role: '角色', model: '模型', never: '—',
|
|
168
180
|
col: { id: '任务', role: '角色', source: '来源', model: '模型', tier: '档位', status: '状态', progress: '进度', tokens: 'tokens ⇅', task: '内容' },
|
|
169
181
|
working: '处理中…',
|
|
170
182
|
tips: {
|
|
@@ -199,7 +211,7 @@ const COPY = {
|
|
|
199
211
|
expandAll: 'Expand all', collapseAll: 'Collapse all',
|
|
200
212
|
modelCount: (count: number) => `${count} models`, providerCount: (count: number) => `${count} providers`,
|
|
201
213
|
jobCount: (count: number) => `${count} jobs`, runningCount: (count: number) => `${count} running`,
|
|
202
|
-
sectionNames: { integrations: 'Codex / Claude / ZCode integration status', workflow: 'Crew workflow settings', flash: 'Worker / Flash', pro: 'Reviewer / Pro', dispatch: 'Model priority & dispatch', adaptive: 'Adaptive routing', runtime: 'Runtime / activation boundaries', multimodal: 'Vision & image generation', harnessProviders: 'Harness Providers', providers: 'Multimodal adapters', jobs: 'Task status' },
|
|
214
|
+
sectionNames: { integrations: 'Codex / Claude / ZCode integration status', workflow: 'Crew workflow settings', flash: 'Worker / Flash', pro: 'Reviewer / Pro', dispatch: 'Model priority & dispatch', schedule: 'Peak / off-peak scheduling', adaptive: 'Adaptive routing', runtime: 'Runtime / activation boundaries', multimodal: 'Vision & image generation', harnessProviders: 'Harness Providers', providers: 'Multimodal adapters', jobs: 'Task status' },
|
|
203
215
|
harnessHint: 'The official DeepSeek Harness dsh-crew profile: providers, Harness Models, and runtime configuration',
|
|
204
216
|
hostReadiness: 'Host integration readiness', hostReadinessHint: 'Uses structured installer and runtime evidence only; missing evidence is never READY.',
|
|
205
217
|
readinessLabels: { codex_mcp: 'Codex MCP', ds_worker: 'ds-worker', ds_reviewer: 'ds-reviewer', claude_plugin: 'Claude plugin', zcode_mcp: 'ZCode MCP', crew_harness: 'Crew plugin profile', official_bridge: 'Official bridge' },
|
|
@@ -305,8 +317,14 @@ const COPY = {
|
|
|
305
317
|
save: 'Save', saved: 'Saved', jobs: 'Worker jobs', empty: 'No Worker / Reviewer jobs yet.',
|
|
306
318
|
adaptiveTitle: 'Adaptive model routing (experimental)', adaptiveHint: 'Off by default. Only system-derived candidates may be reordered; explicit model priorities always keep their order. Health uses only process-local success, failure, timeout, and coarse latency observations and resets on restart.',
|
|
307
319
|
adaptiveEnabled: 'Enable adaptive routing', adaptiveWindow: 'Health window', adaptiveSamples: 'Minimum samples', adaptiveBoundary: 'Effective for the next workflow',
|
|
308
|
-
|
|
309
|
-
|
|
320
|
+
scheduleTitle: 'Peak / off-peak scheduling', scheduleHint: 'Restricts chosen models by local wall clock. During peak, a "block" model is skipped and the next candidate serves the job; a "warn" model is still used and flagged in the selection trace. Models not listed are unrestricted. Defaults are DeepSeek\'s published peak hours (weekdays 09:00-12:00 and 14:00-18:00).',
|
|
321
|
+
scheduleTimezone: 'Timezone (relative to UTC)', scheduleWindows: 'Peak windows', schedulePeak: 'Peak hours now', scheduleOffPeak: 'Off-peak now',
|
|
322
|
+
scheduleAddWindow: 'Add window', scheduleStart: 'Start', scheduleEnd: 'End',
|
|
323
|
+
scheduleBlock: 'Block', scheduleWarn: 'Warn', scheduleOff: 'Off',
|
|
324
|
+
scheduleRestricted: 'Restricted models', scheduleNoRestricted: 'No restricted models yet; unlisted models have no time limit.',
|
|
325
|
+
scheduleWeekdays: 'Days the windows apply', scheduleBoundary: 'Effective for the next dispatch',
|
|
326
|
+
modelActivity: 'Model activity overview', modelActivityHint: 'Aggregates the latest 500 in-memory Hub jobs with real model-activity evidence; each model is counted once per qualifying job (not an inferred turn count). Shows at most 50 models; prompts, results, and credentials are never stored here.', noModelActivity: 'No real model activity yet.',
|
|
327
|
+
calls: 'Jobs', invocationSource: 'Task source', routingSource: 'Routing source', lastCalled: 'Last called', role: 'Role', model: 'Model', never: '—',
|
|
310
328
|
col: { id: 'job', role: 'role', source: 'source', model: 'model', tier: 'tier', status: 'status', progress: 'progress', tokens: 'tokens ⇅', task: 'task' },
|
|
311
329
|
working: 'Working…',
|
|
312
330
|
tips: {
|
|
@@ -1121,6 +1139,59 @@ function WorkersPanel({ ctx }: { ctx: any }) {
|
|
|
1121
1139
|
const { next, ordering } = setAdaptiveLocal(candidate);
|
|
1122
1140
|
void applyPatch({ worker: { model_policy: { adaptive: next, ordering } } });
|
|
1123
1141
|
};
|
|
1142
|
+
// ---- per-model peak/off-peak scheduling ----
|
|
1143
|
+
// The schedule replaces as a whole (windows and model modes only mean anything
|
|
1144
|
+
// together), so every edit saves the complete object.
|
|
1145
|
+
const schedule = normalizeModelSchedule(config?.model_schedule);
|
|
1146
|
+
const saveSchedule = (candidate: any) => {
|
|
1147
|
+
const next = normalizeModelSchedule(candidate);
|
|
1148
|
+
setConfig((current: any) => ({ ...current, model_schedule: next }));
|
|
1149
|
+
void applyPatch({ model_schedule: next });
|
|
1150
|
+
};
|
|
1151
|
+
const schedulePeakNow = isPeakAt(schedule);
|
|
1152
|
+
const scheduleModeOf = (ref: any) => schedule.models.find(
|
|
1153
|
+
(entry: any) => entry.provider === ref.provider && entry.model === ref.model,
|
|
1154
|
+
)?.mode ?? 'off';
|
|
1155
|
+
const setScheduleMode = (ref: any, mode: string) => {
|
|
1156
|
+
const others = schedule.models.filter(
|
|
1157
|
+
(entry: any) => !(entry.provider === ref.provider && entry.model === ref.model),
|
|
1158
|
+
);
|
|
1159
|
+
saveSchedule({ ...schedule, models: mode === 'off' ? others : [...others, { ...ref, mode }] });
|
|
1160
|
+
};
|
|
1161
|
+
// Every model the router can actually select, not just what the flat tier
|
|
1162
|
+
// mirrors happen to expose. The Pro mirror collapses worker-escalation and
|
|
1163
|
+
// reviewer priority into one list — it shows whichever is set and hides the
|
|
1164
|
+
// other — so a reviewer-only model would be routable yet impossible to
|
|
1165
|
+
// restrict. Harness Default and the catalog are included for the same reason:
|
|
1166
|
+
// routing can land on a model that is in no priority list at all.
|
|
1167
|
+
const scheduleModelRefs = (() => {
|
|
1168
|
+
const seen = new Map<string, any>();
|
|
1169
|
+
const add = (ref: any) => {
|
|
1170
|
+
if (ref?.provider && ref?.model) seen.set(`${ref.provider}\0${ref.model}`, { provider: ref.provider, model: ref.model });
|
|
1171
|
+
};
|
|
1172
|
+
for (const ref of config?.flash_model_priority ?? []) add(ref);
|
|
1173
|
+
for (const ref of config?.pro_model_priority ?? []) add(ref);
|
|
1174
|
+
for (const role of ['worker', 'review'] as const) {
|
|
1175
|
+
const policy = config?.[role]?.model_policy;
|
|
1176
|
+
for (const ref of policy?.priority ?? []) add(ref);
|
|
1177
|
+
for (const ref of policy?.escalation_priority ?? []) add(ref);
|
|
1178
|
+
}
|
|
1179
|
+
add(modelCatalog?.harness_default);
|
|
1180
|
+
for (const provider of modelCatalog?.providers ?? []) {
|
|
1181
|
+
for (const model of provider?.models ?? []) add({ provider: provider.id, model: model?.id });
|
|
1182
|
+
}
|
|
1183
|
+
for (const entry of schedule.models) add(entry);
|
|
1184
|
+
return [...seen.values()];
|
|
1185
|
+
})();
|
|
1186
|
+
const scheduleWeekdayNames = locale === 'zh'
|
|
1187
|
+
? ['日', '一', '二', '三', '四', '五', '六']
|
|
1188
|
+
: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
1189
|
+
const toggleScheduleWeekday = (day: number) => {
|
|
1190
|
+
const days = schedule.weekdays.includes(day)
|
|
1191
|
+
? schedule.weekdays.filter((value: number) => value !== day)
|
|
1192
|
+
: [...schedule.weekdays, day].sort((a: number, b: number) => a - b);
|
|
1193
|
+
saveSchedule({ ...schedule, weekdays: days });
|
|
1194
|
+
};
|
|
1124
1195
|
const modelActivity = aggregateModelInvocations(jobs);
|
|
1125
1196
|
const modelState = modelCallabilityState(readinessSnapshot?.model_callability, runtimeInfo);
|
|
1126
1197
|
const currentSurfaceResponsibilities = surfaceResponsibilities(surface);
|
|
@@ -1466,6 +1537,87 @@ function WorkersPanel({ ctx }: { ctx: any }) {
|
|
|
1466
1537
|
</>))}
|
|
1467
1538
|
</CollapsibleSection>
|
|
1468
1539
|
|
|
1540
|
+
<CollapsibleSection sectionId="schedule" title={copy.sectionNames.schedule}
|
|
1541
|
+
summary={sectionSummary(schedulePeakNow ? copy.schedulePeak : copy.scheduleOffPeak,
|
|
1542
|
+
`UTC${schedule.timezone_offset_minutes >= 0 ? '+' : '-'}${Math.abs(schedule.timezone_offset_minutes) / 60}`,
|
|
1543
|
+
config?.model_schedule === undefined ? copy.scheduleOff : `${schedule.models.length} · ${copy.scheduleRestricted}`)}
|
|
1544
|
+
expanded={!!expandedSections.schedule} onToggle={() => toggleSection('schedule')}>
|
|
1545
|
+
{block({ t: copy.scheduleTitle, d: copy.scheduleHint }, (<>
|
|
1546
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.scheduleTimezone}</span>
|
|
1547
|
+
<CustomSelect value={String(schedule.timezone_offset_minutes)}
|
|
1548
|
+
onChange={(v) => saveSchedule({ ...schedule, timezone_offset_minutes: Number(v) })}
|
|
1549
|
+
options={(() => {
|
|
1550
|
+
// Real-world fixed offsets, not just whole hours: a stored
|
|
1551
|
+
// minute-level offset with no matching option would render as
|
|
1552
|
+
// a different value than it holds.
|
|
1553
|
+
const offsets = new Set<number>();
|
|
1554
|
+
for (let h = -14; h <= 14; h++) offsets.add(h * 60);
|
|
1555
|
+
for (const minutes of [330, 345, 570, -210, -570]) offsets.add(minutes);
|
|
1556
|
+
offsets.add(schedule.timezone_offset_minutes);
|
|
1557
|
+
return [...offsets].sort((a, b) => a - b).map((minutes) => ({
|
|
1558
|
+
value: String(minutes),
|
|
1559
|
+
label: `UTC${minutes >= 0 ? '+' : '-'}${Math.floor(Math.abs(minutes) / 60)}`
|
|
1560
|
+
+ (Math.abs(minutes) % 60 ? `:${String(Math.abs(minutes) % 60).padStart(2, '0')}` : ''),
|
|
1561
|
+
}));
|
|
1562
|
+
})()} /></label>
|
|
1563
|
+
<div style={{ gridColumn: '1 / -1' }}>
|
|
1564
|
+
<span style={{ ...S.fieldLabel, display: 'block', marginBottom: 5 }}>{copy.scheduleWeekdays}</span>
|
|
1565
|
+
<div style={{ display: 'flex', gap: 5, flexWrap: 'wrap' }}>
|
|
1566
|
+
{scheduleWeekdayNames.map((name: string, day: number) => (
|
|
1567
|
+
<button key={day} type="button"
|
|
1568
|
+
style={{ ...S.btn, padding: '3px 9px', opacity: schedule.weekdays.includes(day) ? 1 : 0.45 }}
|
|
1569
|
+
onClick={() => toggleScheduleWeekday(day)}>{name}</button>
|
|
1570
|
+
))}
|
|
1571
|
+
</div>
|
|
1572
|
+
</div>
|
|
1573
|
+
<div style={{ gridColumn: '1 / -1' }}>
|
|
1574
|
+
<span style={{ ...S.fieldLabel, display: 'block', marginBottom: 5 }}>{copy.scheduleWindows}</span>
|
|
1575
|
+
<div style={{ display: 'flex', flexDirection: 'column' as const, gap: 6 }}>
|
|
1576
|
+
{schedule.peak_windows.map((window: any, index: number) => (
|
|
1577
|
+
<div key={`${window.start}-${window.end}-${index}`} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
|
1578
|
+
<input type="time" value={window.start} style={S.input}
|
|
1579
|
+
onChange={(event) => {
|
|
1580
|
+
const next = schedule.peak_windows.map((row: any, i: number) => i === index ? { ...row, start: event.target.value } : row);
|
|
1581
|
+
saveSchedule({ ...schedule, peak_windows: next });
|
|
1582
|
+
}} />
|
|
1583
|
+
<span style={{ opacity: 0.6 }}>–</span>
|
|
1584
|
+
<input type="time" value={window.end} style={S.input}
|
|
1585
|
+
onChange={(event) => {
|
|
1586
|
+
const next = schedule.peak_windows.map((row: any, i: number) => i === index ? { ...row, end: event.target.value } : row);
|
|
1587
|
+
saveSchedule({ ...schedule, peak_windows: next });
|
|
1588
|
+
}} />
|
|
1589
|
+
<button type="button" style={{ ...S.btn, padding: '2px 8px' }}
|
|
1590
|
+
onClick={() => saveSchedule({ ...schedule, peak_windows: schedule.peak_windows.filter((_: any, i: number) => i !== index) })}>×</button>
|
|
1591
|
+
</div>
|
|
1592
|
+
))}
|
|
1593
|
+
<button type="button" style={{ ...S.btn, alignSelf: 'flex-start' }}
|
|
1594
|
+
onClick={() => saveSchedule({ ...schedule, peak_windows: [...schedule.peak_windows, { start: '09:00', end: '12:00' }] })}>
|
|
1595
|
+
{copy.scheduleAddWindow}</button>
|
|
1596
|
+
</div>
|
|
1597
|
+
</div>
|
|
1598
|
+
<div style={{ gridColumn: '1 / -1' }}>
|
|
1599
|
+
<span style={{ ...S.fieldLabel, display: 'block', marginBottom: 5 }}>{copy.scheduleRestricted}</span>
|
|
1600
|
+
{scheduleModelRefs.length === 0
|
|
1601
|
+
? <span style={{ fontSize: 11.5, opacity: 0.6 }}>{copy.scheduleNoRestricted}</span>
|
|
1602
|
+
: <div style={{ display: 'flex', flexDirection: 'column' as const, gap: 5 }}>
|
|
1603
|
+
{scheduleModelRefs.map((ref: any) => (
|
|
1604
|
+
<div key={`${ref.provider}\0${ref.model}`} style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '5px 8px', border: '1px solid rgba(128,128,128,0.2)', borderRadius: 6 }}>
|
|
1605
|
+
<span style={{ flex: 1, minWidth: 0, fontSize: 11, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis' }}>{ref.provider} / {ref.model}</span>
|
|
1606
|
+
<CustomSelect value={scheduleModeOf(ref)}
|
|
1607
|
+
onChange={(v) => setScheduleMode(ref, v)}
|
|
1608
|
+
options={[
|
|
1609
|
+
{ value: 'off', label: copy.scheduleOff },
|
|
1610
|
+
{ value: 'warn', label: copy.scheduleWarn },
|
|
1611
|
+
{ value: 'block', label: copy.scheduleBlock },
|
|
1612
|
+
]} />
|
|
1613
|
+
</div>
|
|
1614
|
+
))}
|
|
1615
|
+
</div>}
|
|
1616
|
+
<span style={{ display: 'block', fontSize: 11, opacity: 0.55, marginTop: 5 }}>{copy.scheduleBoundary}</span>
|
|
1617
|
+
</div>
|
|
1618
|
+
</>))}
|
|
1619
|
+
</CollapsibleSection>
|
|
1620
|
+
|
|
1469
1621
|
<CollapsibleSection sectionId="runtime" title={copy.sectionNames.runtime}
|
|
1470
1622
|
summary={sectionSummary(config.mode, `${config.default_timeout_seconds}s`, config.hub_url)}
|
|
1471
1623
|
expanded={!!expandedSections.runtime} onToggle={() => toggleSection('runtime')}>
|
|
@@ -1838,7 +1990,7 @@ function WorkersPanel({ ctx }: { ctx: any }) {
|
|
|
1838
1990
|
<div role="row" key={`${entry.provider}/${entry.model}`} style={{ display: 'grid', gridTemplateColumns: 'minmax(190px, 1.35fr) 72px minmax(170px, 1fr) 125px', gap: 10, alignItems: 'center', padding: '7px 6px', borderTop: '1px solid rgba(128,128,128,0.16)' }}>
|
|
1839
1991
|
<span role="cell" style={{ minWidth: 0 }}><strong style={{ display: 'block', fontSize: 12 }}>{entry.model}</strong><span style={{ ...S.mono, opacity: 0.55 }}>{entry.provider}</span></span>
|
|
1840
1992
|
<span role="cell" style={{ ...S.mono, fontSize: 15, fontWeight: 650 }}>{entry.count}</span>
|
|
1841
|
-
<span role="cell" style={{ minWidth: 0, fontSize: 11.5 }}><span style={{ display: 'block' }}>{entry.task_sources.map(sourceLabel).join(' · ')}</span><span style={{ display: 'block', opacity: 0.55 }}>{entry.selection_sources.join(' · ')} · {entry.roles.length > 0 ? entry.roles.join(' / ') : copy.never}</span></span>
|
|
1993
|
+
<span role="cell" style={{ minWidth: 0, fontSize: 11.5 }}><span style={{ display: 'block' }}>{entry.task_sources.map(sourceLabel).join(' · ')}</span><span style={{ display: 'block', opacity: 0.55 }}>{entry.selection_sources.join(' · ')} · {entry.roles.length > 0 ? entry.roles.join(' / ') : copy.never}</span></span>
|
|
1842
1994
|
<span role="cell" style={{ ...S.mono, fontSize: 10.5 }}>{formatTimestamp(entry.last_called_at, locale)}</span>
|
|
1843
1995
|
</div>
|
|
1844
1996
|
))}
|
|
@@ -28,18 +28,42 @@ function records(values) {
|
|
|
28
28
|
return [...values].sort((a, b) => a.id.localeCompare(b.id, 'en'));
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Pure preview only. No filesystem or project-path operations are performed.
|
|
33
|
+
*
|
|
34
|
+
* `scope` defaults to `crew`: a caller that forgets to narrow the range gets the
|
|
35
|
+
* one range that cannot remove sessions the operator opened themselves. Widening
|
|
36
|
+
* to `all` is always an explicit choice.
|
|
37
|
+
*/
|
|
38
|
+
export function planHistoryCleanup(snapshot, { operation = 'archive', scope = 'crew', before } = {}) {
|
|
39
|
+
if (!['archive', 'delete'].includes(operation) || !['all', 'crew', 'worktree', 'before'].includes(scope)) throw new Error('HISTORY_INVALID_OPTIONS');
|
|
34
40
|
const cutoff = scope === 'before' ? instant(before) : null;
|
|
35
41
|
if (scope === 'before' && (typeof before !== 'string' || cutoff === null)) throw new Error('HISTORY_INVALID_CUTOFF');
|
|
36
42
|
const workspaces = records(snapshot.workspaces);
|
|
37
43
|
const sessions = records(snapshot.sessions);
|
|
38
44
|
const active = ids(snapshot.activeSessionIds);
|
|
39
|
-
|
|
45
|
+
// `crew` and `worktree` narrow by provenance, never by project path: a session
|
|
46
|
+
// is Crew's own only when Crew recorded creating it, and worktree scope adds
|
|
47
|
+
// the isolated-workspace marker the hub stamps at dispatch.
|
|
48
|
+
const origin = new Set(Array.isArray(snapshot.crewSessionIds) ? snapshot.crewSessionIds : []);
|
|
49
|
+
const admitted = (row) => {
|
|
50
|
+
if (scope === 'crew') return origin.has(row.id);
|
|
51
|
+
if (scope === 'worktree') return origin.has(row.id) && row.worktree === true;
|
|
52
|
+
return true;
|
|
53
|
+
};
|
|
54
|
+
const withinTime = (row) => cutoff === null
|
|
55
|
+
|| (instant(row.createdAt) !== null && instant(row.createdAt) < cutoff);
|
|
56
|
+
const selected = new Set(sessions.filter(row => admitted(row) && withinTime(row)).map(row => row.id));
|
|
57
|
+
// A workspace carries no provenance of its own: it follows its sessions, and
|
|
58
|
+
// only when every one of them is selected. Checking `admitted(row)` here would
|
|
59
|
+
// test a workspace id against a session ledger and always fail. A provenance
|
|
60
|
+
// scope additionally requires at least one selected child — an empty workspace
|
|
61
|
+
// holds no Crew work, so it is not Crew's to remove; `all` keeps its historical
|
|
62
|
+
// behaviour of following an empty workspace.
|
|
63
|
+
const requiresOwnedChild = scope === 'crew' || scope === 'worktree';
|
|
40
64
|
const workspaceIds = workspaces.filter(row => {
|
|
41
65
|
const children = ids(row.sessionIds);
|
|
42
|
-
return (
|
|
66
|
+
return withinTime(row) && (!requiresOwnedChild || children.length > 0)
|
|
43
67
|
&& children.every(id => selected.has(id));
|
|
44
68
|
}).map(row => row.id).sort();
|
|
45
69
|
const sessionIds = [...selected].sort();
|
|
@@ -48,6 +72,7 @@ export function planHistoryCleanup(snapshot, { operation = 'archive', scope = 'a
|
|
|
48
72
|
workspaces: workspaces.map(row => ({ id: row.id, createdAt: row.createdAt, updatedAt: row.updatedAt, sessionIds: ids(row.sessionIds) })),
|
|
49
73
|
sessions: sessions.map(row => ({ id: row.id, createdAt: row.createdAt, revision: row.revision })),
|
|
50
74
|
active,
|
|
75
|
+
crewSessionIds: [...origin].sort(),
|
|
51
76
|
};
|
|
52
77
|
return {
|
|
53
78
|
schemaVersion: 1, operation, scope,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Detach the history maintenance runner from the hub's process tree.
|
|
2
|
+
//
|
|
3
|
+
// The runner must outlive the hub it is maintaining: its transaction stops 3210,
|
|
4
|
+
// rewrites the session store while nothing holds it, then starts 3210 again. The
|
|
5
|
+
// supervisor stops a service by killing that service's whole tracked process
|
|
6
|
+
// tree, and the tree is walked through `ParentProcessId`. A runner spawned
|
|
7
|
+
// directly by the hub is therefore inside the tree and is killed by the very stop
|
|
8
|
+
// it requested, leaving the transaction stranded in STOPPING with a stale update
|
|
9
|
+
// lock and no operator-side way to finish it.
|
|
10
|
+
//
|
|
11
|
+
// This launcher is the intermediate: it starts the runner, confirms the process
|
|
12
|
+
// exists, and exits immediately. The runner's parent is then a process that no
|
|
13
|
+
// longer exists, so the supervisor's tree walk cannot reach it and it survives.
|
|
14
|
+
//
|
|
15
|
+
// Usage: node runner-detach.mjs <runnerPath> [...runnerArgs]
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
|
|
18
|
+
const [runnerPath, ...runnerArgs] = process.argv.slice(2);
|
|
19
|
+
if (!runnerPath) {
|
|
20
|
+
process.stderr.write('runner-detach: missing runner path\n');
|
|
21
|
+
process.exit(2);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const child = spawn(process.execPath, [runnerPath, ...runnerArgs], {
|
|
25
|
+
detached: true,
|
|
26
|
+
windowsHide: true,
|
|
27
|
+
stdio: 'ignore',
|
|
28
|
+
env: { ...process.env },
|
|
29
|
+
});
|
|
30
|
+
child.once('error', () => process.exit(1));
|
|
31
|
+
child.once('spawn', () => {
|
|
32
|
+
child.unref();
|
|
33
|
+
process.exit(0);
|
|
34
|
+
});
|
package/src/history/runtime.mjs
CHANGED
|
@@ -17,8 +17,15 @@ export function registerRuntimeHistory(ctx) {
|
|
|
17
17
|
const service = createHistoryService({ crewRoot, agents: host.agents, persistence: host.sessionPersistence,
|
|
18
18
|
runtimeId: runtime.runtime_id, launch(id, recover = false) {
|
|
19
19
|
return new Promise((accept, reject) => {
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
// Spawn through a launcher that exits immediately: a runner parented by
|
|
21
|
+
// this hub would sit inside the hub's tracked process tree and be killed
|
|
22
|
+
// by the stop it requested. See runner-detach.mjs.
|
|
23
|
+
const child = spawn(process.execPath, [
|
|
24
|
+
fileURLToPath(new URL('./runner-detach.mjs', import.meta.url)),
|
|
25
|
+
fileURLToPath(new URL('./runner.mjs', import.meta.url)),
|
|
26
|
+
id, ...(recover ? ['--recover'] : []),
|
|
27
|
+
], {
|
|
28
|
+
windowsHide: true, stdio: 'ignore', env: { ...process.env },
|
|
22
29
|
});
|
|
23
30
|
child.once('error', reject); child.once('spawn', () => { child.unref(); accept(); });
|
|
24
31
|
});
|
package/src/history/service.mjs
CHANGED
|
@@ -5,6 +5,8 @@ import { planHistoryCleanup } from './cleanup-plan.mjs';
|
|
|
5
5
|
import { installHistoryAdmissionGate } from './admission-gate.mjs';
|
|
6
6
|
import { historyHash, historyPath, readHistoryBytes, decodeWorkspaceStore, readHistoryManifest, isSessionArtifactPath } from './archive-store.mjs';
|
|
7
7
|
import { readHistoryState, writeHistoryState, historyPending, publicHistoryState } from './state.mjs';
|
|
8
|
+
import { readSessionOrigins } from '../session-origins.mjs';
|
|
9
|
+
import { defaultWorktreeRoot } from '../workspace-isolation.mjs';
|
|
8
10
|
|
|
9
11
|
export function createHistoryService({ crewRoot, agents, persistence, runtimeId, launch, now = Date.now }) {
|
|
10
12
|
const gate = installHistoryAdmissionGate(agents, () => historyPending(crewRoot));
|
|
@@ -60,12 +62,21 @@ export function createHistoryService({ crewRoot, agents, persistence, runtimeId,
|
|
|
60
62
|
}
|
|
61
63
|
const raw = readHistoryBytes(historyPath(crewRoot, path)); total += raw.length;
|
|
62
64
|
if (total > 512 * 1024 * 1024) throw Error('HISTORY_INVENTORY_TOO_LARGE');
|
|
63
|
-
return { id: header.id, createdAt: header.createdAt, parentSession: header.parentSession,
|
|
65
|
+
return { id: header.id, createdAt: header.createdAt, parentSession: header.parentSession, cwd: header.cwd,
|
|
64
66
|
revision: JSON.stringify([header.revision, historyHash(raw)]), artifact: { sessionId: header.id, relativePath: path, sha256: historyHash(raw) } };
|
|
65
67
|
});
|
|
66
68
|
// A retained child keeps its ancestor chain; do not leave a newer fork orphaned.
|
|
67
69
|
const workspaces = Object.entries(store.tables.workspaces).map(([id, row]) => ({ id, ...row }));
|
|
68
|
-
const
|
|
70
|
+
const crewSessionIds = [...readSessionOrigins()];
|
|
71
|
+
const crewSet = new Set(crewSessionIds);
|
|
72
|
+
for (const row of sessions) if (crewSet.has(row.id)) row.crew = true;
|
|
73
|
+
// The isolated-workspace marker: the hub stamps every worktree session with a
|
|
74
|
+
// cwd under the Crew worktree root, so that subset is scopeable on its own.
|
|
75
|
+
const worktreeRoot = defaultWorktreeRoot().replaceAll('\\', '/').toLowerCase();
|
|
76
|
+
for (const row of sessions) {
|
|
77
|
+
if (row.crew === true && typeof row.cwd === 'string' && row.cwd.replaceAll('\\', '/').toLowerCase().startsWith(worktreeRoot)) row.worktree = true;
|
|
78
|
+
}
|
|
79
|
+
const plan = planHistoryCleanup({ workspaces, sessions, crewSessionIds, activeSessionIds: gate.idle() ? [] : ['active-agent'] }, options);
|
|
69
80
|
const selected = new Set(plan.sessionIds);
|
|
70
81
|
const byId = new Map(sessions.map(row => [row.id, row]));
|
|
71
82
|
const queue = sessions.filter(row => !selected.has(row.id));
|
package/src/hub/index.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
resolveRoleTierHint,
|
|
19
19
|
} from '../policy.mjs';
|
|
20
20
|
import { buildDirectSelectionTrace, resolveWorkerModel, resolveModel } from '../model-routing.mjs';
|
|
21
|
+
import { scheduleAdmission } from '../model-schedule.mjs';
|
|
21
22
|
import { readHarnessModelCatalog } from '../model-catalog.mjs';
|
|
22
23
|
import { appendDeliveryInstructions, parseDeliveryReport, formatDeliveryMetadata } from '../delivery.mjs';
|
|
23
24
|
import { captureWorkspaceBaseline, captureWorkspaceDiff, NOT_A_GIT_REPOSITORY } from '../workspace-audit.mjs';
|
|
@@ -101,6 +102,7 @@ function seedLangFromHost(ctx) {
|
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
import { setLang } from '../i18n.mjs';
|
|
105
|
+
import { appendSessionOrigin } from '../session-origins.mjs';
|
|
104
106
|
|
|
105
107
|
const ROUTE_BASE = '/_dsh/dsh-crew';
|
|
106
108
|
// Quick-config allowlist: the ONLY config keys writable from the official
|
|
@@ -701,12 +703,48 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
701
703
|
const getCurrentSelection = () => this.ctx.get('agentDefaultModel')?.currentSelection?.();
|
|
702
704
|
let selection;
|
|
703
705
|
if (workerProviderMode === 'deepseek-official') {
|
|
706
|
+
// Strict mode names its model directly instead of walking a catalog, but a
|
|
707
|
+
// peak `block` must still apply: otherwise enabling the schedule would be
|
|
708
|
+
// silently ignored on every dispatch that uses this provider mode.
|
|
709
|
+
const strictRef = { provider: 'deepseek-official', model: legacyModel };
|
|
710
|
+
const strictAt = new Date();
|
|
711
|
+
const strictPeak = scheduleAdmission(cfg.model_schedule, strictRef, strictAt);
|
|
712
|
+
if (strictPeak?.mode === 'block') {
|
|
713
|
+
// Carry the same structured evidence the catalog path produces. The
|
|
714
|
+
// strict branch has no candidate list to fall through, so the trace is
|
|
715
|
+
// the operator's only record of why the dispatch died.
|
|
716
|
+
const blockedTrace = buildDirectSelectionTrace({
|
|
717
|
+
role: effRole,
|
|
718
|
+
logicalAttempt: attempt,
|
|
719
|
+
modelClassHint: effTier,
|
|
720
|
+
strategy: 'legacy-strict',
|
|
721
|
+
candidateSet: attempt > 0 ? 'escalation' : 'primary',
|
|
722
|
+
provider: strictRef.provider,
|
|
723
|
+
model: strictRef.model,
|
|
724
|
+
source: 'legacy-strict',
|
|
725
|
+
});
|
|
726
|
+
blockedTrace.ordered_candidates = [{
|
|
727
|
+
provider: strictRef.provider,
|
|
728
|
+
model: strictRef.model,
|
|
729
|
+
source: 'legacy-strict',
|
|
730
|
+
status: 'skipped',
|
|
731
|
+
reason_code: 'PEAK_RESTRICTED',
|
|
732
|
+
}];
|
|
733
|
+
blockedTrace.fallback_reason = 'PEAK_RESTRICTED';
|
|
734
|
+
throw Object.assign(new Error('MODEL_BLOCKED_PEAK'), {
|
|
735
|
+
policyCode: 'MODEL_BLOCKED_PEAK',
|
|
736
|
+
provider: strictRef.provider,
|
|
737
|
+
model: strictRef.model,
|
|
738
|
+
selection_trace: blockedTrace,
|
|
739
|
+
});
|
|
740
|
+
}
|
|
704
741
|
selection = {
|
|
705
742
|
ok: true,
|
|
706
743
|
provider: 'deepseek-official',
|
|
707
744
|
model: legacyModel,
|
|
708
745
|
source: 'legacy-strict',
|
|
709
746
|
reasoningEffort: effort,
|
|
747
|
+
...(strictPeak?.mode === 'warn' ? { peak_advisory: true } : {}),
|
|
710
748
|
selection_trace: buildDirectSelectionTrace({
|
|
711
749
|
role: effRole,
|
|
712
750
|
logicalAttempt: attempt,
|
|
@@ -716,6 +754,7 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
716
754
|
provider: 'deepseek-official',
|
|
717
755
|
model: legacyModel,
|
|
718
756
|
source: 'legacy-strict',
|
|
757
|
+
...(strictPeak?.mode === 'warn' ? { peakAdvisory: true } : {}),
|
|
719
758
|
}),
|
|
720
759
|
};
|
|
721
760
|
} else {
|
|
@@ -747,6 +786,7 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
747
786
|
healthGate: policy.health_gate ?? healthGate,
|
|
748
787
|
allowFallback: cfg.allow_fallback !== false,
|
|
749
788
|
tombstones: lifecycleState.tombstones,
|
|
789
|
+
schedule: cfg.model_schedule,
|
|
750
790
|
});
|
|
751
791
|
} else {
|
|
752
792
|
selection = resolveWorkerModel({
|
|
@@ -760,6 +800,7 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
760
800
|
healthGate,
|
|
761
801
|
allowFallback: cfg.allow_fallback !== false,
|
|
762
802
|
tombstones: lifecycleState.tombstones,
|
|
803
|
+
schedule: cfg.model_schedule,
|
|
763
804
|
traceContext: {
|
|
764
805
|
role: effRole,
|
|
765
806
|
logicalAttempt: attempt,
|
|
@@ -809,9 +850,17 @@ export class WorkerRegistry { constructor(ctx) {
|
|
|
809
850
|
isolatedWorkspace = { worktreePath: created.worktreePath, repoRoot: created.repoRoot };
|
|
810
851
|
}
|
|
811
852
|
const sessionId = `session-${randomUUID()}`;
|
|
853
|
+
// Record provenance while it is still knowable: a session header carries no
|
|
854
|
+
// field naming who asked for the session, so a later Crew-scoped cleanup can
|
|
855
|
+
// only tell Crew's own work apart from the operator's by this ledger.
|
|
856
|
+
appendSessionOrigin({ sessionId, role: jobRole, jobId: id });
|
|
812
857
|
const job = {
|
|
813
858
|
id, client_job_id: client_job_id ?? null, sessionId, role: jobRole, attempt, tier: effTier, provider: selection.provider, model: selection.model,
|
|
814
859
|
selection_source: selection.source, selection_trace: selection.selection_trace ?? null,
|
|
860
|
+
// Routing already reports a peak `warn` as a top-level flag; carry it onto
|
|
861
|
+
// the job so a job consumer sees the same signal the router produced
|
|
862
|
+
// without having to re-derive it from the trace.
|
|
863
|
+
peak_advisory: selection.peak_advisory === true,
|
|
815
864
|
effort, reasoning_effort: selection.reasoningEffort,
|
|
816
865
|
task, source, cwd: executionCwd, requested_cwd: cwd,
|
|
817
866
|
isolation: isolatedWorkspace ? 'worktree' : 'shared',
|
|
@@ -1530,6 +1579,9 @@ export async function apply(ctx) {
|
|
|
1530
1579
|
healthGate: policy.health_gate ?? config.health_gate ?? config.worker?.model_policy?.health_gate,
|
|
1531
1580
|
allowFallback: config.allow_fallback !== false,
|
|
1532
1581
|
tombstones: lifecycleState.tombstones,
|
|
1582
|
+
// A projection must reflect the decision dispatch would make, or
|
|
1583
|
+
// the panel reports a model that routing would skip at peak.
|
|
1584
|
+
schedule: config.model_schedule,
|
|
1533
1585
|
});
|
|
1534
1586
|
}
|
|
1535
1587
|
}
|
|
@@ -1768,6 +1820,9 @@ export async function apply(ctx) {
|
|
|
1768
1820
|
healthGate,
|
|
1769
1821
|
allowFallback: config.allow_fallback !== false,
|
|
1770
1822
|
tombstones: lifecycleState.tombstones,
|
|
1823
|
+
// Same reason as the extension projection: report what dispatch
|
|
1824
|
+
// would actually pick.
|
|
1825
|
+
schedule: config.model_schedule,
|
|
1771
1826
|
});
|
|
1772
1827
|
selections[tier] = selected.ok
|
|
1773
1828
|
? { provider: selected.provider, model: selected.model, source: selected.source }
|
|
@@ -323,6 +323,14 @@ export const GLOBAL_CONFIG_DEFAULTS = {
|
|
|
323
323
|
worker_state: undefined,
|
|
324
324
|
review_state: undefined,
|
|
325
325
|
auto_review: undefined,
|
|
326
|
+
// ---- per-model peak/off-peak scheduling ----
|
|
327
|
+
// Some providers price by wall clock (DeepSeek doubles its rate during peak
|
|
328
|
+
// hours). `models` lists only the restricted models — one absent from it is
|
|
329
|
+
// unrestricted — each at `warn` (selectable, flagged) or `block` (skipped, so
|
|
330
|
+
// the next candidate serves the job). Times are the operator's local wall
|
|
331
|
+
// clock at `timezone_offset_minutes`, defaulting to UTC+8 with DeepSeek's
|
|
332
|
+
// published peak hours expressed in it.
|
|
333
|
+
model_schedule: undefined,
|
|
326
334
|
};
|
|
327
335
|
|
|
328
336
|
export function mergeStoredGlobalConfig(stored) {
|
package/src/install/install.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { basename, dirname, join } from 'node:path';
|
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
14
|
import * as legacy from './install-legacy.mjs';
|
|
15
15
|
import { normalizeModelPriority } from '../model-routing.mjs';
|
|
16
|
+
import { normalizeModelSchedule } from '../model-schedule.mjs';
|
|
16
17
|
import { runtimeActivationMetadata } from '../runtime-controls.mjs';
|
|
17
18
|
import {
|
|
18
19
|
CONFIG_SCHEMA_VERSION,
|
|
@@ -93,12 +94,12 @@ function legacySnapshotFrom(config = {}) {
|
|
|
93
94
|
};
|
|
94
95
|
}
|
|
95
96
|
|
|
96
|
-
function attachReadMetadata(config, { version, canonical }) {
|
|
97
|
-
return {
|
|
97
|
+
function attachReadMetadata(config, { version, canonical }) {
|
|
98
|
+
return {
|
|
98
99
|
...config,
|
|
99
100
|
config_schema_version: version,
|
|
100
|
-
config_authority: canonical ? 'canonical' : 'legacy-import',
|
|
101
|
-
config_migration_required: !canonical || version < CONFIG_SCHEMA_VERSION,
|
|
101
|
+
config_authority: canonical ? 'canonical' : 'legacy-import',
|
|
102
|
+
config_migration_required: !canonical || version < CONFIG_SCHEMA_VERSION,
|
|
102
103
|
config_activation: runtimeActivationMetadata(),
|
|
103
104
|
};
|
|
104
105
|
}
|
|
@@ -210,6 +211,10 @@ function mergeCanonicalPatch(current, patch) {
|
|
|
210
211
|
};
|
|
211
212
|
}
|
|
212
213
|
if (validObject(patch.legacy)) next.legacy = { ...next.legacy, ...patch.legacy };
|
|
214
|
+
// The peak/off-peak schedule replaces as a whole: its parts (windows, weekdays,
|
|
215
|
+
// offset, per-model modes) only make sense together, so a partial merge could
|
|
216
|
+
// pair one operator's windows with another's model list.
|
|
217
|
+
if (validObject(patch.model_schedule)) next.model_schedule = normalizeModelSchedule(patch.model_schedule);
|
|
213
218
|
|
|
214
219
|
// v0.3 still has one provider-mode control. A direct canonical patch is
|
|
215
220
|
// allowed, but it cannot manufacture separate worker/reviewer authorities.
|
|
@@ -397,10 +402,13 @@ export function writeGlobalConfig(patch, { configFile = GLOBAL_CONFIG_FILE } = {
|
|
|
397
402
|
? mergeLegacyPatchIntoCanonical(current, compatibilityView, candidate, patch)
|
|
398
403
|
: { ...candidate, legacy: legacySnapshotFrom(candidate) };
|
|
399
404
|
|
|
405
|
+
// `model_schedule` is canonical-only (it has no flat mirror), so it must open
|
|
406
|
+
// the canonical merge path on its own rather than ride along with worker/review.
|
|
400
407
|
const hasCanonicalPatch = validObject(patch?.worker)
|
|
401
408
|
|| validObject(patch?.review)
|
|
402
409
|
|| validObject(patch?.execution)
|
|
403
|
-
|| validObject(patch?.legacy)
|
|
410
|
+
|| validObject(patch?.legacy)
|
|
411
|
+
|| validObject(patch?.model_schedule);
|
|
404
412
|
if (hasCanonicalPatch) normalized = mergeCanonicalPatch(normalized, patch);
|
|
405
413
|
|
|
406
414
|
if (!validObject(normalized.legacy)) normalized.legacy = legacySnapshotFrom(candidate);
|