@lmzhen/dsh-evolution-curator 0.3.62 → 0.3.63

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/lib/index.js CHANGED
@@ -68,7 +68,6 @@ var EvolutionCurator = class extends Service {
68
68
  lastRun = 0;
69
69
  timer;
70
70
  bootCheck;
71
- running = false;
72
71
  /** 0.3.18 (E-18): stateless first-run defer fires ONCE per process — the
73
72
  * in-memory clock is seeded and later due runs must proceed, or the
74
73
  * persisted===null defer repeats forever (no state service to persist). */
@@ -83,8 +82,11 @@ var EvolutionCurator = class extends Service {
83
82
  });
84
83
  this.enabled = config.enabled ?? true;
85
84
  const clamped = [];
86
- const field = (name, value, fallback, min) => {
87
- const result = clampedNumber(value, fallback, { min });
85
+ const field = (name, value, fallback, min, max) => {
86
+ const result = clampedNumber(value, fallback, max === void 0 ? { min } : {
87
+ min,
88
+ max
89
+ });
88
90
  if (value !== void 0 && result !== value) clamped.push(name);
89
91
  return result;
90
92
  };
@@ -100,7 +102,7 @@ var EvolutionCurator = class extends Service {
100
102
  this.manageUnmanaged = config.manageUnmanaged ?? false;
101
103
  this.pruneBuiltins = config.pruneBuiltins ?? false;
102
104
  this.referencedSkillNames = new Set(config.referencedSkillNames ?? []);
103
- this.bootGraceSeconds = field("bootGraceSeconds", config.bootGraceSeconds, DEFAULT_CURATOR_BOOT_GRACE_SECONDS, 0);
105
+ this.bootGraceSeconds = field("bootGraceSeconds", config.bootGraceSeconds, DEFAULT_CURATOR_BOOT_GRACE_SECONDS, 0, 3600);
104
106
  this.curatorReviewMaxTokens = field("curatorReviewMaxTokens", config.curatorReviewMaxTokens, DEFAULT_CURATOR_REVIEW_MAX_TOKENS, 1);
105
107
  this.healthSoftBodyChars = field("healthSoftBodyChars", config.healthSoftBodyChars, DEFAULT_HEALTH_THRESHOLDS.softBodyChars, 1);
106
108
  this.healthStampDensityPerKb = field("healthStampDensityPerKb", config.healthStampDensityPerKb, DEFAULT_HEALTH_THRESHOLDS.stampDensityPerKb, 1);
@@ -185,7 +187,7 @@ var EvolutionCurator = class extends Service {
185
187
  const stateService = this.curatorStateService();
186
188
  if (stateService === void 0 && !this.statelessStateWarned) {
187
189
  this.statelessStateWarned = true;
188
- this.ctx.logger.warn("evolution-curator: evolution-state is not mounted — the curation interval baseline is this process's lifetime only (default interval 168h), so automatic curation will not fire again until the process has been alive that long. Mount evolution-state (evolution-host/all bundle) for a durable schedule.");
190
+ this.ctx.logger.warn(`evolution-curator: evolution-state is not mounted — the curation interval baseline is this process's lifetime only (default interval ${DEFAULT_CURATOR_INTERVAL_HOURS}h), so automatic curation will not fire again until the process has been alive that long. Mount evolution-state (evolution-host/all bundle) for a durable schedule.`);
189
191
  }
190
192
  const last = (await stateService?.loadCuratorState())?.lastRunAt ?? this.lastRun;
191
193
  if (Date.now() - last >= this.lifecycle().intervalHours * 36e5) await this.run();
@@ -332,18 +334,18 @@ var EvolutionCurator = class extends Service {
332
334
  * skipped with an explicit `already-running` outcome.
333
335
  */
334
336
  async run(options = {}) {
335
- if (this.running) return {
337
+ if (this.mutexDepth > 0) return {
336
338
  stale: [],
337
339
  archived: [],
338
340
  errors: [],
339
341
  report: this.skippedReport("already-running", (/* @__PURE__ */ new Date()).toISOString()),
340
342
  skipped: "already-running"
341
343
  };
342
- this.running = true;
344
+ const release = await this.acquireMutex();
343
345
  try {
344
346
  return await this.runCore(options);
345
347
  } finally {
346
- this.running = false;
348
+ release();
347
349
  try {
348
350
  await this.retainReports();
349
351
  } catch (error) {
@@ -351,6 +353,36 @@ var EvolutionCurator = class extends Service {
351
353
  }
352
354
  }
353
355
  }
356
+ /**
357
+ * P1 (v16): the control-plane mutex — ONE promise chain serializing run(),
358
+ * restore() and consolidate(). Replaces the v15 draft (`running` flag +
359
+ * `runSettled` polling), which could (a) spin forever on an
360
+ * already-resolved promise while a control-plane mutator held the flag
361
+ * (micro-task starvation: the flag's reset lives behind IO the spun loop
362
+ * never lets run) and (b) let two queued waiters wake into the same idle
363
+ * window and mutate concurrently. Here the chain IS the mutex: an entrant
364
+ * increments `mutexDepth` synchronously (so run()'s skip check sees queued
365
+ * work), awaits the previous tail, and the returned release resolves the
366
+ * tail for the next entrant. Double-release is a no-op.
367
+ */
368
+ mutexDepth = 0;
369
+ mutexTail = Promise.resolve();
370
+ acquireMutex() {
371
+ this.mutexDepth += 1;
372
+ const prev = this.mutexTail;
373
+ let releaseMutex;
374
+ this.mutexTail = new Promise((resolve) => {
375
+ releaseMutex = resolve;
376
+ });
377
+ let released = false;
378
+ const release = () => {
379
+ if (released) return;
380
+ released = true;
381
+ this.mutexDepth -= 1;
382
+ releaseMutex();
383
+ };
384
+ return prev.then(() => release);
385
+ }
354
386
  async runCore(options = {}) {
355
387
  const { ignoreGates = false, dryRun = false } = options;
356
388
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -485,16 +517,20 @@ var EvolutionCurator = class extends Service {
485
517
  }
486
518
  const llmHint = !this.llmReview && result.markStale.length > 0 ? " (llmReview: off - deterministic archive only; set llmReview: true for the LLM merge channel)" : "";
487
519
  const summary = `${dryRun ? "dry-run" : "auto"}: stale:${result.markStale.length} archived:${archivedSkills.length} consolidated:${gatedNominations.consolidations.length}${llmHint}`;
488
- await stateService?.transactCuratorState((current) => {
489
- const pausedNow = current?.paused ?? false;
490
- return {
491
- schemaVersion: 1,
492
- lastRunAt: dryRun ? persisted?.lastRunAt ?? this.lastRun : Date.now(),
493
- runCount: dryRun ? persisted?.runCount ?? 0 : (current?.runCount ?? 0) + 1,
494
- lastSummary: summary,
495
- paused: pausedNow
496
- };
497
- });
520
+ try {
521
+ await stateService?.transactCuratorState((current) => {
522
+ const pausedNow = current?.paused ?? false;
523
+ return {
524
+ schemaVersion: 1,
525
+ lastRunAt: dryRun ? persisted?.lastRunAt ?? this.lastRun : Date.now(),
526
+ runCount: dryRun ? persisted?.runCount ?? 0 : (current?.runCount ?? 0) + 1,
527
+ lastSummary: summary,
528
+ paused: pausedNow
529
+ };
530
+ });
531
+ } catch (error) {
532
+ this.ctx.logger.warn(`evolution-curator: failed to persist run bookkeeping: ${error instanceof Error ? error.message : String(error)}`);
533
+ }
498
534
  return {
499
535
  stale: result.markStale,
500
536
  archived: archivedSkills.map((item) => item.name),
@@ -526,6 +562,11 @@ var EvolutionCurator = class extends Service {
526
562
  }
527
563
  /**
528
564
  * F13 six-factor quality scoring, persisted onto the usage records.
565
+ * P1-1 (v15): these are the CURATOR-owned fields (`quality_score`/
566
+ * `quality_warn`) — this method must never touch the feedback-owned
567
+ * `feedback_*` pair (the lifecycle engine reads the union of both warn
568
+ * flags, so overwriting feedback here is what used to make negative
569
+ * feedback decision-irrelevant; field ownership on `UsageRecord`).
529
570
  */
530
571
  async scoreTree(usage, treeNames) {
531
572
  const supportDirs = /* @__PURE__ */ new Map();
@@ -859,6 +900,14 @@ var EvolutionCurator = class extends Service {
859
900
  * records into `archived` state. Snapshot-then-mutate, never a hard delete.
860
901
  */
861
902
  async consolidate(target, sources) {
903
+ const release = await this.acquireMutex();
904
+ try {
905
+ return await this.consolidateMutate(target, sources);
906
+ } finally {
907
+ release();
908
+ }
909
+ }
910
+ async consolidateMutate(target, sources) {
862
911
  const suppressedNames = new Set(await loadSuppressedNames(this.skills.root, this.io));
863
912
  const gates = new EvolutionGateSet({
864
913
  exclude: this.excludeSkillNames,
@@ -889,6 +938,14 @@ var EvolutionCurator = class extends Service {
889
938
  * and reset its usage state, keeping the recoverable-archive invariant.
890
939
  */
891
940
  async restore(name) {
941
+ const release = await this.acquireMutex();
942
+ try {
943
+ return await this.restoreMutate(name);
944
+ } finally {
945
+ release();
946
+ }
947
+ }
948
+ async restoreMutate(name) {
892
949
  await this.snapshotFull("pre-restore");
893
950
  const result = await this.skills.restoreFromArchive(name);
894
951
  if (!result.ok) return result;
@@ -110,7 +110,6 @@ export declare class EvolutionCurator extends Service {
110
110
  private lastRun;
111
111
  private timer;
112
112
  private bootCheck;
113
- private running;
114
113
  /** 0.3.18 (E-18): stateless first-run defer fires ONCE per process — the
115
114
  * in-memory clock is seeded and later due runs must proceed, or the
116
115
  * persisted===null defer repeats forever (no state service to persist). */
@@ -192,6 +191,21 @@ export declare class EvolutionCurator extends Service {
192
191
  ignoreGates?: boolean;
193
192
  dryRun?: boolean;
194
193
  }): Promise<CuratorRunOutcome>;
194
+ /**
195
+ * P1 (v16): the control-plane mutex — ONE promise chain serializing run(),
196
+ * restore() and consolidate(). Replaces the v15 draft (`running` flag +
197
+ * `runSettled` polling), which could (a) spin forever on an
198
+ * already-resolved promise while a control-plane mutator held the flag
199
+ * (micro-task starvation: the flag's reset lives behind IO the spun loop
200
+ * never lets run) and (b) let two queued waiters wake into the same idle
201
+ * window and mutate concurrently. Here the chain IS the mutex: an entrant
202
+ * increments `mutexDepth` synchronously (so run()'s skip check sees queued
203
+ * work), awaits the previous tail, and the returned release resolves the
204
+ * tail for the next entrant. Double-release is a no-op.
205
+ */
206
+ private mutexDepth;
207
+ private mutexTail;
208
+ private acquireMutex;
195
209
  private runCore;
196
210
  /**
197
211
  * Seed baseline records for tree skills the sidecar has not seen yet, so
@@ -202,6 +216,11 @@ export declare class EvolutionCurator extends Service {
202
216
  private seedBaseline;
203
217
  /**
204
218
  * F13 six-factor quality scoring, persisted onto the usage records.
219
+ * P1-1 (v15): these are the CURATOR-owned fields (`quality_score`/
220
+ * `quality_warn`) — this method must never touch the feedback-owned
221
+ * `feedback_*` pair (the lifecycle engine reads the union of both warn
222
+ * flags, so overwriting feedback here is what used to make negative
223
+ * feedback decision-irrelevant; field ownership on `UsageRecord`).
205
224
  */
206
225
  private scoreTree;
207
226
  /**
@@ -262,11 +281,13 @@ export declare class EvolutionCurator extends Service {
262
281
  * records into `archived` state. Snapshot-then-mutate, never a hard delete.
263
282
  */
264
283
  consolidate(target: string, sources: string[]): Promise<SkillActionResult>;
284
+ private consolidateMutate;
265
285
  /**
266
286
  * Control-plane restore: bring one archived skill back to the active root
267
287
  * and reset its usage state, keeping the recoverable-archive invariant.
268
288
  */
269
289
  restore(name: string): Promise<SkillActionResult>;
290
+ private restoreMutate;
270
291
  }
271
292
  export default EvolutionCurator;
272
293
  //# sourceMappingURL=index.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-curator",
3
3
  "description": "Deterministic skill lifecycle and recovery (community build)",
4
- "version": "0.3.62",
4
+ "version": "0.3.63",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -31,20 +31,20 @@
31
31
  "license": "MIT",
32
32
  "dependencies": {
33
33
  "@deepseek-ai/schemastery": "^3.18.1",
34
- "@lmzhen/dsh-evolution-core": "^0.3.62"
34
+ "@lmzhen/dsh-evolution-core": "^0.3.63"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@deepseek-ai/cordis": "^4.0.1",
38
38
  "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
39
39
  "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
40
- "@lmzhen/dsh-evolution-io": "^0.3.62",
41
- "@lmzhen/dsh-evolution-state": "^0.3.62"
40
+ "@lmzhen/dsh-evolution-io": "^0.3.63",
41
+ "@lmzhen/dsh-evolution-state": "^0.3.63"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
45
45
  "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
46
- "@lmzhen/dsh-evolution-core": "^0.3.62",
47
- "@lmzhen/dsh-evolution-io": "^0.3.62",
48
- "@lmzhen/dsh-evolution-state": "^0.3.62"
46
+ "@lmzhen/dsh-evolution-core": "^0.3.63",
47
+ "@lmzhen/dsh-evolution-io": "^0.3.63",
48
+ "@lmzhen/dsh-evolution-state": "^0.3.63"
49
49
  }
50
50
  }