@ran-sh/dsh-crew 1.4.0 → 1.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.
@@ -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);
@@ -42,6 +42,7 @@ import { homedir } from 'node:os';
42
42
  import * as realInstaller from './install.mjs';
43
43
  import { samePayloadContent, capturePayloadContent } from './payload-content.mjs';
44
44
  import { crewDshHome, crewProfileDir } from './install.mjs';
45
+ import { liveReleaseClaims } from '../release-in-use.mjs';
45
46
  import { ensureCrewDshRuntime, ensureCrewPluginRegistration, removeCrewPluginRegistration, migrateCrewDshRuntime, installDshInto, restoreRetainedRuntime, crewDshRuntimeRoot, payloadDshVersion, TARGET_DSH_VERSION } from '../dsh-cli-runtime.mjs';
46
47
  import {
47
48
  ensureOfficialWebIntegration,
@@ -1085,9 +1086,15 @@ function gcOldReleases({ home, keep = KEEP_RELEASES, protect = null }) {
1085
1086
  const releasesDir = crewReleasesDir({ home });
1086
1087
  if (!existsSync(releasesDir)) return;
1087
1088
  const removed = [];
1089
+ // A running Hub keeps executing the release it started from and re-reads
1090
+ // several modules from disk on every request, so removing that release breaks
1091
+ // those routes with no recovery but a restart. Protect what live processes
1092
+ // claim, not just what the pointer names.
1093
+ const claimed = liveReleaseClaims({ home }).map((dir) => resolve(dir));
1094
+ const live = new Set([...(protect ? [resolve(protect)] : []), ...claimed]);
1088
1095
  const dirs = readdirSync(releasesDir)
1089
1096
  .map((name) => join(releasesDir, name))
1090
- .filter((dir) => (!pointer || dir !== pointer.path) && (!protect || dir !== protect));
1097
+ .filter((dir) => (!pointer || dir !== pointer.path) && !live.has(resolve(dir)));
1091
1098
  const incomplete = [];
1092
1099
  const complete = [];
1093
1100
  for (const dir of dirs) {
@@ -3434,7 +3441,14 @@ export async function runNpxCli({
3434
3441
  else if (command === 'releases') result = await actions.releases({ args, log });
3435
3442
  else if (command === 'rollback') result = await actions.rollback({ version: args?.[0], args, log });
3436
3443
  else result = await actions[command]({ log });
3437
- return result?.ok === false ? 1 : 0;
3444
+ // Surface why the command failed. Actions return a structured failure rather
3445
+ // than throwing, so without this the CLI exits 1 having printed nothing —
3446
+ // `dsh-crew rollback` with no version was a silent failure.
3447
+ if (result?.ok === false) {
3448
+ error(`dsh-crew ${command} failed: ${result.error ?? result.code ?? 'unknown error'}`);
3449
+ return 1;
3450
+ }
3451
+ return 0;
3438
3452
  } catch (err) {
3439
3453
  error(`dsh-crew ${command} failed: ${err?.message ?? err}`);
3440
3454
  return 1;
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}`);