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,3 +1,4 @@
1
+ /* eslint-disable no-console */
1
2
  import * as fs from 'fs';
2
3
  import * as path from 'path';
3
4
  import { readPainFlagData } from '../core/pain.js';
@@ -554,7 +555,7 @@ export class HealthQueryService {
554
555
  const streamPath = resolvePdPath(this.workspaceDir, 'EVOLUTION_STREAM');
555
556
  if (!fs.existsSync(streamPath)) return [];
556
557
 
557
- // eslint-disable-next-line @typescript-eslint/init-declarations
558
+
558
559
  let lines: string[];
559
560
  try {
560
561
  const raw = fs.readFileSync(streamPath, 'utf8').trim();
@@ -567,7 +568,7 @@ export class HealthQueryService {
567
568
  const records: RecentPrincipleChange[] = [];
568
569
  for (const line of lines) {
569
570
 
570
- // eslint-disable-next-line @typescript-eslint/init-declarations
571
+
571
572
  let event: EvolutionStreamRecord | null;
572
573
  try {
573
574
  event = JSON.parse(line) as EvolutionStreamRecord;
@@ -788,7 +789,7 @@ export class HealthQueryService {
788
789
 
789
790
 
790
791
 
791
- // eslint-disable-next-line @typescript-eslint/class-methods-use-this -- complexity 15, refactor candidate
792
+
792
793
  private getEventDedupKey(entry: EventLogEntry): string {
793
794
  const eventId = typeof entry.data?.eventId === 'string' ? entry.data.eventId : null;
794
795
  if (eventId) {
@@ -860,7 +861,7 @@ export class HealthQueryService {
860
861
 
861
862
 
862
863
 
863
- // eslint-disable-next-line @typescript-eslint/class-methods-use-this
864
+
864
865
  private resolveGateType(row: GateBlockRow): string {
865
866
  if (typeof row.gate_type === 'string' && row.gate_type.trim().length > 0) {
866
867
  return row.gate_type;
@@ -885,7 +886,7 @@ export class HealthQueryService {
885
886
  }
886
887
 
887
888
 
888
- // eslint-disable-next-line @typescript-eslint/class-methods-use-this
889
+
889
890
  private scoreToStatus(score: number): string {
890
891
  if (score >= 70) return 'healthy';
891
892
  if (score >= 40) return 'warning';
@@ -893,7 +894,7 @@ export class HealthQueryService {
893
894
  }
894
895
 
895
896
 
896
- // eslint-disable-next-line @typescript-eslint/class-methods-use-this
897
+
897
898
  private evolutionToStatus(tier: string, points: number): string {
898
899
  const lower = tier.toLowerCase();
899
900
  if (lower === 'forest' || lower === 'tree') return 'healthy';
@@ -902,7 +903,7 @@ export class HealthQueryService {
902
903
  }
903
904
 
904
905
 
905
- // eslint-disable-next-line @typescript-eslint/class-methods-use-this
906
+
906
907
  private safeListFiles(dirPath: string, predicate: (_name: string) => boolean): string[] {
907
908
  if (!fs.existsSync(dirPath)) return [];
908
909
  try {
@@ -915,7 +916,7 @@ export class HealthQueryService {
915
916
  }
916
917
 
917
918
 
918
- // eslint-disable-next-line @typescript-eslint/class-methods-use-this
919
+
919
920
  private readJsonFile<T>(filePath: string, fallback: T): T {
920
921
  if (!fs.existsSync(filePath)) return fallback;
921
922
  try {
@@ -926,13 +927,13 @@ export class HealthQueryService {
926
927
  }
927
928
 
928
929
 
929
- // eslint-disable-next-line @typescript-eslint/class-methods-use-this
930
+
930
931
  private asNumber(value: unknown, fallback: number): number {
931
932
  return Number.isFinite(value) ? Number(value) : fallback;
932
933
  }
933
934
 
934
935
 
935
- // eslint-disable-next-line @typescript-eslint/class-methods-use-this
936
+
936
937
  private asNullableNumber(value: unknown): number | null {
937
938
  if (Number.isFinite(value)) return Number(value);
938
939
  if (typeof value === 'string' && value.trim().length > 0) {
@@ -36,7 +36,7 @@ export class MonitoringQueryService {
36
36
  const now = Date.now();
37
37
  const workflowsWithStuckDetection = workflows.map(wf => {
38
38
  // Parse metadata for timeout configuration
39
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
39
+
40
40
  const metadata = parseWorkflowMetadata(wf.metadata_json);
41
41
  const timeoutMs = metadata.timeoutMs ?? 15 * 60 * 1000; // Default 15 minutes
42
42
 
@@ -85,10 +85,10 @@ export class MonitoringQueryService {
85
85
 
86
86
  // Determine status
87
87
 
88
- // eslint-disable-next-line @typescript-eslint/init-declarations
88
+
89
89
  let status: 'pending' | 'running' | 'completed' | 'failed';
90
90
 
91
- // eslint-disable-next-line @typescript-eslint/init-declarations
91
+
92
92
  let reason: string | undefined;
93
93
 
94
94
  if (!startEvent) {
@@ -110,7 +110,7 @@ export class MonitoringQueryService {
110
110
 
111
111
  // Calculate duration if stage started and completed/failed
112
112
 
113
- // eslint-disable-next-line @typescript-eslint/init-declarations
113
+
114
114
  let duration: number | undefined;
115
115
  if (startEvent && (completeEvent || failedEvent)) {
116
116
  const endEvent = completeEvent || failedEvent;
@@ -288,7 +288,7 @@ export class NocturnalTargetSelector {
288
288
  };
289
289
 
290
290
 
291
- // eslint-disable-next-line @typescript-eslint/max-params
291
+
292
292
  constructor(
293
293
  workspaceDir: string,
294
294
  stateDir: string,
@@ -533,7 +533,7 @@ export class NocturnalTargetSelector {
533
533
  * This is a convenience wrapper for the common case.
534
534
  */
535
535
 
536
- // eslint-disable-next-line @typescript-eslint/max-params
536
+
537
537
  export function selectNocturnalTarget(
538
538
  workspaceDir: string,
539
539
  stateDir: string,
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Queue I/O + Enqueue — extracted from evolution-worker.ts
3
+ *
4
+ * Full persistence layer encapsulating queue file locking, atomic writes,
5
+ * queue format, and enqueue orchestration. Depends on file-lock.ts, io.ts,
6
+ * queue-migration.ts, correction-cue-learner.ts, and pain.ts.
7
+ * Zero imports from evolution-worker.ts.
8
+ */
9
+
10
+ import * as fs from 'fs';
11
+ import { createHash } from 'crypto';
12
+ import { acquireLockAsync, releaseLock as releaseImportedLock, type LockContext } from '../utils/file-lock.js';
13
+ import { atomicWriteFileSync } from '../utils/io.js';
14
+ import { LockUnavailableError } from '../config/errors.js';
15
+ import { migrateQueueToV2 } from './queue-migration.js';
16
+ import type { EvolutionQueueItem } from '../core/evolution-types.js';
17
+ import type { RawQueueItem } from './queue-migration.js';
18
+ import type { PluginLogger } from '../openclaw-sdk.js';
19
+ import type { WorkspaceContext } from '../core/workspace-context.js';
20
+ import { CorrectionCueLearner } from '../core/correction-cue-learner.js';
21
+ import { readPainFlagContract } from '../core/pain.js';
22
+
23
+ /**
24
+ * Extended EvolutionQueueItem that includes the recentPainContext field.
25
+ * This field is added inline in evolution-worker.ts but needs to be available
26
+ * in queue-io.ts for the enqueue functions.
27
+ */
28
+ interface EvolutionQueueItemWithPain extends EvolutionQueueItem {
29
+ recentPainContext?: RecentPainContext;
30
+ }
31
+
32
+ export const EVOLUTION_QUEUE_LOCK_SUFFIX = '.lock';
33
+ export const LOCK_MAX_RETRIES = 50;
34
+ export const LOCK_RETRY_DELAY_MS = 50;
35
+ export const LOCK_STALE_MS = 30_000;
36
+
37
+ export const PAIN_QUEUE_DEDUP_WINDOW_MS = 4 * 60 * 60 * 1000; // 4 hours
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // requireQueueLock — thin wrapper that adds LockUnavailableError
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * Acquire a queue lock, throwing LockUnavailableError on failure.
45
+ * This is the standard lock used across all queue operations.
46
+ */
47
+ export async function requireQueueLock(
48
+ resourcePath: string,
49
+ logger: PluginLogger | { warn?: (message: string) => void; info?: (message: string) => void } | undefined,
50
+ scope: string,
51
+ lockSuffix: string = EVOLUTION_QUEUE_LOCK_SUFFIX,
52
+ ): Promise<() => void> {
53
+ try {
54
+ return await acquireQueueLock(resourcePath, logger, lockSuffix);
55
+ } catch (err) {
56
+ throw new LockUnavailableError(resourcePath, scope, { cause: err });
57
+ }
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // RecentPainContext
62
+ // ---------------------------------------------------------------------------
63
+
64
+ export interface RecentPainContext {
65
+ mostRecent: {
66
+ score: number;
67
+ source: string;
68
+ reason: string;
69
+ timestamp: string;
70
+ sessionId: string;
71
+ } | null;
72
+ recentPainCount: number;
73
+ recentMaxPainScore: number;
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Task ID creation
78
+ // ---------------------------------------------------------------------------
79
+
80
+ export function createEvolutionTaskId(
81
+ source: string,
82
+ score: number,
83
+ preview: string,
84
+ reason: string,
85
+ now: number,
86
+ ): string {
87
+ return createHash('md5')
88
+ .update(`${source}:${score}:${preview}:${reason}:${now}`)
89
+ .digest('hex')
90
+ .substring(0, 8);
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Queue helpers
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /**
98
+ * Check whether a specific task kind has a pending or in-progress entry.
99
+ */
100
+ export function hasPendingTask(queue: EvolutionQueueItem[], taskKind: string): boolean {
101
+ return queue.some(
102
+ (t) => t.taskKind === taskKind && (t.status === 'pending' || t.status === 'in_progress'),
103
+ );
104
+ }
105
+
106
+ /**
107
+ * Build a dedup key from pain context.
108
+ * Returns null when no pain context is available (bypasses dedup).
109
+ */
110
+ function buildPainSourceKey(
111
+ painCtx: ReturnType<typeof readRecentPainContext>,
112
+ ): string | null {
113
+ if (!painCtx.mostRecent) return null;
114
+ return `${painCtx.mostRecent.source}::${painCtx.mostRecent.reason?.slice(0, 50) ?? ''}`;
115
+ }
116
+
117
+ /**
118
+ * Check whether a similar sleep_reflection task completed recently.
119
+ */
120
+ function hasRecentSimilarReflection(
121
+ queue: EvolutionQueueItemWithPain[],
122
+ painSourceKey: string,
123
+ now: number,
124
+ ): EvolutionQueueItem | null {
125
+ return queue.find((t) => {
126
+ if (t.taskKind !== 'sleep_reflection') return false;
127
+ if (t.status !== 'completed') return false;
128
+ if (!t.completed_at) return false;
129
+ const age = now - new Date(t.completed_at).getTime();
130
+ if (age > PAIN_QUEUE_DEDUP_WINDOW_MS) return false;
131
+ const taskPainKey = buildPainSourceKey(t.recentPainContext ?? { mostRecent: null, recentPainCount: 0, recentMaxPainScore: 0 });
132
+ if (!taskPainKey) return false;
133
+ return taskPainKey === painSourceKey;
134
+ }) ?? null;
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Pain context
139
+ // ---------------------------------------------------------------------------
140
+
141
+ export function readRecentPainContext(wctx: WorkspaceContext): RecentPainContext {
142
+ const contract = readPainFlagContract(wctx.workspaceDir);
143
+ if (contract.status !== 'valid') {
144
+ return { mostRecent: null, recentPainCount: 0, recentMaxPainScore: 0 };
145
+ }
146
+
147
+ try {
148
+ const score = parseInt(contract.data.score ?? '0', 10) || 0;
149
+ const source = contract.data.source ?? '';
150
+ const reason = contract.data.reason ?? '';
151
+ const timestamp = contract.data.time ?? '';
152
+ const sessionId = contract.data.session_id ?? '';
153
+
154
+ if (score > 0) {
155
+ return {
156
+ mostRecent: { score, source, reason, timestamp, sessionId },
157
+ recentPainCount: 1,
158
+ recentMaxPainScore: score,
159
+ };
160
+ }
161
+ } catch (err) {
162
+ // Best effort — non-fatal, but surface unexpected errors
163
+ /* eslint-disable no-console */
164
+ console.warn(`[queue-io] Failed to read pain context (non-fatal): ${String(err)}`);
165
+ /* eslint-enable no-console */
166
+ }
167
+
168
+ return { mostRecent: null, recentPainCount: 0, recentMaxPainScore: 0 };
169
+ }
170
+
171
+ /**
172
+ * Decide whether to skip enqueuing due to a recent similar reflection.
173
+ */
174
+ export function shouldSkipForDedup(
175
+ queue: EvolutionQueueItemWithPain[],
176
+ wctx: WorkspaceContext,
177
+ logger: PluginLogger | undefined,
178
+ ): boolean {
179
+ const recentPainContext = readRecentPainContext(wctx);
180
+ const painSourceKey = buildPainSourceKey(recentPainContext);
181
+
182
+ if (!painSourceKey) return false;
183
+
184
+ const now = Date.now();
185
+ const recentSimilarReflection = hasRecentSimilarReflection(queue, painSourceKey, now);
186
+
187
+ if (recentSimilarReflection) {
188
+ const completedTime = new Date(recentSimilarReflection.completed_at!).getTime(); /* eslint-disable-line @typescript-eslint/no-non-null-assertion */
189
+ logger?.debug?.(`[PD:EvolutionWorker] Skipping sleep_reflection — similar reflection completed ${Math.round((now - completedTime) / 60000)}min ago (same pain pattern: ${painSourceKey})`);
190
+ return true;
191
+ }
192
+ return false;
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Enqueue functions
197
+ // ---------------------------------------------------------------------------
198
+
199
+ function enqueueNewSleepReflectionTask(
200
+ queue: EvolutionQueueItemWithPain[],
201
+ recentPainContext: ReturnType<typeof readRecentPainContext>,
202
+ queuePath: string,
203
+ logger: PluginLogger | undefined,
204
+ ): void {
205
+ const taskId = createEvolutionTaskId('nocturnal', 50, 'idle workspace', 'Sleep-mode reflection', Date.now());
206
+ const nowIso = new Date().toISOString();
207
+
208
+ queue.push({
209
+ id: taskId,
210
+ taskKind: 'sleep_reflection',
211
+ priority: 'medium',
212
+ score: 50,
213
+ source: 'nocturnal',
214
+ reason: 'Sleep-mode reflection triggered by idle workspace',
215
+ trigger_text_preview: 'Idle workspace detected',
216
+ timestamp: nowIso,
217
+ enqueued_at: nowIso,
218
+ status: 'pending',
219
+ traceId: taskId,
220
+ retryCount: 0,
221
+ maxRetries: 1,
222
+ recentPainContext,
223
+ });
224
+
225
+ // Cast to EvolutionQueueItem[] because saveEvolutionQueue expects the base type
226
+ // but the queue may contain extended fields (recentPainContext) that are
227
+ // serialized as part of the JSON - this is safe at runtime.
228
+ saveEvolutionQueue(queuePath, queue as unknown as EvolutionQueueItem[]);
229
+ logger?.info?.(`[PD:EvolutionWorker] Enqueued sleep_reflection task ${taskId}`);
230
+ }
231
+
232
+ /**
233
+ * Enqueue a sleep_reflection task if one is not already pending.
234
+ */
235
+ export async function enqueueSleepReflectionTask(
236
+ wctx: WorkspaceContext,
237
+ logger: PluginLogger | undefined,
238
+ ): Promise<void> {
239
+ const queuePath = wctx.resolve('EVOLUTION_QUEUE');
240
+ const releaseLock = await requireQueueLock(queuePath, logger, 'enqueueSleepReflection', EVOLUTION_QUEUE_LOCK_SUFFIX);
241
+
242
+ try {
243
+ const queue = loadEvolutionQueue(queuePath);
244
+
245
+ if (hasPendingTask(queue, 'sleep_reflection')) {
246
+ logger?.debug?.('[PD:EvolutionWorker] sleep_reflection task already pending/in-progress, skipping');
247
+ return;
248
+ }
249
+
250
+ if (shouldSkipForDedup(queue, wctx, logger)) {
251
+ return;
252
+ }
253
+
254
+ const recentPainContext = readRecentPainContext(wctx);
255
+ enqueueNewSleepReflectionTask(queue, recentPainContext, queuePath, logger);
256
+ } finally {
257
+ releaseLock();
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Enqueue a keyword_optimization task if one is not already pending/in-progress.
263
+ */
264
+ export async function enqueueKeywordOptimizationTask(
265
+ wctx: WorkspaceContext,
266
+ logger: PluginLogger | undefined,
267
+ ): Promise<void> {
268
+ const queuePath = wctx.resolve('EVOLUTION_QUEUE');
269
+ const releaseLock = await requireQueueLock(queuePath, logger, 'enqueueKeywordOpt', EVOLUTION_QUEUE_LOCK_SUFFIX);
270
+
271
+ try {
272
+ const queue = loadEvolutionQueue(queuePath);
273
+
274
+ if (hasPendingTask(queue, 'keyword_optimization')) {
275
+ logger?.debug?.('[PD:EvolutionWorker] keyword_optimization task already pending/in-progress, skipping');
276
+ return;
277
+ }
278
+
279
+ const learner = CorrectionCueLearner.get(wctx.stateDir);
280
+ if (!learner.canRunKeywordOptimization()) {
281
+ logger?.debug?.('[PD:EvolutionWorker] keyword_optimization throttle exhausted, skipping');
282
+ return;
283
+ }
284
+
285
+ const taskId = createEvolutionTaskId('keyword_optimization', 50, 'keyword optimization', 'Keyword optimization via LLM', Date.now());
286
+ const nowIso = new Date().toISOString();
287
+
288
+ queue.push({
289
+ id: taskId,
290
+ taskKind: 'keyword_optimization',
291
+ priority: 'medium',
292
+ score: 50,
293
+ source: 'correction',
294
+ reason: 'Keyword optimization triggered by heartbeat',
295
+ trigger_text_preview: 'Keyword optimization via LLM',
296
+ timestamp: nowIso,
297
+ enqueued_at: nowIso,
298
+ status: 'pending',
299
+ traceId: taskId,
300
+ retryCount: 0,
301
+ maxRetries: 1,
302
+ });
303
+
304
+ saveEvolutionQueue(queuePath, queue);
305
+ logger?.info?.(`[PD:EvolutionWorker] Enqueued keyword_optimization task ${taskId}`);
306
+ } finally {
307
+ releaseLock();
308
+ }
309
+ }
310
+
311
+ export async function acquireQueueLock(
312
+ resourcePath: string,
313
+ logger: PluginLogger | { warn?: (message: string) => void; info?: (message: string) => void } | undefined,
314
+ lockSuffix: string = EVOLUTION_QUEUE_LOCK_SUFFIX,
315
+ ): Promise<() => void> {
316
+ try {
317
+ const ctx: LockContext = await acquireLockAsync(resourcePath, {
318
+ lockSuffix,
319
+ maxRetries: LOCK_MAX_RETRIES,
320
+ baseRetryDelayMs: LOCK_RETRY_DELAY_MS,
321
+ lockStaleMs: LOCK_STALE_MS,
322
+ });
323
+ return () => releaseImportedLock(ctx);
324
+ } catch (error: unknown) {
325
+ const warn = logger?.warn;
326
+ warn?.(`[PD:EvolutionWorker] Failed to acquire lock for ${resourcePath}: ${String(error)}`);
327
+ throw error;
328
+ }
329
+ }
330
+
331
+ /**
332
+ * RAII-style lock guard — always releases the lock on exceptions.
333
+ */
334
+ export async function withQueueLock<T>(
335
+ resourcePath: string,
336
+ logger: PluginLogger | { warn?: (message: string) => void; info?: (message: string) => void } | undefined,
337
+ scope: string,
338
+ fn: () => Promise<T>,
339
+ ): Promise<T> {
340
+ const releaseLock = await acquireQueueLock(resourcePath, logger, EVOLUTION_QUEUE_LOCK_SUFFIX);
341
+ try {
342
+ return await fn();
343
+ } finally {
344
+ releaseLock();
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Load and migrate the evolution queue. Returns empty array if file doesn't exist.
350
+ */
351
+ export function loadEvolutionQueue(queuePath: string): EvolutionQueueItem[] {
352
+ let rawQueue: RawQueueItem[] = [];
353
+ try {
354
+ rawQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
355
+ } catch (err) {
356
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
357
+ // Queue doesn't exist yet - create empty array
358
+ rawQueue = [];
359
+ } else {
360
+ // Corrupted JSON or other read error — warn and recover with empty queue
361
+ /* eslint-disable no-console */
362
+ console.warn(`[queue-io] Failed to load evolution queue (recovering with empty): ${String(err)}`);
363
+ /* eslint-enable no-console */
364
+ rawQueue = [];
365
+ }
366
+ }
367
+ return migrateQueueToV2(rawQueue) as unknown as EvolutionQueueItem[];
368
+ }
369
+
370
+ /**
371
+ * Atomically write the queue to disk.
372
+ */
373
+ export function saveEvolutionQueue(queuePath: string, queue: EvolutionQueueItem[]): void {
374
+ atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
375
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Queue Migration — extracted from evolution-worker.ts (lines 297-379)
3
+ *
4
+ * Pure data transformation functions for migrating legacy queue items
5
+ * to the V2 schema. Zero I/O, zero imports from evolution-worker.ts.
6
+ */
7
+
8
+ import type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
9
+
10
+ // V2 types (not exported from evolution-types.ts — defined here for self-containment)
11
+ export type QueueStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
12
+ export type TaskResolution = 'success' | 'failure' | 'skipped';
13
+ export interface EvolutionQueueItem {
14
+ id: string;
15
+ taskKind: TaskKind;
16
+ priority: TaskPriority;
17
+ source: string;
18
+ traceId?: string;
19
+ task?: string;
20
+ score: number;
21
+ reason: string;
22
+ timestamp: string;
23
+ enqueued_at?: string;
24
+ started_at?: string;
25
+ completed_at?: string;
26
+ assigned_session_key?: string;
27
+ trigger_text_preview?: string;
28
+ status: QueueStatus;
29
+ resolution?: TaskResolution;
30
+ session_id?: string;
31
+ agent_id?: string;
32
+ retryCount: number;
33
+ maxRetries: number;
34
+ lastError?: string;
35
+ resultRef?: string;
36
+ }
37
+
38
+ /**
39
+ * Legacy queue item shape (pre-V2) for migration compatibility.
40
+ * These items lack taskKind, priority, retryCount, maxRetries, lastError fields.
41
+ */
42
+ export interface LegacyEvolutionQueueItem {
43
+ id: string;
44
+ task?: string;
45
+ score: number;
46
+ source: string;
47
+ reason: string;
48
+ timestamp: string;
49
+ enqueued_at?: string;
50
+ started_at?: string;
51
+ completed_at?: string;
52
+ assigned_session_key?: string;
53
+ trigger_text_preview?: string;
54
+ status?: string;
55
+ resolution?: string;
56
+ session_id?: string;
57
+ agent_id?: string;
58
+ traceId?: string;
59
+ taskKind?: string;
60
+ priority?: string;
61
+ retryCount?: number;
62
+ maxRetries?: number;
63
+ lastError?: string;
64
+ resultRef?: string;
65
+ }
66
+
67
+ /**
68
+ * Default values for new V2 fields when migrating legacy items.
69
+ */
70
+ const DEFAULT_TASK_KIND: TaskKind = 'pain_diagnosis';
71
+ const DEFAULT_PRIORITY: TaskPriority = 'medium';
72
+ const DEFAULT_MAX_RETRIES = 3;
73
+
74
+ export { DEFAULT_TASK_KIND, DEFAULT_PRIORITY, DEFAULT_MAX_RETRIES };
75
+
76
+ export type RawQueueItem = Record<string, unknown>;
77
+
78
+ /**
79
+ * Migrate a legacy queue item to V2 schema.
80
+ * Old items without taskKind are assumed to be pain_diagnosis for backward compatibility.
81
+ */
82
+ export function migrateToV2(item: LegacyEvolutionQueueItem): EvolutionQueueItem {
83
+ return {
84
+ id: item.id,
85
+ taskKind: (item.taskKind as TaskKind) || DEFAULT_TASK_KIND,
86
+ priority: (item.priority as TaskPriority) || DEFAULT_PRIORITY,
87
+ source: item.source,
88
+ traceId: item.traceId,
89
+ task: item.task,
90
+ score: item.score,
91
+ reason: item.reason,
92
+ timestamp: item.timestamp,
93
+ enqueued_at: item.enqueued_at,
94
+ started_at: item.started_at,
95
+ completed_at: item.completed_at,
96
+ assigned_session_key: item.assigned_session_key,
97
+ trigger_text_preview: item.trigger_text_preview,
98
+ status: (item.status as QueueStatus) || 'pending',
99
+ resolution: item.resolution as TaskResolution | undefined,
100
+ session_id: item.session_id,
101
+ agent_id: item.agent_id,
102
+ retryCount: item.retryCount || 0,
103
+ maxRetries: item.maxRetries || DEFAULT_MAX_RETRIES,
104
+ lastError: item.lastError,
105
+ resultRef: item.resultRef,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Check if an item is a legacy (pre-V2) queue item.
111
+ */
112
+ export function isLegacyQueueItem(item: RawQueueItem): boolean {
113
+ return item && typeof item === 'object' && !('taskKind' in item);
114
+ }
115
+
116
+ /**
117
+ * Migrate entire queue to V2 schema if needed.
118
+ * Returns a new array with all items migrated to V2 format.
119
+ */
120
+ export function migrateQueueToV2(queue: RawQueueItem[]): EvolutionQueueItem[] {
121
+ return queue.map(item => isLegacyQueueItem(item) ? migrateToV2(item as unknown as LegacyEvolutionQueueItem) : item as unknown as EvolutionQueueItem);
122
+ }
@@ -420,7 +420,7 @@ export class RuntimeSummaryService {
420
420
  * Queue is the only authoritative execution truth source.
421
421
  */
422
422
 
423
- // eslint-disable-next-line @typescript-eslint/max-params
423
+
424
424
  private static buildDirectiveSummary(
425
425
  queue: QueueItem[] | null,
426
426
  directive: DirectiveFile | null,