principles-disciple 1.41.0 → 1.42.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 (37) hide show
  1. package/.planning/codebase/ARCHITECTURE.md +157 -0
  2. package/.planning/codebase/CONCERNS.md +145 -0
  3. package/.planning/codebase/CONVENTIONS.md +148 -0
  4. package/.planning/codebase/INTEGRATIONS.md +81 -0
  5. package/.planning/codebase/STACK.md +87 -0
  6. package/.planning/codebase/STRUCTURE.md +193 -0
  7. package/.planning/codebase/TESTING.md +243 -0
  8. package/openclaw.plugin.json +1 -1
  9. package/package.json +1 -1
  10. package/src/commands/pain.ts +12 -5
  11. package/src/commands/promote-impl.ts +13 -7
  12. package/src/commands/rollback.ts +10 -3
  13. package/src/core/event-log.ts +8 -6
  14. package/src/core/evolution-types.ts +33 -1
  15. package/src/hooks/message-sanitize.ts +18 -5
  16. package/src/hooks/prompt.ts +15 -4
  17. package/src/hooks/subagent.ts +2 -3
  18. package/src/http/principles-console-route.ts +21 -4
  19. package/src/service/evolution-worker.ts +89 -365
  20. package/src/service/queue-io.ts +375 -0
  21. package/src/service/queue-migration.ts +122 -0
  22. package/src/service/sleep-cycle.ts +157 -0
  23. package/src/service/subagent-workflow/runtime-direct-driver.ts +1 -1
  24. package/src/service/workflow-watchdog.ts +168 -0
  25. package/src/tools/deep-reflect.ts +22 -11
  26. package/src/types/event-payload.ts +80 -0
  27. package/src/types/queue.ts +70 -0
  28. package/src/utils/file-lock.ts +2 -2
  29. package/src/utils/io.ts +11 -3
  30. package/tests/core/evolution-migration.test.ts +325 -1
  31. package/tests/core/queue-purge.test.ts +337 -0
  32. package/tests/fixtures/legacy-queue-v1.json +74 -0
  33. package/tests/queue/async-lock.test.ts +200 -0
  34. package/tests/service/evolution-worker.queue.test.ts +296 -0
  35. package/tests/service/queue-io.test.ts +229 -0
  36. package/tests/service/queue-migration.test.ts +147 -0
  37. package/tests/service/workflow-watchdog.test.ts +372 -0
@@ -1,7 +1,7 @@
1
1
  /* global NodeJS */
2
+
2
3
  import * as fs from 'fs';
3
4
  import * as path from 'path';
4
- import { createHash } from 'crypto';
5
5
  import type { OpenClawPluginServiceContext, OpenClawPluginApi, PluginLogger } from '../openclaw-sdk.js';
6
6
  import { DictionaryService } from '../core/dictionary-service.js';
7
7
  import { DetectionService } from '../core/detection-service.js';
@@ -12,11 +12,20 @@ import type { EventLog } from '../core/event-log.js';
12
12
  import { initPersistence, flushAllSessions } from '../core/session-tracker.js';
13
13
  import { addDiagnosticianTask, completeDiagnosticianTask } from '../core/diagnostician-task-store.js';
14
14
  import { getEvolutionLogger } from '../core/evolution-logger.js';
15
+ import type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
16
+ export type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
15
17
  import { atomicWriteFileSync } from '../utils/io.js';
18
+
19
+ // Re-export queue I/O (extracted to queue-io.ts)
20
+ export { loadEvolutionQueue, saveEvolutionQueue, withQueueLock, acquireQueueLock, requireQueueLock } from './queue-io.js';
21
+ export { enqueueSleepReflectionTask, enqueueKeywordOptimizationTask } from './queue-io.js';
22
+ export { EVOLUTION_QUEUE_LOCK_SUFFIX, LOCK_MAX_RETRIES, LOCK_RETRY_DELAY_MS, LOCK_STALE_MS } from './queue-io.js';
23
+ import { saveEvolutionQueue, requireQueueLock, hasPendingTask, enqueueSleepReflectionTask, enqueueKeywordOptimizationTask, createEvolutionTaskId } from './queue-io.js';
24
+ import type { RecentPainContext } from './queue-io.js';
25
+ export type { RecentPainContext } from './queue-io.js';
16
26
  import { checkWorkspaceIdle, checkCooldown, recordCooldown } from './nocturnal-runtime.js';
17
27
  import { loadCooldownEscalationConfig, loadNocturnalConfigMerged } from './nocturnal-config.js';
18
28
  import { WorkflowStore } from './subagent-workflow/workflow-store.js';
19
- import type { WorkflowRow } from './subagent-workflow/types.js';
20
29
  import { EmpathyObserverWorkflowManager } from './subagent-workflow/empathy-observer-workflow-manager.js';
21
30
  import { DeepReflectWorkflowManager } from './subagent-workflow/deep-reflect-workflow-manager.js';
22
31
  import { NocturnalWorkflowManager, nocturnalWorkflowSpec } from './subagent-workflow/nocturnal-workflow-manager.js';
@@ -29,168 +38,58 @@ import { validateNocturnalSnapshotIngress } from '../core/nocturnal-snapshot-con
29
38
  import { isExpectedSubagentError } from './subagent-workflow/subagent-error-utils.js';
30
39
  import { readPainFlagContract } from '../core/pain.js';
31
40
  import { CorrectionObserverWorkflowManager, correctionObserverWorkflowSpec } from './subagent-workflow/correction-observer-workflow-manager.js';
41
+ import { findRecentDuplicateTask } from './evolution-dedup.js';
32
42
  import type { CorrectionObserverPayload } from './subagent-workflow/correction-observer-types.js';
33
43
  import { KeywordOptimizationService } from './keyword-optimization-service.js';
34
44
  import { TrajectoryRegistry } from '../core/trajectory.js';
35
45
  import { CorrectionCueLearner } from '../core/correction-cue-learner.js';
36
- import { isLegacyQueueItem, migrateQueueToV2, type RawQueueItem, type TaskKind, type TaskPriority } from './evolution-queue-migration.js';
37
- export type { TaskKind, TaskPriority } from './evolution-queue-migration.js';
38
- export { acquireQueueLock, EVOLUTION_QUEUE_LOCK_SUFFIX, LOCK_MAX_RETRIES, LOCK_RETRY_DELAY_MS, LOCK_STALE_MS } from './evolution-queue-lock.js';
39
- import { requireQueueLock, EVOLUTION_QUEUE_LOCK_SUFFIX } from './evolution-queue-lock.js';
40
- import { readRecentPainContext, buildPainSourceKey, hasRecentSimilarReflection } from './evolution-pain-context.js';
41
- import { findRecentDuplicateTask } from './evolution-dedup.js';
42
46
  import { classifyFailure, type ClassifiableTaskKind } from './failure-classifier.js';
43
47
  import { recordPersistentFailure, resetFailureState, isTaskKindInCooldown } from './cooldown-strategy.js';
44
48
  import { reconcileStartup } from './startup-reconciler.js';
45
49
  import { WORKFLOW_TTL_MS } from '../config/defaults/runtime.js';
46
50
  import { OpenClawTrinityRuntimeAdapter } from '../core/nocturnal-trinity.js';
47
51
 
48
- // ── Workflow Watchdog ────────────────────────────────────────────────────────
49
- // Detects stale/orphaned workflows, invalid results, and cleanup failures.
50
- // Runs every heartbeat cycle, catching bugs like:
51
- // #185 — orphaned active workflows
52
- // #181 — structurally invalid results (all zeros)
53
- // #180/#183 — expired workflows not swept
54
- // #182 — unhandled rejections leaving workflows in limbo
55
-
56
- interface WatchdogResult {
57
- anomalies: number;
58
- details: string[];
59
- }
52
+ // ── Queue Event Payload Validation ─────────────────────────────────────────
60
53
 
61
- async function runWorkflowWatchdog(
62
- wctx: WorkspaceContext,
63
- api: OpenClawPluginApi | null,
64
- logger?: PluginLogger,
65
- ): Promise<WatchdogResult> {
66
- const details: string[] = [];
67
- const now = Date.now();
68
- const subagentRuntime = api?.runtime?.subagent;
69
- const agentSession = api?.runtime?.agent?.session;
70
-
71
- try {
72
- const store = new WorkflowStore({ workspaceDir: wctx.workspaceDir });
54
+ /**
55
+ * Validates a queue event payload string before JSON.parse.
56
+ * Checks:
57
+ * 1. typeof payload === 'string'
58
+ * 2. Parsed object has required fields: 'type' and 'workspaceId'
59
+ * Returns the parsed object only if validation passes.
60
+ * Returns empty object {} if payload is falsy.
61
+ * Throws Error if payload is a non-empty string that fails validation.
62
+ */
63
+ function validateQueueEventPayload(payload: string | null | undefined): Record<string, unknown> {
64
+ if (!payload) return {};
65
+ if (typeof payload !== 'string') {
66
+ throw new Error(`Queue event payload must be a string, got: ${typeof payload}`);
67
+ }
73
68
  try {
74
- const allWorkflows: WorkflowRow[] = store.listWorkflows();
75
-
76
- // Check 1: Stale active workflows (active > 2x TTL)
77
- const staleThreshold = WORKFLOW_TTL_MS * 2;
78
- const staleActive = allWorkflows.filter(
79
- (wf: WorkflowRow) => wf.state === 'active' && (now - wf.created_at) > staleThreshold,
80
- );
81
- if (staleActive.length > 0) {
82
- for (const wf of staleActive) {
83
- const ageMin = Math.round((now - wf.created_at) / 60000);
84
- details.push(`stale_active: ${wf.workflow_id} (${wf.workflow_type}, ${ageMin}min old)`);
85
-
86
- // #257: Check if the last recorded event reason indicates expected subagent unavailability.
87
- // If so, skip marking as terminal_error — the workflow is stale because the subagent
88
- // was expectedly unavailable (daemon mode, process isolation), not due to a hard failure.
89
- const events = store.getEvents(wf.workflow_id);
90
- const lastEventReason = events.length > 0 ? events[events.length - 1].reason : 'unknown';
91
- if (isExpectedSubagentError(lastEventReason)) {
92
- logger?.debug?.(`[PD:Watchdog] Skipping stale active workflow ${wf.workflow_id}: expected subagent error (${lastEventReason})`);
93
- continue;
94
- }
95
-
96
- store.updateWorkflowState(wf.workflow_id, 'terminal_error');
97
- store.recordEvent(wf.workflow_id, 'watchdog_timeout', 'active', 'terminal_error', `Stale active > ${staleThreshold / 60000}s`, { ageMs: now - wf.created_at });
98
-
99
- // Cleanup session if possible (#188: gateway-safe fallback)
100
- if (wf.child_session_key) {
101
- try {
102
- if (subagentRuntime) {
103
- await subagentRuntime.deleteSession({ sessionKey: wf.child_session_key, deleteTranscript: true });
104
- logger?.info?.(`[PD:Watchdog] Cleaned up stale session: ${wf.child_session_key}`);
105
- } else if (agentSession) {
106
- const storePath = agentSession.resolveStorePath();
107
- const sessionStore = agentSession.loadSessionStore(storePath, { skipCache: true });
108
- const normalizedKey = wf.child_session_key.toLowerCase();
109
- if (sessionStore[normalizedKey]) {
110
- delete sessionStore[normalizedKey];
111
- await agentSession.saveSessionStore(storePath, sessionStore);
112
- logger?.info?.(`[PD:Watchdog] Cleaned up stale session via agentSession fallback: ${wf.child_session_key}`);
113
- }
114
- }
115
- } catch (cleanupErr) {
116
- const errMsg = String(cleanupErr);
117
- if (errMsg.includes('gateway request') && agentSession) {
118
- const storePath = agentSession.resolveStorePath();
119
- const sessionStore = agentSession.loadSessionStore(storePath, { skipCache: true });
120
- const normalizedKey = wf.child_session_key.toLowerCase();
121
- if (sessionStore[normalizedKey]) {
122
- delete sessionStore[normalizedKey];
123
- await agentSession.saveSessionStore(storePath, sessionStore);
124
- logger?.info?.(`[PD:Watchdog] Cleaned up stale session via agentSession fallback after gateway error: ${wf.child_session_key}`);
125
- }
126
- } else {
127
- logger?.warn?.(`[PD:Watchdog] Failed to cleanup session ${wf.child_session_key}: ${errMsg}`);
128
- }
129
- }
130
- }
69
+ const parsed = JSON.parse(payload);
70
+ if (typeof parsed !== 'object' || parsed === null) {
71
+ throw new Error('Queue event payload must be a JSON object');
131
72
  }
132
- }
133
-
134
- // Check 2: Workflows in terminal_error/expired without cleanup
135
- const unclearedTerminal = allWorkflows.filter(
136
- (wf: WorkflowRow) => (wf.state === 'terminal_error' || wf.state === 'expired') && wf.cleanup_state === 'pending',
137
- );
138
- if (unclearedTerminal.length > 0) {
139
- details.push(`uncleared_terminal: ${unclearedTerminal.length} workflows (will be swept next cycle)`);
140
- }
141
-
142
- // Check 3: Nocturnal workflow result validation (#181 pattern)
143
- const nocturnalCompleted = allWorkflows.filter(
144
- (wf: WorkflowRow) => wf.workflow_type === 'nocturnal' && wf.state === 'completed',
145
- );
146
- for (const wf of nocturnalCompleted) {
147
- // Check if the metadata snapshot has all zeros (invalid data)
148
- try {
149
- const meta = JSON.parse(wf.metadata_json) as Record<string, unknown>;
150
- const snapshot = meta.snapshot as Record<string, unknown> | undefined;
151
- if (snapshot) {
152
- // #219: Check for fallback data source (partial stats from pain context)
153
- const dataSource = snapshot._dataSource as string | undefined;
154
- if (dataSource === 'pain_context_fallback') {
155
- details.push(`fallback_snapshot: nocturnal workflow ${wf.workflow_id} uses pain-context fallback (stats may be incomplete)`);
156
- }
157
- const stats = snapshot.stats as Record<string, number> | undefined;
158
- // #246: Stats are now always number (never null). Detect "empty" fallback:
159
- // fallback + all counts zero means no real data was available.
160
- // NOTE: totalAssistantTurns may be 0 even for valid sessions because
161
- // listRecentNocturnalCandidateSessions (used in fallback path) does not
162
- // populate assistantTurnCount (only getNocturnalSessionSnapshot does).
163
- // We use totalToolCalls=0 as the primary indicator instead.
164
- if (stats && dataSource === 'pain_context_fallback' &&
165
- stats.totalToolCalls === 0 && stats.totalGateBlocks === 0 &&
166
- stats.failureCount === 0) {
167
- details.push(`fallback_snapshot_stats: nocturnal workflow ${wf.workflow_id} has empty fallback stats (no trajectory data found)`);
168
- }
169
- }
170
- } catch { /* ignore malformed metadata */ }
171
- }
172
-
173
- // Summary
174
- const stateCounts: Record<string, number> = {};
175
- for (const wf of allWorkflows) {
176
- stateCounts[wf.state] = (stateCounts[wf.state] || 0) + 1;
177
- }
178
- const stateSummary = Object.entries(stateCounts).map(([s, c]) => `${s}=${c}`).join(', ');
179
- if (details.length === 0) {
180
- logger?.debug?.(`[PD:Watchdog] OK — ${allWorkflows.length} workflows (${stateSummary})`);
181
- } else {
182
- logger?.info?.(`[PD:Watchdog] ${details.length} anomalies — ${allWorkflows.length} workflows (${stateSummary})`);
183
- }
184
- } finally {
185
- store.dispose();
73
+ if (!('type' in parsed) || !('workspaceId' in parsed)) {
74
+ throw new Error('Queue event payload missing required fields: type, workspaceId');
75
+ }
76
+ return parsed;
77
+ } catch (err) {
78
+ if (err instanceof SyntaxError) {
79
+ throw new Error(`Invalid JSON in queue event payload: ${err.message}`);
80
+ }
81
+ throw err;
186
82
  }
187
- } catch (err) {
188
- logger?.warn?.(`[PD:Watchdog] Failed to scan workflows: ${String(err)}`);
189
- }
190
-
191
- return { anomalies: details.length, details };
192
83
  }
193
84
 
85
+ /* istanbul ignore next — test export for validateQueueEventPayload */
86
+ export { validateQueueEventPayload };
87
+
88
+ // Re-export workflow watchdog (extracted to workflow-watchdog.ts)
89
+ import { runWorkflowWatchdog, type WatchdogResult } from './workflow-watchdog.js';
90
+ export { runWorkflowWatchdog };
91
+ export type { WatchdogResult };
92
+
194
93
  let timeoutId: NodeJS.Timeout | null = null;
195
94
 
196
95
  /**
@@ -205,27 +104,6 @@ let timeoutId: NodeJS.Timeout | null = null;
205
104
  export type QueueStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'canceled';
206
105
  export type TaskResolution = 'marker_detected' | 'auto_completed_timeout' | 'failed_max_retries' | 'runtime_unavailable' | 'canceled' | 'late_marker_principle_created' | 'late_marker_no_principle' | 'stub_fallback' | 'skipped_thin_violation';
207
106
 
208
- /**
209
- * Recent pain context attached to sleep_reflection tasks.
210
- * Carries explicit recent pain signal metadata without being a separate task kind.
211
- * Used by NocturnalTargetSelector for ranking bias and context enrichment.
212
- */
213
- export interface RecentPainContext {
214
- /** Most recent unresolved pain event */
215
- mostRecent: {
216
- score: number;
217
- source: string;
218
- reason: string;
219
- timestamp: string;
220
- /** Session ID where the pain occurred */
221
- sessionId: string;
222
- } | null;
223
- /** Count of pain events in the recent window (for signal strength) */
224
- recentPainCount: number;
225
- /** Highest pain score in the recent window */
226
- recentMaxPainScore: number;
227
- }
228
-
229
107
  export interface EvolutionQueueItem {
230
108
  // Core identity
231
109
  id: string;
@@ -263,6 +141,11 @@ export interface EvolutionQueueItem {
263
141
  recentPainContext?: RecentPainContext;
264
142
  }
265
143
 
144
+ // ── Queue Migration (extracted to queue-migration.ts) ────────────────────────
145
+ import { migrateToV2, isLegacyQueueItem, migrateQueueToV2, LegacyEvolutionQueueItem, DEFAULT_TASK_KIND, DEFAULT_PRIORITY, DEFAULT_MAX_RETRIES, type RawQueueItem } from './queue-migration.js';
146
+ export { migrateToV2, isLegacyQueueItem, migrateQueueToV2, LegacyEvolutionQueueItem, DEFAULT_TASK_KIND, DEFAULT_PRIORITY, DEFAULT_MAX_RETRIES };
147
+ export type { RawQueueItem };
148
+
266
149
  function isSessionAtOrBeforeTriggerTime(
267
150
  session: { startedAt: string; updatedAt: string },
268
151
  triggerTimeMs: number,
@@ -281,6 +164,7 @@ function isSessionAtOrBeforeTriggerTime(
281
164
  return true;
282
165
  }
283
166
 
167
+
284
168
  function buildFallbackNocturnalSnapshot(
285
169
  sleepTask: EvolutionQueueItem,
286
170
  extractor?: ReturnType<typeof createNocturnalTrajectoryExtractor> | null,
@@ -347,20 +231,9 @@ function buildFallbackNocturnalSnapshot(
347
231
  };
348
232
  }
349
233
 
350
- export function createEvolutionTaskId(
351
- source: string,
352
- score: number,
353
- preview: string,
354
- reason: string,
355
- now: number
356
- ): string {
357
- // Keep ids short for prompt injection, but include enough entropy to avoid
358
- // collisions between different pain events that share the same source/score/preview.
359
- return createHash('md5')
360
- .update(`${source}:${score}:${preview}:${reason}:${now}`)
361
- .digest('hex')
362
- .substring(0, 8);
363
- }
234
+ const PAIN_QUEUE_DEDUP_WINDOW_MS = 30 * 60 * 1000;
235
+
236
+ // Queue lock constants and requireQueueLock are imported from queue-io.ts
364
237
 
365
238
  export function extractEvolutionTaskId(task: string): string | null {
366
239
  if (!task) return null;
@@ -409,180 +282,31 @@ export function purgeStaleFailedTasks(
409
282
  return { purged: purged.length, remaining: queue.length, byReason };
410
283
  }
411
284
 
412
-
413
- /**
414
- * Check whether a specific task kind has a pending or in-progress entry.
415
- */
416
- function hasPendingTask(queue: EvolutionQueueItem[], taskKind: string): boolean {
417
- return queue.some(
418
- (t) => t.taskKind === taskKind && (t.status === 'pending' || t.status === 'in_progress'),
419
- );
420
- }
421
-
422
- /**
423
- * Decide whether to skip enqueuing due to a recent similar reflection.
424
- * Returns true if skipped (with log), false if should proceed.
425
- */
426
- function shouldSkipForDedup(
427
- queue: EvolutionQueueItem[],
428
- wctx: WorkspaceContext,
429
- logger: PluginLogger,
430
- ): boolean {
431
- const recentPainContext = readRecentPainContext(wctx);
432
- const painSourceKey = buildPainSourceKey(recentPainContext);
433
-
434
- // Bypass dedup when there is no pain context — general idle reflections
435
- // should not be throttled by the 'no_pain_context' sentinel.
436
- if (!painSourceKey) return false;
437
-
438
- const now = Date.now();
439
- const recentSimilarReflection = hasRecentSimilarReflection(queue, painSourceKey, now);
440
-
441
- if (recentSimilarReflection) {
442
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
443
- const completedTime = new Date(recentSimilarReflection.completed_at!).getTime();
444
- logger?.debug?.(`[PD:EvolutionWorker] Skipping sleep_reflection — similar reflection completed ${Math.round((now - completedTime) / 60000)}min ago (same pain pattern: ${painSourceKey})`);
445
- return true;
446
- }
447
- return false;
448
- }
449
-
450
- /**
451
- * Load and migrate the evolution queue. Returns empty array if file doesn't exist.
452
- */
453
- function loadEvolutionQueue(queuePath: string): EvolutionQueueItem[] {
454
-
455
- let rawQueue: RawQueueItem[] = [];
456
- try {
457
- rawQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
458
- } catch {
459
- // Queue doesn't exist yet - create empty array
460
- rawQueue = [];
461
- }
462
- return migrateQueueToV2(rawQueue);
285
+ function normalizePainDedupKey(source: string, preview: string, reason?: string): string {
286
+ // Include reason in dedup key to match createEvolutionTaskId() behavior
287
+ // Different reasons for the same source/preview should create different tasks
288
+ const normalizedReason = (reason || '').trim().toLowerCase();
289
+ return `${source.trim().toLowerCase()}::${preview.trim().toLowerCase()}::${normalizedReason}`;
463
290
  }
464
291
 
465
- /**
466
- * Build and persist a new sleep_reflection task.
467
- */
468
292
 
469
- function enqueueNewSleepReflectionTask(
470
- queue: EvolutionQueueItem[],
471
- recentPainContext: ReturnType<typeof readRecentPainContext>,
472
- queuePath: string,
473
- logger: PluginLogger,
474
- ): void {
475
- const taskId = createEvolutionTaskId('nocturnal', 50, 'idle workspace', 'Sleep-mode reflection', Date.now());
476
- const nowIso = new Date().toISOString();
477
-
478
- queue.push({
479
- id: taskId,
480
- taskKind: 'sleep_reflection',
481
- priority: 'medium',
482
- score: 50,
483
- source: 'nocturnal',
484
- reason: 'Sleep-mode reflection triggered by idle workspace',
485
- trigger_text_preview: 'Idle workspace detected',
486
- timestamp: nowIso,
487
- enqueued_at: nowIso,
488
- status: 'pending',
489
- traceId: taskId,
490
- retryCount: 0,
491
- maxRetries: 1, // sleep_reflection doesn't retry
492
- recentPainContext,
493
- });
494
-
495
- atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
496
- logger?.info?.(`[PD:EvolutionWorker] Enqueued sleep_reflection task ${taskId}`);
497
- }
498
-
499
- /**
500
- * Enqueue a sleep_reflection task if one is not already pending.
501
- * Phase 2.4: Called when workspace is idle to trigger nocturnal reflection.
502
- * Phase 3c: Dedup checks recent sleep_reflection tasks by pain source pattern
503
- * to prevent redundant reflections of the same underlying issue.
504
- */
505
- async function enqueueSleepReflectionTask(
506
- wctx: WorkspaceContext,
507
- logger: PluginLogger,
508
- ): Promise<void> {
509
- const queuePath = wctx.resolve('EVOLUTION_QUEUE');
510
- const releaseLock = await requireQueueLock(queuePath, logger, 'enqueueSleepReflection', EVOLUTION_QUEUE_LOCK_SUFFIX);
511
-
512
- try {
513
- const queue = loadEvolutionQueue(queuePath);
514
-
515
- // Guard 1: Skip if a sleep_reflection task is already pending/in-progress
516
- if (hasPendingTask(queue, 'sleep_reflection')) {
517
- logger?.debug?.('[PD:EvolutionWorker] sleep_reflection task already pending/in-progress, skipping');
518
- return;
519
- }
520
-
521
- // Guard 2: Dedup — skip if similar reflection completed recently
522
- if (shouldSkipForDedup(queue, wctx, logger)) {
523
- return;
524
- }
525
-
526
- // Enqueue the new task
527
- const recentPainContext = readRecentPainContext(wctx);
528
- enqueueNewSleepReflectionTask(queue, recentPainContext, queuePath, logger);
529
- } finally {
530
- releaseLock();
531
- }
293
+
294
+ export function hasRecentDuplicateTask(queue: EvolutionQueueItem[], source: string, preview: string, now: number, reason?: string): boolean {
295
+ return !!findRecentDuplicateTask(queue, source, preview, now, reason);
532
296
  }
533
297
 
534
- /**
535
- * Enqueue a keyword_optimization task if one is not already pending/in-progress (CORR-08).
536
- * Dispatches LLM subagent via CorrectionObserverWorkflowManager to optimize
537
- * correction keywords based on FPR and match history.
538
- */
539
- async function enqueueKeywordOptimizationTask(
540
- wctx: WorkspaceContext,
541
- logger: PluginLogger,
542
- ): Promise<void> {
543
- const queuePath = wctx.resolve('EVOLUTION_QUEUE');
544
- const releaseLock = await requireQueueLock(queuePath, logger, 'enqueueKeywordOpt', EVOLUTION_QUEUE_LOCK_SUFFIX);
545
-
546
- try {
547
- const queue = loadEvolutionQueue(queuePath);
548
-
549
- // Guard: Skip if a keyword_optimization task is already pending/in-progress (CORR-08)
550
- if (hasPendingTask(queue, 'keyword_optimization')) {
551
- logger?.debug?.('[PD:EvolutionWorker] keyword_optimization task already pending/in-progress, skipping');
552
- return;
298
+ export function hasEquivalentPromotedRule(dictionary: { getAllRules(): Record<string, { type: string; phrases?: string[]; pattern?: string; status: string; }> }, phrase: string): boolean {
299
+ const normalizedPhrase = phrase.trim().toLowerCase();
300
+ return Object.values(dictionary.getAllRules()).some((rule) => {
301
+ if (rule.status !== 'active') return false;
302
+ if (rule.type === 'exact_match' && Array.isArray(rule.phrases)) {
303
+ return rule.phrases.some((candidate) => candidate.trim().toLowerCase() === normalizedPhrase);
553
304
  }
554
-
555
- // Guard: Skip if daily optimization throttle is exhausted (CORR-08)
556
- const learner = CorrectionCueLearner.get(wctx.stateDir);
557
- if (!learner.canRunKeywordOptimization()) {
558
- logger?.debug?.('[PD:EvolutionWorker] keyword_optimization throttle exhausted, skipping');
559
- return;
305
+ if (rule.type === 'regex' && typeof rule.pattern === 'string') {
306
+ return rule.pattern.trim().toLowerCase() === normalizedPhrase;
560
307
  }
561
-
562
- const taskId = createEvolutionTaskId('keyword_optimization', 50, 'keyword optimization', 'Keyword optimization via LLM', Date.now());
563
- const nowIso = new Date().toISOString();
564
-
565
- queue.push({
566
- id: taskId,
567
- taskKind: 'keyword_optimization',
568
- priority: 'medium',
569
- score: 50,
570
- source: 'correction',
571
- reason: 'Keyword optimization triggered by heartbeat',
572
- trigger_text_preview: 'Keyword optimization via LLM',
573
- timestamp: nowIso,
574
- enqueued_at: nowIso,
575
- status: 'pending',
576
- traceId: taskId,
577
- retryCount: 0,
578
- maxRetries: 1,
579
- });
580
-
581
- atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
582
- logger?.info?.(`[PD:EvolutionWorker] Enqueued keyword_optimization task ${taskId}`);
583
- } finally {
584
- releaseLock();
585
- }
308
+ return false;
309
+ });
586
310
  }
587
311
 
588
312
  interface ParsedPainValues {
@@ -637,7 +361,7 @@ async function doEnqueuePainTask(
637
361
  retryCount: 0, maxRetries: 3,
638
362
  });
639
363
 
640
- atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
364
+ saveEvolutionQueue(queuePath, queue);
641
365
  fs.appendFileSync(painFlagPath, `\nstatus: queued\ntask_id: ${taskId}\n`, 'utf8');
642
366
  result.enqueued = true;
643
367
 
@@ -872,7 +596,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
872
596
  }
873
597
 
874
598
  // V2: Migrate queue to current schema if needed
875
- const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue);
599
+ const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue) as unknown as EvolutionQueueItem[];
876
600
 
877
601
  let queueChanged = rawQueue.some(isLegacyQueueItem);
878
602
 
@@ -910,13 +634,13 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
910
634
  e.event_type.includes('failed') || e.event_type.includes('error')
911
635
  ).pop();
912
636
  if (failureEvent) {
913
- const payload = failureEvent.payload_json ? JSON.parse(failureEvent.payload_json) : {};
637
+ const payload = validateQueueEventPayload(failureEvent.payload_json);
914
638
  detailedError = `sleep_reflection failed: ${failureEvent.reason}`;
915
639
  if (payload.skipReason) {
916
640
  detailedError += ` (skipReason: ${payload.skipReason})`;
917
641
  }
918
- if (payload.failures && payload.failures.length > 0) {
919
- detailedError += ` | failures: ${payload.failures.slice(0, 3).join(', ')}`;
642
+ if (payload.failures && Array.isArray(payload.failures) && payload.failures.length > 0) {
643
+ detailedError += ` | failures: ${(payload.failures as string[]).slice(0, 3).join(', ')}`;
920
644
  }
921
645
  }
922
646
  } catch (fetchErr) {
@@ -1432,7 +1156,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1432
1156
 
1433
1157
  // Write claimed state (includes any pain changes from above) and release lock
1434
1158
  if (queueChanged) {
1435
- atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
1159
+ saveEvolutionQueue(queuePath, queue);
1436
1160
  }
1437
1161
  releaseLock();
1438
1162
  // Phase 40: Track outcomes for failure classification after queue write
@@ -1768,7 +1492,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1768
1492
  if (keywordOptTasks.length > 0) {
1769
1493
  // Skip all keyword_optimization tasks this cycle; release lock and return
1770
1494
  if (queueChanged) {
1771
- atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
1495
+ saveEvolutionQueue(queuePath, queue);
1772
1496
  }
1773
1497
  releaseLock();
1774
1498
  lockReleased = true;
@@ -1784,7 +1508,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1784
1508
  queueChanged = queueChanged || pendingKeywordOptTasks.length > 0;
1785
1509
 
1786
1510
  // Release lock during LLM dispatch (long-running)
1787
- atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
1511
+ saveEvolutionQueue(queuePath, queue);
1788
1512
  releaseLock();
1789
1513
  lockReleased = true;
1790
1514
 
@@ -1830,7 +1554,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1830
1554
  const manager = new CorrectionObserverWorkflowManager({
1831
1555
  workspaceDir: wctx.workspaceDir,
1832
1556
  logger,
1833
- subagent: api?.runtime?.subagent!,
1557
+ subagent: api?.runtime?.subagent!, /* eslint-disable-line @typescript-eslint/no-non-null-assertion */
1834
1558
  agentSession: api?.runtime?.agent?.session,
1835
1559
  });
1836
1560
 
@@ -1844,7 +1568,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1844
1568
  workflowId = handle.workflowId;
1845
1569
  koTask.resultRef = workflowId;
1846
1570
  } else {
1847
- workflowId = koTask.resultRef!;
1571
+ workflowId = koTask.resultRef!; /* eslint-disable-line @typescript-eslint/no-non-null-assertion */
1848
1572
  }
1849
1573
 
1850
1574
  // Poll workflow state
@@ -1945,7 +1669,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1945
1669
  }
1946
1670
 
1947
1671
  if (queueChanged) {
1948
- atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
1672
+ saveEvolutionQueue(queuePath, queue);
1949
1673
  }
1950
1674
 
1951
1675
  // Pipeline observability: log stage-level summary at end of cycle
@@ -2051,8 +1775,8 @@ export async function registerEvolutionTaskSession(
2051
1775
  }
2052
1776
 
2053
1777
  // V2: Migrate queue to current schema
2054
- const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue);
2055
-
1778
+ const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue) as unknown as EvolutionQueueItem[];
1779
+
2056
1780
  const task = queue.find((item) => item.id === taskId && item.status === 'in_progress');
2057
1781
  if (!task) {
2058
1782
  logger?.warn?.(`[PD:EvolutionWorker] Could not find in-progress evolution task ${taskId} for session assignment`);
@@ -2063,7 +1787,7 @@ export async function registerEvolutionTaskSession(
2063
1787
  if (!task.started_at) {
2064
1788
  task.started_at = new Date().toISOString();
2065
1789
  }
2066
- atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
1790
+ saveEvolutionQueue(queuePath, queue);
2067
1791
  return true;
2068
1792
  } finally {
2069
1793
  releaseLock();
@@ -2134,7 +1858,7 @@ async function processEvolutionQueueWithResult(
2134
1858
  const purgeResult = purgeStaleFailedTasks(queue, logger);
2135
1859
  if (purgeResult.purged > 0) {
2136
1860
  // Write back the cleaned queue
2137
- atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
1861
+ saveEvolutionQueue(queuePath, queue);
2138
1862
  }
2139
1863
 
2140
1864
  queueResult.total = queue.length;