principles-disciple 1.41.0 → 1.43.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 (95) 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/archive-impl.ts +5 -3
  11. package/src/commands/context.ts +1 -0
  12. package/src/commands/disable-impl.ts +1 -1
  13. package/src/commands/evolution-status.ts +2 -2
  14. package/src/commands/pain.ts +12 -5
  15. package/src/commands/principle-rollback.ts +1 -1
  16. package/src/commands/promote-impl.ts +13 -7
  17. package/src/commands/rollback.ts +10 -4
  18. package/src/commands/samples.ts +1 -1
  19. package/src/commands/thinking-os.ts +1 -0
  20. package/src/commands/workflow-debug.ts +1 -1
  21. package/src/core/config.ts +1 -0
  22. package/src/core/dictionary.ts +1 -0
  23. package/src/core/event-log.ts +8 -6
  24. package/src/core/evolution-types.ts +33 -1
  25. package/src/core/external-training-contract.ts +1 -1
  26. package/src/core/merge-gate-audit.ts +3 -3
  27. package/src/core/nocturnal-arbiter.ts +1 -1
  28. package/src/core/nocturnal-compliance.ts +21 -21
  29. package/src/core/nocturnal-executability.ts +1 -1
  30. package/src/core/nocturnal-reasoning-deriver.ts +4 -4
  31. package/src/core/nocturnal-rule-implementation-validator.ts +1 -1
  32. package/src/core/nocturnal-snapshot-contract.ts +1 -1
  33. package/src/core/pain-context-extractor.ts +2 -2
  34. package/src/core/path-resolver.ts +1 -0
  35. package/src/core/pd-task-reconciler.ts +1 -0
  36. package/src/core/pd-task-service.ts +1 -1
  37. package/src/core/pd-task-store.ts +1 -0
  38. package/src/core/principle-internalization/deprecated-readiness.ts +1 -1
  39. package/src/core/principle-internalization/principle-lifecycle-service.ts +1 -1
  40. package/src/core/principle-training-state.ts +2 -2
  41. package/src/core/principle-tree-migration.ts +1 -1
  42. package/src/core/replay-engine.ts +1 -0
  43. package/src/core/risk-calculator.ts +2 -1
  44. package/src/core/rule-host.ts +1 -1
  45. package/src/core/session-tracker.ts +1 -0
  46. package/src/core/shadow-observation-registry.ts +1 -1
  47. package/src/core/thinking-models.ts +1 -1
  48. package/src/core/thinking-os-parser.ts +1 -1
  49. package/src/core/trajectory.ts +2 -0
  50. package/src/hooks/bash-risk.ts +2 -2
  51. package/src/hooks/edit-verification.ts +3 -3
  52. package/src/hooks/gate.ts +8 -8
  53. package/src/hooks/gfi-gate.ts +2 -2
  54. package/src/hooks/lifecycle-routing.ts +1 -1
  55. package/src/hooks/message-sanitize.ts +18 -5
  56. package/src/hooks/pain.ts +2 -2
  57. package/src/hooks/progressive-trust-gate.ts +3 -3
  58. package/src/hooks/prompt.ts +17 -4
  59. package/src/hooks/subagent.ts +2 -3
  60. package/src/hooks/thinking-checkpoint.ts +1 -1
  61. package/src/http/principles-console-route.ts +21 -4
  62. package/src/service/central-database.ts +3 -2
  63. package/src/service/central-health-service.ts +2 -1
  64. package/src/service/central-overview-service.ts +3 -2
  65. package/src/service/control-ui-query-service.ts +2 -2
  66. package/src/service/event-log-auditor.ts +2 -2
  67. package/src/service/evolution-query-service.ts +1 -1
  68. package/src/service/evolution-worker.ts +96 -370
  69. package/src/service/health-query-service.ts +11 -10
  70. package/src/service/monitoring-query-service.ts +4 -4
  71. package/src/service/nocturnal-target-selector.ts +2 -2
  72. package/src/service/queue-io.ts +375 -0
  73. package/src/service/queue-migration.ts +122 -0
  74. package/src/service/runtime-summary-service.ts +1 -1
  75. package/src/service/sleep-cycle.ts +157 -0
  76. package/src/service/subagent-workflow/empathy-observer-workflow-manager.ts +1 -0
  77. package/src/service/subagent-workflow/runtime-direct-driver.ts +1 -1
  78. package/src/service/subagent-workflow/subagent-error-utils.ts +1 -1
  79. package/src/service/subagent-workflow/workflow-store.ts +3 -2
  80. package/src/service/workflow-watchdog.ts +168 -0
  81. package/src/tools/critique-prompt.ts +1 -1
  82. package/src/tools/deep-reflect.ts +22 -11
  83. package/src/tools/model-index.ts +1 -1
  84. package/src/types/event-payload.ts +80 -0
  85. package/src/types/queue.ts +70 -0
  86. package/src/utils/file-lock.ts +2 -2
  87. package/src/utils/io.ts +11 -3
  88. package/tests/core/evolution-migration.test.ts +325 -1
  89. package/tests/core/queue-purge.test.ts +337 -0
  90. package/tests/fixtures/legacy-queue-v1.json +74 -0
  91. package/tests/queue/async-lock.test.ts +200 -0
  92. package/tests/service/evolution-worker.queue.test.ts +296 -0
  93. package/tests/service/queue-io.test.ts +229 -0
  94. package/tests/service/queue-migration.test.ts +147 -0
  95. package/tests/service/workflow-watchdog.test.ts +372 -0
@@ -1,7 +1,9 @@
1
+
2
+
1
3
  /* global NodeJS */
4
+
2
5
  import * as fs from 'fs';
3
6
  import * as path from 'path';
4
- import { createHash } from 'crypto';
5
7
  import type { OpenClawPluginServiceContext, OpenClawPluginApi, PluginLogger } from '../openclaw-sdk.js';
6
8
  import { DictionaryService } from '../core/dictionary-service.js';
7
9
  import { DetectionService } from '../core/detection-service.js';
@@ -12,11 +14,20 @@ import type { EventLog } from '../core/event-log.js';
12
14
  import { initPersistence, flushAllSessions } from '../core/session-tracker.js';
13
15
  import { addDiagnosticianTask, completeDiagnosticianTask } from '../core/diagnostician-task-store.js';
14
16
  import { getEvolutionLogger } from '../core/evolution-logger.js';
17
+ import type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
18
+ export type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
15
19
  import { atomicWriteFileSync } from '../utils/io.js';
20
+
21
+ // Re-export queue I/O (extracted to queue-io.ts)
22
+ export { loadEvolutionQueue, saveEvolutionQueue, withQueueLock, acquireQueueLock, requireQueueLock } from './queue-io.js';
23
+ export { enqueueSleepReflectionTask, enqueueKeywordOptimizationTask } from './queue-io.js';
24
+ export { EVOLUTION_QUEUE_LOCK_SUFFIX, LOCK_MAX_RETRIES, LOCK_RETRY_DELAY_MS, LOCK_STALE_MS } from './queue-io.js';
25
+ import { saveEvolutionQueue, requireQueueLock, hasPendingTask, enqueueSleepReflectionTask, enqueueKeywordOptimizationTask, createEvolutionTaskId } from './queue-io.js';
26
+ import type { RecentPainContext } from './queue-io.js';
27
+ export type { RecentPainContext } from './queue-io.js';
16
28
  import { checkWorkspaceIdle, checkCooldown, recordCooldown } from './nocturnal-runtime.js';
17
29
  import { loadCooldownEscalationConfig, loadNocturnalConfigMerged } from './nocturnal-config.js';
18
30
  import { WorkflowStore } from './subagent-workflow/workflow-store.js';
19
- import type { WorkflowRow } from './subagent-workflow/types.js';
20
31
  import { EmpathyObserverWorkflowManager } from './subagent-workflow/empathy-observer-workflow-manager.js';
21
32
  import { DeepReflectWorkflowManager } from './subagent-workflow/deep-reflect-workflow-manager.js';
22
33
  import { NocturnalWorkflowManager, nocturnalWorkflowSpec } from './subagent-workflow/nocturnal-workflow-manager.js';
@@ -29,168 +40,58 @@ import { validateNocturnalSnapshotIngress } from '../core/nocturnal-snapshot-con
29
40
  import { isExpectedSubagentError } from './subagent-workflow/subagent-error-utils.js';
30
41
  import { readPainFlagContract } from '../core/pain.js';
31
42
  import { CorrectionObserverWorkflowManager, correctionObserverWorkflowSpec } from './subagent-workflow/correction-observer-workflow-manager.js';
43
+ import { findRecentDuplicateTask } from './evolution-dedup.js';
32
44
  import type { CorrectionObserverPayload } from './subagent-workflow/correction-observer-types.js';
33
45
  import { KeywordOptimizationService } from './keyword-optimization-service.js';
34
46
  import { TrajectoryRegistry } from '../core/trajectory.js';
35
47
  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
48
  import { classifyFailure, type ClassifiableTaskKind } from './failure-classifier.js';
43
49
  import { recordPersistentFailure, resetFailureState, isTaskKindInCooldown } from './cooldown-strategy.js';
44
50
  import { reconcileStartup } from './startup-reconciler.js';
45
51
  import { WORKFLOW_TTL_MS } from '../config/defaults/runtime.js';
46
52
  import { OpenClawTrinityRuntimeAdapter } from '../core/nocturnal-trinity.js';
47
53
 
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
- }
54
+ // ── Queue Event Payload Validation ─────────────────────────────────────────
60
55
 
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 });
56
+ /**
57
+ * Validates a queue event payload string before JSON.parse.
58
+ * Checks:
59
+ * 1. typeof payload === 'string'
60
+ * 2. Parsed object has required fields: 'type' and 'workspaceId'
61
+ * Returns the parsed object only if validation passes.
62
+ * Returns empty object {} if payload is falsy.
63
+ * Throws Error if payload is a non-empty string that fails validation.
64
+ */
65
+ function validateQueueEventPayload(payload: string | null | undefined): Record<string, unknown> {
66
+ if (!payload) return {};
67
+ if (typeof payload !== 'string') {
68
+ throw new Error(`Queue event payload must be a string, got: ${typeof payload}`);
69
+ }
73
70
  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
- }
71
+ const parsed = JSON.parse(payload);
72
+ if (typeof parsed !== 'object' || parsed === null) {
73
+ throw new Error('Queue event payload must be a JSON object');
131
74
  }
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();
75
+ if (!('type' in parsed) || !('workspaceId' in parsed)) {
76
+ throw new Error('Queue event payload missing required fields: type, workspaceId');
77
+ }
78
+ return parsed;
79
+ } catch (err) {
80
+ if (err instanceof SyntaxError) {
81
+ throw new Error(`Invalid JSON in queue event payload: ${err.message}`);
82
+ }
83
+ throw err;
186
84
  }
187
- } catch (err) {
188
- logger?.warn?.(`[PD:Watchdog] Failed to scan workflows: ${String(err)}`);
189
- }
190
-
191
- return { anomalies: details.length, details };
192
85
  }
193
86
 
87
+ /* istanbul ignore next — test export for validateQueueEventPayload */
88
+ export { validateQueueEventPayload };
89
+
90
+ // Re-export workflow watchdog (extracted to workflow-watchdog.ts)
91
+ import { runWorkflowWatchdog, type WatchdogResult } from './workflow-watchdog.js';
92
+ export { runWorkflowWatchdog };
93
+ export type { WatchdogResult };
94
+
194
95
  let timeoutId: NodeJS.Timeout | null = null;
195
96
 
196
97
  /**
@@ -205,27 +106,6 @@ let timeoutId: NodeJS.Timeout | null = null;
205
106
  export type QueueStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'canceled';
206
107
  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
108
 
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
109
  export interface EvolutionQueueItem {
230
110
  // Core identity
231
111
  id: string;
@@ -263,6 +143,11 @@ export interface EvolutionQueueItem {
263
143
  recentPainContext?: RecentPainContext;
264
144
  }
265
145
 
146
+ // ── Queue Migration (extracted to queue-migration.ts) ────────────────────────
147
+ import { migrateToV2, isLegacyQueueItem, migrateQueueToV2, LegacyEvolutionQueueItem, DEFAULT_TASK_KIND, DEFAULT_PRIORITY, DEFAULT_MAX_RETRIES, type RawQueueItem } from './queue-migration.js';
148
+ export { migrateToV2, isLegacyQueueItem, migrateQueueToV2, LegacyEvolutionQueueItem, DEFAULT_TASK_KIND, DEFAULT_PRIORITY, DEFAULT_MAX_RETRIES };
149
+ export type { RawQueueItem };
150
+
266
151
  function isSessionAtOrBeforeTriggerTime(
267
152
  session: { startedAt: string; updatedAt: string },
268
153
  triggerTimeMs: number,
@@ -281,6 +166,7 @@ function isSessionAtOrBeforeTriggerTime(
281
166
  return true;
282
167
  }
283
168
 
169
+
284
170
  function buildFallbackNocturnalSnapshot(
285
171
  sleepTask: EvolutionQueueItem,
286
172
  extractor?: ReturnType<typeof createNocturnalTrajectoryExtractor> | null,
@@ -347,20 +233,9 @@ function buildFallbackNocturnalSnapshot(
347
233
  };
348
234
  }
349
235
 
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
- }
236
+ const PAIN_QUEUE_DEDUP_WINDOW_MS = 30 * 60 * 1000;
237
+
238
+ // Queue lock constants and requireQueueLock are imported from queue-io.ts
364
239
 
365
240
  export function extractEvolutionTaskId(task: string): string | null {
366
241
  if (!task) return null;
@@ -409,180 +284,31 @@ export function purgeStaleFailedTasks(
409
284
  return { purged: purged.length, remaining: queue.length, byReason };
410
285
  }
411
286
 
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
- );
287
+ function normalizePainDedupKey(source: string, preview: string, reason?: string): string {
288
+ // Include reason in dedup key to match createEvolutionTaskId() behavior
289
+ // Different reasons for the same source/preview should create different tasks
290
+ const normalizedReason = (reason || '').trim().toLowerCase();
291
+ return `${source.trim().toLowerCase()}::${preview.trim().toLowerCase()}::${normalizedReason}`;
420
292
  }
421
293
 
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);
463
- }
464
-
465
- /**
466
- * Build and persist a new sleep_reflection task.
467
- */
468
294
 
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}`);
295
+
296
+ export function hasRecentDuplicateTask(queue: EvolutionQueueItem[], source: string, preview: string, now: number, reason?: string): boolean {
297
+ return !!findRecentDuplicateTask(queue, source, preview, now, reason);
497
298
  }
498
299
 
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;
300
+ export function hasEquivalentPromotedRule(dictionary: { getAllRules(): Record<string, { type: string; phrases?: string[]; pattern?: string; status: string; }> }, phrase: string): boolean {
301
+ const normalizedPhrase = phrase.trim().toLowerCase();
302
+ return Object.values(dictionary.getAllRules()).some((rule) => {
303
+ if (rule.status !== 'active') return false;
304
+ if (rule.type === 'exact_match' && Array.isArray(rule.phrases)) {
305
+ return rule.phrases.some((candidate) => candidate.trim().toLowerCase() === normalizedPhrase);
519
306
  }
520
-
521
- // Guard 2: Dedup — skip if similar reflection completed recently
522
- if (shouldSkipForDedup(queue, wctx, logger)) {
523
- return;
307
+ if (rule.type === 'regex' && typeof rule.pattern === 'string') {
308
+ return rule.pattern.trim().toLowerCase() === normalizedPhrase;
524
309
  }
525
-
526
- // Enqueue the new task
527
- const recentPainContext = readRecentPainContext(wctx);
528
- enqueueNewSleepReflectionTask(queue, recentPainContext, queuePath, logger);
529
- } finally {
530
- releaseLock();
531
- }
532
- }
533
-
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;
553
- }
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;
560
- }
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
- }
310
+ return false;
311
+ });
586
312
  }
587
313
 
588
314
  interface ParsedPainValues {
@@ -637,7 +363,7 @@ async function doEnqueuePainTask(
637
363
  retryCount: 0, maxRetries: 3,
638
364
  });
639
365
 
640
- atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
366
+ saveEvolutionQueue(queuePath, queue);
641
367
  fs.appendFileSync(painFlagPath, `\nstatus: queued\ntask_id: ${taskId}\n`, 'utf8');
642
368
  result.enqueued = true;
643
369
 
@@ -872,7 +598,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
872
598
  }
873
599
 
874
600
  // V2: Migrate queue to current schema if needed
875
- const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue);
601
+ const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue) as unknown as EvolutionQueueItem[];
876
602
 
877
603
  let queueChanged = rawQueue.some(isLegacyQueueItem);
878
604
 
@@ -910,13 +636,13 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
910
636
  e.event_type.includes('failed') || e.event_type.includes('error')
911
637
  ).pop();
912
638
  if (failureEvent) {
913
- const payload = failureEvent.payload_json ? JSON.parse(failureEvent.payload_json) : {};
639
+ const payload = validateQueueEventPayload(failureEvent.payload_json);
914
640
  detailedError = `sleep_reflection failed: ${failureEvent.reason}`;
915
641
  if (payload.skipReason) {
916
642
  detailedError += ` (skipReason: ${payload.skipReason})`;
917
643
  }
918
- if (payload.failures && payload.failures.length > 0) {
919
- detailedError += ` | failures: ${payload.failures.slice(0, 3).join(', ')}`;
644
+ if (payload.failures && Array.isArray(payload.failures) && payload.failures.length > 0) {
645
+ detailedError += ` | failures: ${(payload.failures as string[]).slice(0, 3).join(', ')}`;
920
646
  }
921
647
  }
922
648
  } catch (fetchErr) {
@@ -1432,7 +1158,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1432
1158
 
1433
1159
  // Write claimed state (includes any pain changes from above) and release lock
1434
1160
  if (queueChanged) {
1435
- atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
1161
+ saveEvolutionQueue(queuePath, queue);
1436
1162
  }
1437
1163
  releaseLock();
1438
1164
  // Phase 40: Track outcomes for failure classification after queue write
@@ -1611,15 +1337,15 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1611
1337
 
1612
1338
  try {
1613
1339
  payload = lastEvent?.payload ?? {};
1614
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1340
+
1615
1341
  if ((payload as any).skipReason) {
1616
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1342
+
1617
1343
  detailedError += ` (skipReason: ${(payload as any).skipReason})`;
1618
1344
 
1619
1345
  }
1620
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1346
+
1621
1347
  if ((payload as any).failures && Array.isArray((payload as any).failures) && (payload as any).failures.length > 0) {
1622
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1348
+
1623
1349
  detailedError += ` | failures: ${((payload as any).failures as string[]).slice(0, 3).join(', ')}`;
1624
1350
  }
1625
1351
  } catch { /* ignore parse errors */ }
@@ -1635,7 +1361,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1635
1361
  sleepOutcomes.push({ taskKind: 'sleep_reflection', succeeded: true });
1636
1362
 
1637
1363
  logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} background runtime unavailable, using stub fallback: ${errorReason}`);
1638
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1364
+
1639
1365
  } else if ((payload as any).skipReason === 'no_violating_sessions') {
1640
1366
  // #244: No meaningful violations found (thin filter) → skip without failure
1641
1367
  sleepTask.status = 'completed';
@@ -1768,7 +1494,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1768
1494
  if (keywordOptTasks.length > 0) {
1769
1495
  // Skip all keyword_optimization tasks this cycle; release lock and return
1770
1496
  if (queueChanged) {
1771
- atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
1497
+ saveEvolutionQueue(queuePath, queue);
1772
1498
  }
1773
1499
  releaseLock();
1774
1500
  lockReleased = true;
@@ -1784,7 +1510,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1784
1510
  queueChanged = queueChanged || pendingKeywordOptTasks.length > 0;
1785
1511
 
1786
1512
  // Release lock during LLM dispatch (long-running)
1787
- atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
1513
+ saveEvolutionQueue(queuePath, queue);
1788
1514
  releaseLock();
1789
1515
  lockReleased = true;
1790
1516
 
@@ -1830,7 +1556,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1830
1556
  const manager = new CorrectionObserverWorkflowManager({
1831
1557
  workspaceDir: wctx.workspaceDir,
1832
1558
  logger,
1833
- subagent: api?.runtime?.subagent!,
1559
+ subagent: api?.runtime?.subagent!, /* eslint-disable-line @typescript-eslint/no-non-null-assertion */
1834
1560
  agentSession: api?.runtime?.agent?.session,
1835
1561
  });
1836
1562
 
@@ -1844,7 +1570,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1844
1570
  workflowId = handle.workflowId;
1845
1571
  koTask.resultRef = workflowId;
1846
1572
  } else {
1847
- workflowId = koTask.resultRef!;
1573
+ workflowId = koTask.resultRef!; /* eslint-disable-line @typescript-eslint/no-non-null-assertion */
1848
1574
  }
1849
1575
 
1850
1576
  // Poll workflow state
@@ -1945,7 +1671,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1945
1671
  }
1946
1672
 
1947
1673
  if (queueChanged) {
1948
- atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
1674
+ saveEvolutionQueue(queuePath, queue);
1949
1675
  }
1950
1676
 
1951
1677
  // Pipeline observability: log stage-level summary at end of cycle
@@ -2051,8 +1777,8 @@ export async function registerEvolutionTaskSession(
2051
1777
  }
2052
1778
 
2053
1779
  // V2: Migrate queue to current schema
2054
- const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue);
2055
-
1780
+ const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue) as unknown as EvolutionQueueItem[];
1781
+
2056
1782
  const task = queue.find((item) => item.id === taskId && item.status === 'in_progress');
2057
1783
  if (!task) {
2058
1784
  logger?.warn?.(`[PD:EvolutionWorker] Could not find in-progress evolution task ${taskId} for session assignment`);
@@ -2063,7 +1789,7 @@ export async function registerEvolutionTaskSession(
2063
1789
  if (!task.started_at) {
2064
1790
  task.started_at = new Date().toISOString();
2065
1791
  }
2066
- atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
1792
+ saveEvolutionQueue(queuePath, queue);
2067
1793
  return true;
2068
1794
  } finally {
2069
1795
  releaseLock();
@@ -2134,7 +1860,7 @@ async function processEvolutionQueueWithResult(
2134
1860
  const purgeResult = purgeStaleFailedTasks(queue, logger);
2135
1861
  if (purgeResult.purged > 0) {
2136
1862
  // Write back the cleaned queue
2137
- atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
1863
+ saveEvolutionQueue(queuePath, queue);
2138
1864
  }
2139
1865
 
2140
1866
  queueResult.total = queue.length;