principles-disciple 1.35.0 → 1.37.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.
Files changed (32) hide show
  1. package/openclaw.plugin.json +1 -1
  2. package/package.json +1 -1
  3. package/src/commands/nocturnal-train.ts +1 -0
  4. package/src/core/correction-cue-learner.ts +23 -8
  5. package/src/core/event-log.ts +3 -0
  6. package/src/core/evolution-engine.ts +1 -0
  7. package/src/core/init.ts +2 -2
  8. package/src/core/nocturnal-trinity-types.ts +124 -0
  9. package/src/core/session-tracker.ts +1 -0
  10. package/src/core/training-program.ts +1 -0
  11. package/src/hooks/gate-block-helper.ts +1 -1
  12. package/src/hooks/prompt.ts +3 -3
  13. package/src/index.ts +2 -1
  14. package/src/service/central-sync-service.ts +2 -0
  15. package/src/service/evolution-dedup.ts +74 -0
  16. package/src/service/evolution-pain-context.ts +79 -0
  17. package/src/service/evolution-queue-lock.ts +47 -0
  18. package/src/service/evolution-queue-migration.ts +173 -0
  19. package/src/service/evolution-worker.ts +43 -34
  20. package/src/service/keyword-optimization-service.ts +2 -2
  21. package/src/service/subagent-workflow/correction-observer-types.ts +69 -0
  22. package/src/service/subagent-workflow/correction-observer-workflow-manager.ts +246 -0
  23. package/src/service/subagent-workflow/index.ts +13 -0
  24. package/src/service/subagent-workflow/workflow-manager-base.ts +1 -0
  25. package/tests/core/correction-cue-learner.test.ts +345 -0
  26. package/tests/core/pain-score.property.test.ts +205 -0
  27. package/tests/integration/chaos-resilience.test.ts +348 -0
  28. package/tests/integration/gate-real-io.e2e.test.ts +251 -0
  29. package/tests/integration/pain-diagnostician-loop.e2e.test.ts +380 -0
  30. package/tests/integration/tool-hooks-workspace-dir.e2e.test.ts +8 -2
  31. package/tests/integration/trajectory-lifecycle.e2e.test.ts +523 -0
  32. package/vitest.config.ts +23 -4
@@ -2,7 +2,7 @@
2
2
  "id": "principles-disciple",
3
3
  "name": "Principles Disciple",
4
4
  "description": "Evolutionary programming agent framework with strategic guardrails and reflection loops.",
5
- "version": "1.35.0",
5
+ "version": "1.37.0",
6
6
  "skills": [
7
7
  "./skills"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "principles-disciple",
3
- "version": "1.35.0",
3
+ "version": "1.37.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -333,6 +333,7 @@ Hardware tiers:
333
333
  proc.kill();
334
334
  reject(new Error(`Trainer timed out after ${timeoutMs}ms`));
335
335
  }, timeoutMs);
336
+ timer.unref(); // Don't keep process alive for timeout
336
337
 
337
338
  proc.on('close', (code) => {
338
339
  clearTimeout(timer);
@@ -21,10 +21,17 @@ import {
21
21
  CORRECTION_SEED_KEYWORDS,
22
22
  MAX_CORRECTION_KEYWORDS,
23
23
  } from './correction-types.js';
24
- import { checkCooldown, recordCooldown } from '../service/nocturnal-runtime.js';
24
+ import { checkCooldown } from '../service/nocturnal-runtime.js';
25
25
 
26
26
  const KEYWORD_STORE_FILE = 'correction_keywords.json';
27
27
 
28
+ // CORR-08: Daily optimization throttle (uses checkCooldown in nocturnal-runtime.ts)
29
+ // Note: throttle state is stored in nocturnal-runtime.json, not a separate file.
30
+
31
+ // Weight bounds for correction keywords (D-39-03, D-39-15)
32
+ const MIN_KEYWORD_WEIGHT = 0.1;
33
+ const MAX_KEYWORD_WEIGHT = 0.9;
34
+
28
35
  // =========================================================================
29
36
  // Module-level cache (D-04, D-05)
30
37
  // =========================================================================
@@ -112,6 +119,8 @@ export function saveCorrectionKeywordStore(
112
119
  _correctionCueCache = null;
113
120
  }
114
121
 
122
+ // =========================================================================
123
+ // Throttle helpers (CORR-08)
115
124
  // =========================================================================
116
125
  // Singleton state
117
126
  // =========================================================================
@@ -217,7 +226,7 @@ export class CorrectionCueLearner {
217
226
  keyword.hitCount = (keyword.hitCount ?? 0) + 1;
218
227
 
219
228
  // D-39-15: Multiplicative weight decay x0.8 on confirmed FP
220
- keyword.weight = Math.max(0.1, keyword.weight * 0.8);
229
+ keyword.weight = Math.max(MIN_KEYWORD_WEIGHT, keyword.weight * 0.8);
221
230
  keyword.lastHitAt = new Date().toISOString();
222
231
 
223
232
  this.flush();
@@ -238,10 +247,10 @@ export class CorrectionCueLearner {
238
247
 
239
248
  /**
240
249
  * Records that an optimization was performed.
241
- * Increments the daily throttle counter and updates lastOptimizedAt.
250
+ * Updates lastOptimizedAt for the store. Throttle state is managed
251
+ * by checkCooldown() — no separate throttle file needed (CORR-08).
242
252
  */
243
- async recordOptimizationPerformed(): Promise<void> {
244
- await recordCooldown(this.stateDir, 24 * 60 * 60 * 1000);
253
+ recordOptimizationPerformed(): void {
245
254
  this.store.lastOptimizedAt = new Date().toISOString();
246
255
  this.flush();
247
256
  }
@@ -270,14 +279,20 @@ export class CorrectionCueLearner {
270
279
  * Throws if keyword not found.
271
280
  */
272
281
  updateWeight(term: string, weight: number): void {
273
- const idx = this.store.keywords.findIndex(
282
+ const keyword = this.store.keywords.find(
274
283
  k => k.term.toLowerCase() === term.toLowerCase()
275
284
  );
276
- if (idx < 0) {
285
+ if (!keyword) {
277
286
  throw new Error(`Keyword not found: ${term}`);
278
287
  }
279
288
 
280
- this.store.keywords[idx].weight = Math.max(0.1, Math.min(0.9, weight));
289
+ keyword.weight = Math.max(MIN_KEYWORD_WEIGHT, Math.min(MAX_KEYWORD_WEIGHT, weight)); // Clamp to MIN-MAX_KEYWORD_WEIGHT
290
+ const idx = this.store.keywords.findIndex(
291
+ k => k.term.toLowerCase() === term.toLowerCase()
292
+ );
293
+ if (idx >= 0) {
294
+ this.store.keywords[idx] = { ...keyword };
295
+ }
281
296
  this.flush();
282
297
  }
283
298
 
@@ -295,6 +295,9 @@ export class EventLog {
295
295
 
296
296
  private startFlushTimer(): void {
297
297
  this.flushTimer = setInterval(() => this.flush(), this.flushIntervalMs);
298
+ // Don't keep the process alive just for this timer
299
+ // This allows tests and CLI to exit without waiting for flush
300
+ this.flushTimer.unref();
298
301
  }
299
302
 
300
303
  flush(): void {
@@ -469,6 +469,7 @@ export class EvolutionEngine {
469
469
  this.retryTimer = setTimeout(() => {
470
470
  this.processRetryQueue();
471
471
  }, 1000);
472
+ this.retryTimer.unref(); // Don't keep process alive for retry
472
473
  }
473
474
  }
474
475
 
package/src/core/init.ts CHANGED
@@ -46,7 +46,7 @@ export function ensureWorkspaceTemplates(api: OpenClawPluginApi, workspaceDir: s
46
46
  if (fs.existsSync(commonTemplatesDir)) {
47
47
  api.logger.info(`[PD] Syncing workspace templates: ${workspaceDir}...`);
48
48
 
49
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
49
+
50
50
  copyRecursiveSync(commonTemplatesDir, workspaceDir, api);
51
51
  }
52
52
 
@@ -89,7 +89,7 @@ export function ensureWorkspaceTemplates(api: OpenClawPluginApi, workspaceDir: s
89
89
  fs.mkdirSync(painDestDir, { recursive: true });
90
90
  }
91
91
 
92
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
92
+
93
93
  copyRecursiveSync(painTemplatesDir, painDestDir, api);
94
94
  }
95
95
 
@@ -5,6 +5,8 @@
5
5
  * Extracted to break circular dependency.
6
6
  */
7
7
 
8
+ import type { TrinityArtificerContext } from './nocturnal-artificer.js';
9
+
8
10
  // ---------------------------------------------------------------------------
9
11
  // Dreamer Types
10
12
  // ---------------------------------------------------------------------------
@@ -92,3 +94,125 @@ export interface PhilosopherOutput {
92
94
  /** Timestamp of generation */
93
95
  generatedAt: string;
94
96
  }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Trinity Result Types
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /**
103
+ * Tournament trace entry for explainability.
104
+ */
105
+ export interface TournamentTraceEntry {
106
+ candidateIndex: number;
107
+ reason: string;
108
+ }
109
+
110
+ /**
111
+ * Analysis of a rejected candidate — why it lost the tournament.
112
+ * Informs training signal for "what to avoid".
113
+ */
114
+ export interface RejectedAnalysis {
115
+ /** Mental model that led to the rejected candidate */
116
+ whyRejected: string;
117
+ /** Observable caution triggers that were missed or ignored */
118
+ warningSignals: string[];
119
+ /** Correct reasoning path that should have been seen */
120
+ correctiveThinking: string;
121
+ }
122
+
123
+ /**
124
+ * Justification for the chosen candidate — why it won the tournament.
125
+ * Informs training signal for "what to do".
126
+ */
127
+ export interface ChosenJustification {
128
+ /** Why this candidate was selected over others */
129
+ whyChosen: string;
130
+ /** 1-3 transferable insights from this decision */
131
+ keyInsights: string[];
132
+ /** When this approach does NOT apply */
133
+ limitations: string[];
134
+ }
135
+
136
+ /**
137
+ * Contrastive analysis: key differences between chosen and rejected paths.
138
+ * Synthesizes the core lesson from the tournament.
139
+ */
140
+ export interface ContrastiveAnalysis {
141
+ /** ONE key insight distinguishing chosen from rejected */
142
+ criticalDifference: string;
143
+ /** Pattern: "When X, do Y" */
144
+ decisionTrigger: string;
145
+ /** How to systematically avoid the rejected path */
146
+ preventionStrategy: string;
147
+ }
148
+
149
+ /**
150
+ * Telemetry about Trinity chain execution.
151
+ */
152
+ export interface TrinityTelemetry {
153
+ chainMode: 'trinity' | 'single-reflector';
154
+ usedStubs: boolean;
155
+ dreamerPassed: boolean;
156
+ philosopherPassed: boolean;
157
+ scribePassed: boolean;
158
+ candidateCount: number;
159
+ selectedCandidateIndex: number;
160
+ stageFailures: string[];
161
+ tournamentTrace?: TournamentTraceEntry[];
162
+ winnerAggregateScore?: number;
163
+ winnerThresholdPassed?: boolean;
164
+ eligibleCandidateCount?: number;
165
+ diversityCheckPassed?: boolean;
166
+ candidateRiskLevels?: string[];
167
+ philosopher6D?: {
168
+ avgScores: {
169
+ principleAlignment: number;
170
+ specificity: number;
171
+ actionability: number;
172
+ executability: number;
173
+ safetyImpact: number;
174
+ uxImpact: number;
175
+ };
176
+ highRiskCount: number;
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Validation failure for a Trinity stage.
182
+ */
183
+ export interface TrinityStageFailure {
184
+ stage: 'dreamer' | 'philosopher' | 'scribe';
185
+ reason: string;
186
+ }
187
+
188
+ /**
189
+ * Result of Trinity chain execution.
190
+ */
191
+ export interface TrinityResult {
192
+ success: boolean;
193
+ artifact?: TrinityDraftArtifact;
194
+ telemetry: TrinityTelemetry;
195
+ failures: TrinityStageFailure[];
196
+ fallbackOccurred: boolean;
197
+ artificerContext?: TrinityArtificerContext;
198
+ }
199
+
200
+ /**
201
+ * Scribe output — final structured artifact draft.
202
+ */
203
+ export interface TrinityDraftArtifact {
204
+ selectedCandidateIndex: number;
205
+ badDecision: string;
206
+ betterDecision: string;
207
+ rationale: string;
208
+ sessionId: string;
209
+ principleId: string;
210
+ sourceSnapshotRef: string;
211
+ telemetry: TrinityTelemetry;
212
+ thinkingModelDelta?: number;
213
+ planningRatioGain?: number;
214
+ artificerContext?: TrinityArtificerContext;
215
+ contrastiveAnalysis?: ContrastiveAnalysis;
216
+ rejectedAnalysis?: RejectedAnalysis;
217
+ chosenJustification?: ChosenJustification;
218
+ }
@@ -166,6 +166,7 @@ function schedulePersistence(state: SessionState): void {
166
166
  persistSession(state);
167
167
  persistTimers.delete(state.sessionId);
168
168
  }, 1000); // 1 second debounce
169
+ timer.unref(); // Don't keep process alive for persistence
169
170
  persistTimers.set(state.sessionId, timer);
170
171
  }
171
172
 
@@ -362,6 +362,7 @@ export async function executeTrainer(
362
362
  proc.kill();
363
363
  reject(new Error(`Trainer timed out after ${timeoutMs}ms`));
364
364
  }, timeoutMs);
365
+ timer.unref(); // Don't keep process alive for timeout
365
366
 
366
367
  proc.on('close', (code) => {
367
368
  clearTimeout(timer);
@@ -210,5 +210,5 @@ function scheduleTrajectoryGateBlockRetry(
210
210
  logWarn(`[PD_GATE] Retrying trajectory gate block persistence (attempt ${attempt + 1}): ${String(error)}`);
211
211
  scheduleTrajectoryGateBlockRetry(wctx, payload, attempt + 1, logWarn, logError);
212
212
  }
213
- }, TRAJECTORY_GATE_BLOCK_RETRY_DELAY_MS * attempt);
213
+ }, TRAJECTORY_GATE_BLOCK_RETRY_DELAY_MS * attempt).unref();
214
214
  }
@@ -368,7 +368,7 @@ export async function handleBeforePromptBuild(
368
368
  // prependContext: Only short dynamic directives: evolutionDirective + heartbeat
369
369
 
370
370
 
371
- // eslint-disable-next-line @typescript-eslint/init-declarations
371
+
372
372
  let prependSystemContext: string;
373
373
  let prependContext = '';
374
374
  let appendSystemContext = '';
@@ -684,7 +684,7 @@ ${taskBlocks}${processingNote}
684
684
 
685
685
  // ──── 6. Dynamic Attitude Matrix (based on GFI) ────
686
686
 
687
- // eslint-disable-next-line @typescript-eslint/init-declarations
687
+
688
688
  let attitudeDirective: string;
689
689
  const currentGfi = session?.currentGfi || 0;
690
690
 
@@ -910,7 +910,7 @@ ${taskBlocks}${processingNote}
910
910
  const toolMatches = toolPatterns.flatMap(({ pattern, tool }) => {
911
911
  const matches: string[] = [];
912
912
 
913
- // eslint-disable-next-line @typescript-eslint/init-declarations
913
+
914
914
  let _m;
915
915
  const r = new RegExp(pattern.source, pattern.flags);
916
916
 
package/src/index.ts CHANGED
@@ -87,7 +87,7 @@ const plugin = {
87
87
 
88
88
  // ── Startup Health Check: Verify workspaceDir resolution ──
89
89
  // Catches OpenClaw context bugs early (e.g., missing workspaceDir in tool hooks)
90
- setTimeout(() => {
90
+ const healthCheckTimer = setTimeout(() => {
91
91
  const testCtx = { agentId: 'main' };
92
92
  const toolWorkspaceDir = resolveToolHookWorkspaceDirSafe(testCtx, api, 'startup.health_check');
93
93
  const toolIssue = validateWorkspaceDir(toolWorkspaceDir);
@@ -98,6 +98,7 @@ const plugin = {
98
98
  api.logger.info(`[PD:health] Tool hook workspaceDir OK: "${toolWorkspaceDir}"`);
99
99
  }
100
100
  }, 1000);
101
+ healthCheckTimer.unref(); // Don't keep process alive for health check
101
102
 
102
103
  const language = (api.pluginConfig?.language as string) || 'en';
103
104
 
@@ -57,6 +57,8 @@ export const CentralSyncService: OpenClawPluginService = {
57
57
 
58
58
  // Schedule periodic sync
59
59
  syncInterval = setInterval(runSyncCycle, intervalMs);
60
+ // Don't keep the process alive just for this timer
61
+ syncInterval.unref();
60
62
 
61
63
  logger?.info?.(`[PD:CentralSync] Service started, syncing every ${intervalMs / 1000}s`);
62
64
  },
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Evolution Queue Deduplication Utilities
3
+ *
4
+ * Dedup logic for preventing duplicate pain tasks and redundant reflections.
5
+ * Extracted from evolution-worker.ts.
6
+ */
7
+
8
+ import type { EvolutionQueueItem } from './evolution-queue-migration.js';
9
+
10
+ /**
11
+ * Dedup window for pain queue tasks (30 minutes).
12
+ */
13
+ export const PAIN_QUEUE_DEDUP_WINDOW_MS = 30 * 60 * 1000;
14
+
15
+ /**
16
+ * Maximum length for dedup key components to prevent memory/performance issues
17
+ * from extremely long source or preview strings during queue scanning.
18
+ */
19
+ const MAX_DEDUP_KEY_COMPONENT_LENGTH = 200;
20
+
21
+ function normalizePainDedupKey(source: string, preview: string, reason?: string): string {
22
+ const truncate = (s: string) => s.slice(0, MAX_DEDUP_KEY_COMPONENT_LENGTH);
23
+ const normalizedReason = (reason || '').trim().toLowerCase().slice(0, 50);
24
+ return `${truncate(source.trim().toLowerCase())}::${truncate(preview.trim().toLowerCase())}::${normalizedReason}`;
25
+ }
26
+
27
+ export function findRecentDuplicateTask(
28
+ queue: EvolutionQueueItem[],
29
+ source: string,
30
+ preview: string,
31
+ now: number,
32
+ reason?: string
33
+ ): EvolutionQueueItem | undefined {
34
+ const key = normalizePainDedupKey(source, preview, reason);
35
+ return queue.find((task) => {
36
+ if (task.status === 'completed') return false;
37
+ const taskTime = new Date(task.enqueued_at || task.timestamp).getTime();
38
+ if (!Number.isFinite(taskTime) || (now - taskTime) > PAIN_QUEUE_DEDUP_WINDOW_MS) return false;
39
+ return normalizePainDedupKey(task.source, task.trigger_text_preview || '', task.reason) === key;
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Check if a similar pain task was enqueued recently.
45
+ */
46
+ export function hasRecentDuplicateTask(
47
+ queue: EvolutionQueueItem[],
48
+ source: string,
49
+ preview: string,
50
+ now: number,
51
+ reason?: string
52
+ ): boolean {
53
+ return !!findRecentDuplicateTask(queue, source, preview, now, reason);
54
+ }
55
+
56
+ /**
57
+ * Check if a phrase matches an active promoted rule.
58
+ */
59
+ export function hasEquivalentPromotedRule(
60
+ dictionary: { getAllRules(): Record<string, { type: string; phrases?: string[]; pattern?: string; status: string; }> },
61
+ phrase: string
62
+ ): boolean {
63
+ const normalizedPhrase = phrase.trim().toLowerCase();
64
+ return Object.values(dictionary.getAllRules()).some((rule) => {
65
+ if (rule.status !== 'active') return false;
66
+ if (rule.type === 'exact_match' && Array.isArray(rule.phrases)) {
67
+ return rule.phrases.some((candidate) => candidate.trim().toLowerCase() === normalizedPhrase);
68
+ }
69
+ if (rule.type === 'regex' && typeof rule.pattern === 'string') {
70
+ return rule.pattern.trim().toLowerCase() === normalizedPhrase;
71
+ }
72
+ return false;
73
+ });
74
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Evolution Pain Context Reader
3
+ *
4
+ * Reads and processes pain signal context for task enrichment.
5
+ * Extracted from evolution-worker.ts.
6
+ */
7
+
8
+ import type { WorkspaceContext } from '../core/workspace-context.js';
9
+ import { readPainFlagContract } from '../core/pain.js';
10
+ import type { EvolutionQueueItem } from './evolution-queue-migration.js';
11
+ import type { RecentPainContext } from './evolution-queue-migration.js';
12
+
13
+ /**
14
+ * Read recent pain context from PAIN_FLAG file.
15
+ * Extracts session_id to link to trajectory DB.
16
+ * Returns structured pain metadata for attaching to sleep_reflection tasks.
17
+ * Returns null if no pain flag exists.
18
+ */
19
+ export function readRecentPainContext(wctx: WorkspaceContext): RecentPainContext {
20
+ const contract = readPainFlagContract(wctx.workspaceDir);
21
+ if (contract.status !== 'valid') {
22
+ return { mostRecent: null, recentPainCount: 0, recentMaxPainScore: 0 };
23
+ }
24
+
25
+ try {
26
+ const score = parseInt(contract.data.score ?? '0', 10) || 0;
27
+ const source = contract.data.source ?? '';
28
+ const reason = contract.data.reason ?? '';
29
+ const timestamp = contract.data.time ?? '';
30
+ const sessionId = contract.data.session_id ?? '';
31
+
32
+ if (score > 0) {
33
+ return {
34
+ mostRecent: { score, source, reason, timestamp, sessionId },
35
+ recentPainCount: 1,
36
+ recentMaxPainScore: score,
37
+ };
38
+ }
39
+ } catch {
40
+ // Best effort — non-fatal
41
+ }
42
+
43
+ return { mostRecent: null, recentPainCount: 0, recentMaxPainScore: 0 };
44
+ }
45
+
46
+ /**
47
+ * Build a dedup key from pain context.
48
+ * Returns null when no pain context is available (bypasses dedup).
49
+ */
50
+ export function buildPainSourceKey(
51
+ painCtx: ReturnType<typeof readRecentPainContext>,
52
+ ): string | null {
53
+ if (!painCtx.mostRecent) return null;
54
+ return `${painCtx.mostRecent.source}::${painCtx.mostRecent.reason?.slice(0, 50) ?? ''}`;
55
+ }
56
+
57
+ /**
58
+ * Check whether a similar sleep_reflection task completed recently.
59
+ * Phase 3c: Prevents redundant reflections of the same underlying issue.
60
+ */
61
+ export function hasRecentSimilarReflection(
62
+ queue: EvolutionQueueItem[],
63
+ painSourceKey: string,
64
+ now: number,
65
+ ): EvolutionQueueItem | null {
66
+ const DEDUP_WINDOW_MS = 4 * 60 * 60 * 1000; // 4 hours
67
+ return queue.find((t) => {
68
+ if (t.taskKind !== 'sleep_reflection') return false;
69
+ // Only match completed tasks (exclude failed to allow retries)
70
+ if (t.status !== 'completed') return false;
71
+ if (!t.completed_at) return false;
72
+ const age = now - new Date(t.completed_at).getTime();
73
+ if (age > DEDUP_WINDOW_MS) return false;
74
+ const taskPainKey = buildPainSourceKey(t.recentPainContext ?? { mostRecent: null, recentPainCount: 0, recentMaxPainScore: 0 });
75
+ // If either side has no pain context, they don't match
76
+ if (!taskPainKey) return false;
77
+ return taskPainKey === painSourceKey;
78
+ }) ?? null;
79
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Evolution Queue Lock Utilities
3
+ *
4
+ * File locking for safe concurrent queue access.
5
+ * Extracted from evolution-worker.ts.
6
+ */
7
+
8
+ import { acquireLockAsync, releaseLock as releaseImportedLock, type LockContext } from '../utils/file-lock.js';
9
+ import { LockUnavailableError } from '../config/index.js';
10
+
11
+ export const EVOLUTION_QUEUE_LOCK_SUFFIX = '.lock';
12
+ export const LOCK_MAX_RETRIES = 50;
13
+ export const LOCK_RETRY_DELAY_MS = 50;
14
+ export const LOCK_STALE_MS = 30_000;
15
+
16
+ export async function acquireQueueLock(
17
+ resourcePath: string,
18
+ logger: { warn?: (message: string) => void; info?: (message: string) => void } | undefined,
19
+ lockSuffix: string = EVOLUTION_QUEUE_LOCK_SUFFIX,
20
+ ): Promise<() => void> {
21
+ try {
22
+ const ctx: LockContext = await acquireLockAsync(resourcePath, {
23
+ lockSuffix,
24
+ maxRetries: LOCK_MAX_RETRIES,
25
+ baseRetryDelayMs: LOCK_RETRY_DELAY_MS,
26
+ lockStaleMs: LOCK_STALE_MS,
27
+ });
28
+ return () => releaseImportedLock(ctx);
29
+ } catch (error: unknown) {
30
+ const warn = logger?.warn;
31
+ warn?.(`[PD:EvolutionWorker] Failed to acquire lock for ${resourcePath}: ${String(error)}`);
32
+ throw error;
33
+ }
34
+ }
35
+
36
+ export async function requireQueueLock(
37
+ resourcePath: string,
38
+ logger: { warn?: (message: string) => void; info?: (message: string) => void } | undefined,
39
+ scope: string,
40
+ lockSuffix: string = EVOLUTION_QUEUE_LOCK_SUFFIX,
41
+ ): Promise<() => void> {
42
+ try {
43
+ return await acquireQueueLock(resourcePath, logger, lockSuffix);
44
+ } catch (err) {
45
+ throw new LockUnavailableError(resourcePath, scope, { cause: err });
46
+ }
47
+ }