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
@@ -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
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Sleep Cycle Orchestrator — extracted from evolution-worker.ts
3
+ *
4
+ * Responsibilities:
5
+ * - Idle workspace detection via nocturnal-runtime.js
6
+ * - Cooldown enforcement via nocturnal-runtime.js
7
+ * - Sleep reflection task enqueue orchestration (fire-and-forget)
8
+ * - Keyword optimization task enqueue orchestration (fire-and-forget)
9
+ * - Cycle heartbeat tracking and periodic trigger reset
10
+ *
11
+ * Does NOT include (remain in evolution-worker.ts facade):
12
+ * - checkPainFlag, processEvolutionQueueWithResult, processDetectionQueue
13
+ * - Workflow managers (EmpathyObserver, DeepReflect, Nocturnal)
14
+ * - Workflow watchdog (runWorkflowWatchdog)
15
+ * - Pain-flag-triggered immediate heartbeat
16
+ *
17
+ * Dependencies: nocturnal-runtime.js, nocturnal-config.js, queue-io.js
18
+ * Zero imports from evolution-worker.ts.
19
+ */
20
+
21
+ import type { WorkspaceContext } from '../core/workspace-context.js';
22
+ import type { OpenClawPluginApi, PluginLogger } from '../openclaw-sdk.js';
23
+ import type { EventLog } from '../core/event-log.js';
24
+ import { checkWorkspaceIdle, checkCooldown } from './nocturnal-runtime.js';
25
+ import { loadNocturnalConfigMerged } from './nocturnal-config.js';
26
+ import { enqueueSleepReflectionTask, enqueueKeywordOptimizationTask } from './queue-io.js';
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Types
30
+ // ---------------------------------------------------------------------------
31
+
32
+ export interface WorkerStatusReport {
33
+ timestamp: string;
34
+ cycle_start_ms: number;
35
+ duration_ms: number;
36
+ pain_flag: { exists: boolean; score: number | null; source: string | null; enqueued: boolean; skipped_reason: string | null };
37
+ queue: { total: number; pending: number; in_progress: number; completed_this_cycle: number; failed_this_cycle: number };
38
+ errors: string[];
39
+ }
40
+
41
+ export interface CycleOptions {
42
+ wctx: WorkspaceContext;
43
+ logger: PluginLogger | undefined;
44
+ eventLog: EventLog;
45
+ api: OpenClawPluginApi | undefined;
46
+ /** Mutable ref to the heartbeat counter — incremented by runCycle on each call */
47
+ heartbeatCounterRef: { value: number };
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Cycle Orchestrator
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /**
55
+ * Execute one sleep-cycle heartbeat.
56
+ *
57
+ * Orchestrates:
58
+ * 1. Load merged nocturnal config
59
+ * 2. Check workspace idle state
60
+ * 3. Enqueue keyword_optimization task (independent periodic trigger)
61
+ * 4. Enqueue sleep_reflection task (idle-based OR periodic trigger + cooldown gate)
62
+ * 5. Cycle result reporting
63
+ *
64
+ * Does NOT directly call checkPainFlag, processEvolutionQueueWithResult, or
65
+ * processDetectionQueue — those remain in the evolution-worker.ts facade.
66
+ *
67
+ * @param options.wctx — workspace context
68
+ * @param options.logger — plugin logger
69
+ * @param options.eventLog — event log
70
+ * @param options.api — OpenClaw plugin API (optional)
71
+ * @param options.heartbeatCounterRef — mutable counter, incremented by runCycle
72
+ */
73
+ export async function runCycle(options: CycleOptions): Promise<WorkerStatusReport> {
74
+ const { wctx, logger, eventLog: _eventLog, api: _api, heartbeatCounterRef } = options;
75
+ const cycleStart = Date.now();
76
+ heartbeatCounterRef.value++;
77
+
78
+ // ──── DEBUG: Verify subagent availability in heartbeat context ────
79
+ const hbSubagent = _api?.runtime?.subagent;
80
+ logger?.info?.(`[PD:DEBUG:SubagentCheck:Heartbeat] api_exists=${!!_api}, subagent_exists=${!!hbSubagent}, subagent.run_exists=${!!hbSubagent?.run}, heartbeatCounter=${heartbeatCounterRef.value}`);
81
+ if (hbSubagent?.run) {
82
+ logger?.info?.('[PD:DEBUG:SubagentCheck:Heartbeat] run entrypoint is callable');
83
+ }
84
+
85
+ const cycleResult: WorkerStatusReport = {
86
+ timestamp: new Date().toISOString(),
87
+ cycle_start_ms: cycleStart,
88
+ duration_ms: 0,
89
+ pain_flag: { exists: false, score: null, source: null, enqueued: false, skipped_reason: null },
90
+ queue: { total: 0, pending: 0, in_progress: 0, completed_this_cycle: 0, failed_this_cycle: 0 },
91
+ errors: [],
92
+ };
93
+
94
+ try {
95
+ // Load config on each cycle (supports runtime updates) — single file read
96
+ const mergedConfig = loadNocturnalConfigMerged(wctx.stateDir);
97
+ const { sleepReflection: sleepConfig, keywordOptimization: kwOptConfig } = mergedConfig;
98
+
99
+ const idleResult = checkWorkspaceIdle(wctx.workspaceDir, {});
100
+ logger?.info?.(`[PD:EvolutionWorker] HEARTBEAT cycle=${new Date().toISOString()} idle=${idleResult.isIdle} idleForMs=${idleResult.idleForMs} userActiveSessions=${idleResult.userActiveSessions} abandonedSessions=${idleResult.abandonedSessionIds.length} lastActivityEpoch=${idleResult.mostRecentActivityAt} triggerMode=${sleepConfig.trigger_mode}`);
101
+
102
+ let shouldTrySleepReflection = false;
103
+
104
+ // Path 1: Idle-based trigger (default mode)
105
+ if (idleResult.isIdle && sleepConfig.trigger_mode === 'idle') {
106
+ logger?.info?.(`[PD:EvolutionWorker] Workspace idle (${idleResult.idleForMs}ms since last activity)`);
107
+ shouldTrySleepReflection = true;
108
+ }
109
+
110
+ // keyword_optimization: Independent periodic trigger (CORR-07).
111
+ // Fires every kwOptConfig.period_heartbeats regardless of trigger_mode.
112
+ // Has its own dedicated config (default 24 heartbeats = 6 hours).
113
+ if (kwOptConfig.enabled && heartbeatCounterRef.value > 0 && heartbeatCounterRef.value % kwOptConfig.period_heartbeats === 0) {
114
+ logger?.info?.(`[PD:EvolutionWorker] keyword_optimization trigger at heartbeat ${heartbeatCounterRef.value} (trigger_mode=${sleepConfig.trigger_mode})`);
115
+ enqueueKeywordOptimizationTask(wctx, logger).catch((err) => {
116
+ logger?.error?.(`[PD:EvolutionWorker] Failed to enqueue keyword_optimization task: ${String(err)}`);
117
+ });
118
+ }
119
+
120
+ // Path 2: Periodic trigger for sleep_reflection (fires regardless of idle state)
121
+ if (sleepConfig.trigger_mode === 'periodic') {
122
+ if (heartbeatCounterRef.value >= sleepConfig.period_heartbeats) {
123
+ logger?.info?.(`[PD:EvolutionWorker] Periodic trigger: heartbeatCounter=${heartbeatCounterRef.value} >= period_heartbeats=${sleepConfig.period_heartbeats}`);
124
+ shouldTrySleepReflection = true;
125
+ heartbeatCounterRef.value = 0; // Reset counter
126
+ } else {
127
+ logger?.info?.(`[PD:EvolutionWorker] Periodic: ${heartbeatCounterRef.value}/${sleepConfig.period_heartbeats} heartbeats — waiting`);
128
+ }
129
+ }
130
+
131
+ if (shouldTrySleepReflection) {
132
+ const cooldown = checkCooldown(wctx.stateDir, undefined, {
133
+ globalCooldownMs: sleepConfig.cooldown_ms,
134
+ maxRunsPerWindow: sleepConfig.max_runs_per_day,
135
+ quotaWindowMs: 24 * 60 * 60 * 1000,
136
+ });
137
+ logger?.info?.(`[PD:EvolutionWorker] Cooldown check: globalCooldownActive=${cooldown.globalCooldownActive} quotaExhausted=${cooldown.quotaExhausted} runsRemaining=${cooldown.runsRemaining}`);
138
+ if (!cooldown.globalCooldownActive && !cooldown.quotaExhausted) {
139
+ logger?.info?.('[PD:EvolutionWorker] Attempting to enqueue sleep_reflection task...');
140
+ enqueueSleepReflectionTask(wctx, logger).catch((err) => {
141
+ logger?.error?.(`[PD:EvolutionWorker] Failed to enqueue sleep_reflection task: ${String(err)}`);
142
+ });
143
+ } else {
144
+ logger?.info?.(`[PD:EvolutionWorker] Skipping sleep_reflection: globalCooldown=${cooldown.globalCooldownActive} quotaExhausted=${cooldown.quotaExhausted}`);
145
+ }
146
+ }
147
+
148
+ cycleResult.duration_ms = Date.now() - cycleStart;
149
+ } catch (err) {
150
+ const errMsg = `Error in runCycle: ${String(err)}`;
151
+ if (logger) logger.error(`[PD:EvolutionWorker] ${errMsg}`);
152
+ cycleResult.errors.push(errMsg);
153
+ cycleResult.duration_ms = Date.now() - cycleStart;
154
+ }
155
+
156
+ return cycleResult;
157
+ }
@@ -56,7 +56,7 @@ export interface CleanupParams {
56
56
  }
57
57
 
58
58
 
59
- type PluginRuntimeSubagent = {
59
+ export type PluginRuntimeSubagent = {
60
60
  run: (params: {
61
61
  sessionKey: string;
62
62
  message: string;