@ran-sh/dsh-crew 1.4.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ran-sh/dsh-crew",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
@@ -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
- modelActivity: '模型活动概览', modelActivityHint: '聚合本 Hub 内存中最近 500 个含真实调用证据的任务;每个模型按任务计数(不是推测底层轮次),最多显示 50 个模型。不保存提示词、结果或凭据。', noModelActivity: '还没有真实模型活动。',
167
- calls: '任务数', invocationSource: '任务来源', routingSource: '路由来源', lastCalled: '最近调用', role: '角色', model: '模型', never: '—',
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
- 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.',
309
- calls: 'Jobs', invocationSource: 'Task source', routingSource: 'Routing source', lastCalled: 'Last called', role: 'Role', model: 'Model', never: '—',
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
  ))}
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';
@@ -702,12 +703,48 @@ export class WorkerRegistry { constructor(ctx) {
702
703
  const getCurrentSelection = () => this.ctx.get('agentDefaultModel')?.currentSelection?.();
703
704
  let selection;
704
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
+ }
705
741
  selection = {
706
742
  ok: true,
707
743
  provider: 'deepseek-official',
708
744
  model: legacyModel,
709
745
  source: 'legacy-strict',
710
746
  reasoningEffort: effort,
747
+ ...(strictPeak?.mode === 'warn' ? { peak_advisory: true } : {}),
711
748
  selection_trace: buildDirectSelectionTrace({
712
749
  role: effRole,
713
750
  logicalAttempt: attempt,
@@ -717,6 +754,7 @@ export class WorkerRegistry { constructor(ctx) {
717
754
  provider: 'deepseek-official',
718
755
  model: legacyModel,
719
756
  source: 'legacy-strict',
757
+ ...(strictPeak?.mode === 'warn' ? { peakAdvisory: true } : {}),
720
758
  }),
721
759
  };
722
760
  } else {
@@ -748,6 +786,7 @@ export class WorkerRegistry { constructor(ctx) {
748
786
  healthGate: policy.health_gate ?? healthGate,
749
787
  allowFallback: cfg.allow_fallback !== false,
750
788
  tombstones: lifecycleState.tombstones,
789
+ schedule: cfg.model_schedule,
751
790
  });
752
791
  } else {
753
792
  selection = resolveWorkerModel({
@@ -761,6 +800,7 @@ export class WorkerRegistry { constructor(ctx) {
761
800
  healthGate,
762
801
  allowFallback: cfg.allow_fallback !== false,
763
802
  tombstones: lifecycleState.tombstones,
803
+ schedule: cfg.model_schedule,
764
804
  traceContext: {
765
805
  role: effRole,
766
806
  logicalAttempt: attempt,
@@ -817,6 +857,10 @@ export class WorkerRegistry { constructor(ctx) {
817
857
  const job = {
818
858
  id, client_job_id: client_job_id ?? null, sessionId, role: jobRole, attempt, tier: effTier, provider: selection.provider, model: selection.model,
819
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,
820
864
  effort, reasoning_effort: selection.reasoningEffort,
821
865
  task, source, cwd: executionCwd, requested_cwd: cwd,
822
866
  isolation: isolatedWorkspace ? 'worktree' : 'shared',
@@ -1535,6 +1579,9 @@ export async function apply(ctx) {
1535
1579
  healthGate: policy.health_gate ?? config.health_gate ?? config.worker?.model_policy?.health_gate,
1536
1580
  allowFallback: config.allow_fallback !== false,
1537
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,
1538
1585
  });
1539
1586
  }
1540
1587
  }
@@ -1773,6 +1820,9 @@ export async function apply(ctx) {
1773
1820
  healthGate,
1774
1821
  allowFallback: config.allow_fallback !== false,
1775
1822
  tombstones: lifecycleState.tombstones,
1823
+ // Same reason as the extension projection: report what dispatch
1824
+ // would actually pick.
1825
+ schedule: config.model_schedule,
1776
1826
  });
1777
1827
  selections[tier] = selected.ok
1778
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) {
@@ -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);
package/src/jobs.mjs CHANGED
@@ -19,7 +19,9 @@ import { captureWorkspaceBaseline, captureWorkspaceDiff, NOT_A_GIT_REPOSITORY }
19
19
  import { buildOutcome, JOB_PHASES } from './workflow.mjs';
20
20
  import { raceWaiters } from './removable-waiter.mjs';
21
21
  import { buildDirectSelectionTrace } from './model-routing.mjs';
22
+ import { normalizeModelSchedule, scheduleAdmission } from './model-schedule.mjs';
22
23
  import { appendSessionOrigin } from './session-origins.mjs';
24
+ import { readGlobalConfig } from './install/install.mjs';
23
25
 
24
26
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
25
27
  const CONFIG_DIR = join(homedir(), '.config', 'dsh-crew');
@@ -27,6 +29,13 @@ const CREW_DSH_HOME = join(homedir(), '.config', 'dsh-crew', 'harness');
27
29
  const STATUS_FILE = join(CONFIG_DIR, 'status.json');
28
30
  const CORDIS = join(ROOT, 'worker.cordis.yml');
29
31
 
32
+ // Read the operator's schedule once per dispatch. An unreadable config must not
33
+ // block a job: a missing or damaged file means no restriction, matching how the
34
+ // router treats an absent schedule.
35
+ function readModelSchedule() {
36
+ try { return normalizeModelSchedule(readGlobalConfig().model_schedule); } catch { return normalizeModelSchedule(undefined); }
37
+ }
38
+
30
39
  export const TIERS = {
31
40
  flash: { model: 'deepseek-v4-flash', label: 'V4 Flash' },
32
41
  pro: { model: 'deepseek-v4-pro', label: 'V4 Pro' },
@@ -110,6 +119,14 @@ export function startJob({
110
119
  }) {
111
120
  const tierInfo = TIERS[tier];
112
121
  if (!tierInfo) throw new Error(`unknown tier "${tier}" (expected: ${Object.keys(TIERS).join(', ')})`);
122
+ // Standalone dispatch names its provider/model directly rather than walking a
123
+ // catalog, but a peak `block` still applies: without this the schedule would be
124
+ // silently ignored on every standalone job.
125
+ const standaloneRef = { provider: 'deepseek-official', model: tierInfo.model };
126
+ const standaloneAt = new Date();
127
+ if (scheduleAdmission(readModelSchedule(), standaloneRef, standaloneAt)?.mode === 'block') {
128
+ throw Object.assign(new Error('MODEL_BLOCKED_PEAK'), { policyCode: 'MODEL_BLOCKED_PEAK' });
129
+ }
113
130
  if (!ROLES[role]) throw new Error(`unknown role "${role}" (expected: ${Object.keys(ROLES).join(', ')})`);
114
131
  if (!['off', 'high', 'max'].includes(effort)) throw new Error(`unknown effort "${effort}" (expected: off, high, max)`);
115
132
  if (!existsSync(CORDIS)) throw new Error(`worker overlay not found at ${CORDIS}`);