principles-disciple 1.7.6 → 1.8.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 (106) hide show
  1. package/dist/commands/context.js +5 -15
  2. package/dist/commands/evolution-status.js +2 -9
  3. package/dist/commands/export.js +61 -8
  4. package/dist/commands/nocturnal-review.d.ts +24 -0
  5. package/dist/commands/nocturnal-review.js +265 -0
  6. package/dist/commands/nocturnal-rollout.d.ts +27 -0
  7. package/dist/commands/nocturnal-rollout.js +671 -0
  8. package/dist/commands/nocturnal-train.d.ts +25 -0
  9. package/dist/commands/nocturnal-train.js +919 -0
  10. package/dist/commands/pain.js +8 -21
  11. package/dist/constants/tools.d.ts +2 -2
  12. package/dist/constants/tools.js +1 -1
  13. package/dist/core/adaptive-thresholds.d.ts +186 -0
  14. package/dist/core/adaptive-thresholds.js +300 -0
  15. package/dist/core/config.d.ts +2 -38
  16. package/dist/core/config.js +6 -61
  17. package/dist/core/event-log.d.ts +1 -2
  18. package/dist/core/event-log.js +0 -3
  19. package/dist/core/evolution-engine.js +1 -21
  20. package/dist/core/evolution-reducer.d.ts +7 -1
  21. package/dist/core/evolution-reducer.js +56 -4
  22. package/dist/core/evolution-types.d.ts +61 -9
  23. package/dist/core/evolution-types.js +31 -9
  24. package/dist/core/external-training-contract.d.ts +276 -0
  25. package/dist/core/external-training-contract.js +269 -0
  26. package/dist/core/local-worker-routing.d.ts +175 -0
  27. package/dist/core/local-worker-routing.js +525 -0
  28. package/dist/core/model-deployment-registry.d.ts +218 -0
  29. package/dist/core/model-deployment-registry.js +503 -0
  30. package/dist/core/model-training-registry.d.ts +295 -0
  31. package/dist/core/model-training-registry.js +475 -0
  32. package/dist/core/nocturnal-arbiter.d.ts +159 -0
  33. package/dist/core/nocturnal-arbiter.js +534 -0
  34. package/dist/core/nocturnal-candidate-scoring.d.ts +137 -0
  35. package/dist/core/nocturnal-candidate-scoring.js +266 -0
  36. package/dist/core/nocturnal-compliance.d.ts +175 -0
  37. package/dist/core/nocturnal-compliance.js +824 -0
  38. package/dist/core/nocturnal-dataset.d.ts +224 -0
  39. package/dist/core/nocturnal-dataset.js +443 -0
  40. package/dist/core/nocturnal-executability.d.ts +85 -0
  41. package/dist/core/nocturnal-executability.js +331 -0
  42. package/dist/core/nocturnal-export.d.ts +124 -0
  43. package/dist/core/nocturnal-export.js +275 -0
  44. package/dist/core/nocturnal-paths.d.ts +124 -0
  45. package/dist/core/nocturnal-paths.js +214 -0
  46. package/dist/core/nocturnal-trajectory-extractor.d.ts +242 -0
  47. package/dist/core/nocturnal-trajectory-extractor.js +307 -0
  48. package/dist/core/nocturnal-trinity.d.ts +311 -0
  49. package/dist/core/nocturnal-trinity.js +880 -0
  50. package/dist/core/paths.d.ts +6 -0
  51. package/dist/core/paths.js +6 -0
  52. package/dist/core/principle-training-state.d.ts +121 -0
  53. package/dist/core/principle-training-state.js +321 -0
  54. package/dist/core/promotion-gate.d.ts +238 -0
  55. package/dist/core/promotion-gate.js +529 -0
  56. package/dist/core/session-tracker.d.ts +10 -0
  57. package/dist/core/session-tracker.js +14 -0
  58. package/dist/core/shadow-observation-registry.d.ts +217 -0
  59. package/dist/core/shadow-observation-registry.js +308 -0
  60. package/dist/core/training-program.d.ts +233 -0
  61. package/dist/core/training-program.js +433 -0
  62. package/dist/core/trajectory.d.ts +95 -1
  63. package/dist/core/trajectory.js +220 -6
  64. package/dist/core/workspace-context.d.ts +0 -6
  65. package/dist/core/workspace-context.js +0 -12
  66. package/dist/hooks/bash-risk.d.ts +6 -6
  67. package/dist/hooks/bash-risk.js +8 -8
  68. package/dist/hooks/gate-block-helper.js +1 -1
  69. package/dist/hooks/gate.d.ts +1 -1
  70. package/dist/hooks/gate.js +2 -2
  71. package/dist/hooks/gfi-gate.d.ts +3 -3
  72. package/dist/hooks/gfi-gate.js +15 -14
  73. package/dist/hooks/pain.js +6 -9
  74. package/dist/hooks/progressive-trust-gate.d.ts +21 -49
  75. package/dist/hooks/progressive-trust-gate.js +51 -204
  76. package/dist/hooks/prompt.d.ts +11 -11
  77. package/dist/hooks/prompt.js +158 -72
  78. package/dist/hooks/subagent.js +43 -6
  79. package/dist/i18n/commands.js +8 -8
  80. package/dist/index.js +129 -28
  81. package/dist/service/evolution-worker.d.ts +42 -4
  82. package/dist/service/evolution-worker.js +321 -13
  83. package/dist/service/nocturnal-runtime.d.ts +183 -0
  84. package/dist/service/nocturnal-runtime.js +352 -0
  85. package/dist/service/nocturnal-service.d.ts +163 -0
  86. package/dist/service/nocturnal-service.js +787 -0
  87. package/dist/service/nocturnal-target-selector.d.ts +145 -0
  88. package/dist/service/nocturnal-target-selector.js +315 -0
  89. package/dist/service/phase3-input-filter.d.ts +2 -23
  90. package/dist/service/phase3-input-filter.js +3 -27
  91. package/dist/service/runtime-summary-service.d.ts +0 -10
  92. package/dist/service/runtime-summary-service.js +1 -54
  93. package/dist/tools/deep-reflect.js +2 -1
  94. package/dist/types/event-types.d.ts +2 -10
  95. package/dist/types/runtime-summary.d.ts +1 -8
  96. package/dist/types.d.ts +0 -3
  97. package/dist/types.js +0 -2
  98. package/openclaw.plugin.json +1 -1
  99. package/package.json +1 -1
  100. package/templates/langs/en/skills/pd-mentor/SKILL.md +5 -5
  101. package/templates/langs/zh/skills/pd-mentor/SKILL.md +5 -5
  102. package/templates/pain_settings.json +0 -6
  103. package/dist/commands/trust.d.ts +0 -4
  104. package/dist/commands/trust.js +0 -78
  105. package/dist/core/trust-engine.d.ts +0 -96
  106. package/dist/core/trust-engine.js +0 -286
@@ -12,8 +12,60 @@ import { acquireLockAsync, releaseLock } from '../utils/file-lock.js';
12
12
  import { getEvolutionLogger } from '../core/evolution-logger.js';
13
13
  import { DIAGNOSTICIAN_PROTOCOL_SUMMARY } from '../constants/diagnostician.js';
14
14
  import { LockUnavailableError } from '../config/index.js';
15
+ import { checkWorkspaceIdle, checkCooldown } from './nocturnal-runtime.js';
16
+ import { executeNocturnalReflectionAsync } from './nocturnal-service.js';
17
+ import { OpenClawTrinityRuntimeAdapter } from '../core/nocturnal-trinity.js';
15
18
  let intervalId = null;
16
19
  let timeoutId = null;
20
+ /**
21
+ * Default values for new V2 fields when migrating legacy items.
22
+ */
23
+ const DEFAULT_TASK_KIND = 'pain_diagnosis';
24
+ const DEFAULT_PRIORITY = 'medium';
25
+ const DEFAULT_MAX_RETRIES = 3;
26
+ /**
27
+ * Migrate a legacy queue item to V2 schema.
28
+ * Old items without taskKind are assumed to be pain_diagnosis for backward compatibility.
29
+ */
30
+ function migrateToV2(item) {
31
+ return {
32
+ id: item.id,
33
+ taskKind: item.taskKind || DEFAULT_TASK_KIND,
34
+ priority: item.priority || DEFAULT_PRIORITY,
35
+ source: item.source,
36
+ traceId: item.traceId,
37
+ task: item.task,
38
+ score: item.score,
39
+ reason: item.reason,
40
+ timestamp: item.timestamp,
41
+ enqueued_at: item.enqueued_at,
42
+ started_at: item.started_at,
43
+ completed_at: item.completed_at,
44
+ assigned_session_key: item.assigned_session_key,
45
+ trigger_text_preview: item.trigger_text_preview,
46
+ status: item.status || 'pending',
47
+ resolution: item.resolution,
48
+ session_id: item.session_id,
49
+ agent_id: item.agent_id,
50
+ retryCount: item.retryCount || 0,
51
+ maxRetries: item.maxRetries || DEFAULT_MAX_RETRIES,
52
+ lastError: item.lastError,
53
+ resultRef: item.resultRef,
54
+ };
55
+ }
56
+ /**
57
+ * Check if an item is a legacy (pre-V2) queue item.
58
+ */
59
+ function isLegacyQueueItem(item) {
60
+ return item && typeof item === 'object' && !('taskKind' in item);
61
+ }
62
+ /**
63
+ * Migrate entire queue to V2 schema if needed.
64
+ * Returns a new array with all items migrated to V2 format.
65
+ */
66
+ function migrateQueueToV2(queue) {
67
+ return queue.map(item => isLegacyQueueItem(item) ? migrateToV2(item) : item);
68
+ }
17
69
  const PAIN_QUEUE_DEDUP_WINDOW_MS = 30 * 60 * 1000;
18
70
  // P0 fix: File lock constants and helper for queue operations (prevents TOCTOU race)
19
71
  export const EVOLUTION_QUEUE_LOCK_SUFFIX = '.lock';
@@ -130,6 +182,97 @@ export function hasEquivalentPromotedRule(dictionary, phrase) {
130
182
  return false;
131
183
  });
132
184
  }
185
+ /**
186
+ * Read recent pain context from PAIN_FLAG file.
187
+ * Returns structured pain metadata for attaching to sleep_reflection tasks.
188
+ * Returns null if no pain flag exists.
189
+ */
190
+ function readRecentPainContext(wctx) {
191
+ const painFlagPath = wctx.resolve('PAIN_FLAG');
192
+ if (!fs.existsSync(painFlagPath)) {
193
+ return { mostRecent: null, recentPainCount: 0, recentMaxPainScore: 0 };
194
+ }
195
+ try {
196
+ const rawPain = fs.readFileSync(painFlagPath, 'utf8');
197
+ const lines = rawPain.split('\n');
198
+ let score = 0;
199
+ let source = '';
200
+ let reason = '';
201
+ let timestamp = '';
202
+ for (const line of lines) {
203
+ if (line.startsWith('score:'))
204
+ score = parseInt(line.split(':', 2)[1].trim(), 10) || 0;
205
+ if (line.startsWith('source:'))
206
+ source = line.split(':', 2)[1].trim();
207
+ if (line.startsWith('reason:'))
208
+ reason = line.slice('reason:'.length).trim();
209
+ if (line.startsWith('timestamp:'))
210
+ timestamp = line.slice('timestamp:'.length).trim();
211
+ }
212
+ if (score > 0) {
213
+ return {
214
+ mostRecent: { score, source, reason, timestamp },
215
+ recentPainCount: 1,
216
+ recentMaxPainScore: score,
217
+ };
218
+ }
219
+ }
220
+ catch {
221
+ // Best effort — non-fatal
222
+ }
223
+ return { mostRecent: null, recentPainCount: 0, recentMaxPainScore: 0 };
224
+ }
225
+ /**
226
+ * Enqueue a sleep_reflection task if one is not already pending.
227
+ * Phase 2.4: Called when workspace is idle to trigger nocturnal reflection.
228
+ */
229
+ async function enqueueSleepReflectionTask(wctx, logger) {
230
+ const queuePath = wctx.resolve('EVOLUTION_QUEUE');
231
+ const releaseLock = await requireQueueLock(queuePath, logger, 'enqueueSleepReflection', EVOLUTION_QUEUE_LOCK_SUFFIX);
232
+ try {
233
+ let rawQueue = [];
234
+ try {
235
+ rawQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
236
+ }
237
+ catch {
238
+ // Queue doesn't exist yet - create empty array
239
+ rawQueue = [];
240
+ }
241
+ const queue = migrateQueueToV2(rawQueue);
242
+ // Check if a sleep_reflection task is already pending
243
+ const hasPendingSleepReflection = queue.some(t => t.taskKind === 'sleep_reflection' && (t.status === 'pending' || t.status === 'in_progress'));
244
+ if (hasPendingSleepReflection) {
245
+ logger?.debug?.('[PD:EvolutionWorker] sleep_reflection task already pending/in-progress, skipping');
246
+ return;
247
+ }
248
+ const now = Date.now();
249
+ const taskId = createEvolutionTaskId('nocturnal', 50, 'idle workspace', 'Sleep-mode reflection', now);
250
+ const nowIso = new Date(now).toISOString();
251
+ // Attach recent pain context if available
252
+ const recentPainContext = readRecentPainContext(wctx);
253
+ queue.push({
254
+ id: taskId,
255
+ taskKind: 'sleep_reflection',
256
+ priority: 'medium',
257
+ score: 50,
258
+ source: 'nocturnal',
259
+ reason: 'Sleep-mode reflection triggered by idle workspace',
260
+ trigger_text_preview: 'Idle workspace detected',
261
+ timestamp: nowIso,
262
+ enqueued_at: nowIso,
263
+ status: 'pending',
264
+ traceId: taskId,
265
+ retryCount: 0,
266
+ maxRetries: 1, // sleep_reflection doesn't retry
267
+ recentPainContext,
268
+ });
269
+ fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
270
+ logger?.info?.(`[PD:EvolutionWorker] Enqueued sleep_reflection task ${taskId}`);
271
+ }
272
+ finally {
273
+ releaseLock();
274
+ }
275
+ }
133
276
  async function checkPainFlag(wctx, logger) {
134
277
  try {
135
278
  const painFlagPath = wctx.resolve('PAIN_FLAG');
@@ -190,8 +333,11 @@ async function checkPainFlag(wctx, logger) {
190
333
  const taskId = createEvolutionTaskId(source, score, preview, reason, now);
191
334
  const nowIso = new Date(now).toISOString();
192
335
  const effectiveTraceId = traceId || taskId;
336
+ // V2: New queue items include all V2 fields with pain_diagnosis defaults
193
337
  queue.push({
194
338
  id: taskId,
339
+ taskKind: 'pain_diagnosis', // V2: All pain-flag triggered tasks are pain_diagnosis
340
+ priority: score >= 70 ? 'high' : score >= 40 ? 'medium' : 'low', // V2: Priority based on score
195
341
  score,
196
342
  source,
197
343
  reason,
@@ -202,6 +348,8 @@ async function checkPainFlag(wctx, logger) {
202
348
  session_id: sessionId || undefined,
203
349
  agent_id: agentId || undefined,
204
350
  traceId: effectiveTraceId,
351
+ retryCount: 0, // V2: No retries yet
352
+ maxRetries: 3, // V2: Default max retries
205
353
  });
206
354
  fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
207
355
  fs.appendFileSync(painFlagPath, `\nstatus: queued\ntask_id: ${taskId}\n`, 'utf8');
@@ -234,16 +382,17 @@ async function checkPainFlag(wctx, logger) {
234
382
  logger.warn(`[PD:EvolutionWorker] Error processing pain flag: ${String(err)}`);
235
383
  }
236
384
  }
237
- async function processEvolutionQueue(wctx, logger, eventLog) {
385
+ async function processEvolutionQueue(wctx, logger, eventLog, api) {
238
386
  const queuePath = wctx.resolve('EVOLUTION_QUEUE');
239
387
  if (!fs.existsSync(queuePath))
240
388
  return;
241
389
  const releaseLock = await requireQueueLock(queuePath, logger, 'processEvolutionQueue');
242
390
  const evoLogger = getEvolutionLogger(wctx.workspaceDir, wctx.trajectory);
391
+ let lockReleased = false;
243
392
  try {
244
- let queue = [];
393
+ let rawQueue = [];
245
394
  try {
246
- queue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
395
+ rawQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
247
396
  }
248
397
  catch (e) {
249
398
  // Backup corrupted file instead of silently discarding
@@ -262,11 +411,36 @@ async function processEvolutionQueue(wctx, logger, eventLog) {
262
411
  }
263
412
  return;
264
413
  }
265
- let queueChanged = false;
414
+ // V2: Migrate queue to current schema if needed
415
+ const queue = migrateQueueToV2(rawQueue);
416
+ let queueChanged = rawQueue.some(isLegacyQueueItem);
266
417
  const config = wctx.config;
267
418
  const timeout = config.get('intervals.task_timeout_ms') || (60 * 60 * 1000); // Default 1 hour
268
- // Check in_progress tasks for completion
269
- for (const task of queue.filter(t => t.status === 'in_progress')) {
419
+ // V2: Recover stuck in_progress sleep_reflection tasks.
420
+ // If the worker crashes or the result write-back fails after Phase 1 claimed
421
+ // the task, it stays in_progress indefinitely. Detect via timeout and mark
422
+ // as failed so a fresh task can be enqueued on the next idle cycle.
423
+ for (const task of queue.filter(t => t.status === 'in_progress' && t.taskKind === 'sleep_reflection')) {
424
+ const startedAt = new Date(task.started_at || task.timestamp);
425
+ const age = Date.now() - startedAt.getTime();
426
+ if (age > timeout) {
427
+ task.status = 'failed';
428
+ task.completed_at = new Date().toISOString();
429
+ task.resolution = 'failed_max_retries';
430
+ task.lastError = `sleep_reflection timed out after ${Math.round(timeout / 60000)} minutes`;
431
+ task.retryCount = (task.retryCount ?? 0) + 1;
432
+ queueChanged = true;
433
+ logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${task.id} timed out after ${Math.round(age / 60000)} minutes, marking as failed`);
434
+ evoLogger.logCompleted({
435
+ traceId: task.traceId || task.id,
436
+ taskId: task.id,
437
+ resolution: 'manual',
438
+ durationMs: age,
439
+ });
440
+ }
441
+ }
442
+ // Check in_progress tasks for completion (only pain_diagnosis gets HEARTBEAT treatment)
443
+ for (const task of queue.filter(t => t.status === 'in_progress' && t.taskKind === 'pain_diagnosis')) {
270
444
  const startedAt = new Date(task.started_at || task.timestamp);
271
445
  // Condition 1: Check for marker file (created by diagnostician on completion)
272
446
  const completeMarker = path.join(wctx.stateDir, `.evolution_complete_${task.id}`);
@@ -338,9 +512,21 @@ async function processEvolutionQueue(wctx, logger, eventLog) {
338
512
  queueChanged = true;
339
513
  }
340
514
  }
341
- const pendingTasks = queue.filter(t => t.status === 'pending');
515
+ // V2: Process pain_diagnosis tasks FIRST (quick, inside lock),
516
+ // then sleep_reflection tasks (slow, lock released during execution).
517
+ // This order ensures pain tasks are never starved by long-running
518
+ // nocturnal reflection — sleep_reflection can safely return early
519
+ // because pain_diagnosis has already been handled.
520
+ const pendingTasks = queue.filter(t => t.status === 'pending' && t.taskKind === 'pain_diagnosis');
342
521
  if (pendingTasks.length > 0) {
343
- const highestScoreTask = pendingTasks.sort((a, b) => b.score - a.score)[0];
522
+ // V2: Also sort by priority within same score
523
+ const priorityWeight = { high: 3, medium: 2, low: 1 };
524
+ const highestScoreTask = pendingTasks.sort((a, b) => {
525
+ const scoreDiff = b.score - a.score;
526
+ if (scoreDiff !== 0)
527
+ return scoreDiff;
528
+ return (priorityWeight[b.priority] || 2) - (priorityWeight[a.priority] || 2);
529
+ })[0];
344
530
  const nowIso = new Date().toISOString();
345
531
  const taskDescription = `Diagnose systemic pain [ID: ${highestScoreTask.id}]. Source: ${highestScoreTask.source}. Reason: ${highestScoreTask.reason}. ` +
346
532
  `Trigger text: "${highestScoreTask.trigger_text_preview || 'N/A'}"`;
@@ -410,6 +596,107 @@ async function processEvolutionQueue(wctx, logger, eventLog) {
410
596
  SystemLogger.log(wctx.workspaceDir, 'HEARTBEAT_WRITE_FAILED', `Task ${highestScoreTask.id} HEARTBEAT write failed: ${String(heartbeatErr)}`);
411
597
  }
412
598
  }
599
+ // Phase 2.4: Process sleep_reflection tasks AFTER pain_diagnosis.
600
+ // Claim tasks inside the lock, execute reflection outside the lock,
601
+ // then re-acquire the lock to write results. This prevents the long-running
602
+ // nocturnal reflection from blocking all other queue consumers.
603
+ // Safe to return early here because pain_diagnosis was already handled above.
604
+ const sleepReflectionTasks = queue.filter(t => t.status === 'pending' && t.taskKind === 'sleep_reflection');
605
+ if (sleepReflectionTasks.length > 0) {
606
+ // --- Phase 1: Claim tasks (inside lock) ---
607
+ for (const sleepTask of sleepReflectionTasks) {
608
+ sleepTask.status = 'in_progress';
609
+ sleepTask.started_at = new Date().toISOString();
610
+ }
611
+ queueChanged = true;
612
+ // Write claimed state (includes any pain changes from above) and release lock
613
+ if (queueChanged) {
614
+ fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
615
+ }
616
+ releaseLock();
617
+ for (const sleepTask of sleepReflectionTasks) {
618
+ try {
619
+ logger?.info?.(`[PD:EvolutionWorker] Processing sleep_reflection task ${sleepTask.id}`);
620
+ // Build runtime adapter for real Trinity execution if api is available
621
+ const runtimeAdapter = api ? new OpenClawTrinityRuntimeAdapter(api) : undefined;
622
+ // Call the nocturnal reflection service
623
+ const result = await executeNocturnalReflectionAsync(wctx.workspaceDir, wctx.stateDir, {
624
+ runtimeAdapter,
625
+ });
626
+ if (result.success && result.artifact) {
627
+ sleepTask.status = 'completed';
628
+ sleepTask.completed_at = new Date().toISOString();
629
+ sleepTask.resolution = 'marker_detected';
630
+ sleepTask.resultRef = result.diagnostics.persistedPath;
631
+ logger?.info?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} completed successfully`);
632
+ }
633
+ else {
634
+ // Record failure with skip reason
635
+ const skipReason = result.skipReason || (result.noTargetSelected ? 'no_target' : 'validation_failed');
636
+ sleepTask.status = 'failed';
637
+ sleepTask.completed_at = new Date().toISOString();
638
+ sleepTask.resolution = 'failed_max_retries';
639
+ sleepTask.lastError = `Nocturnal reflection failed: ${result.validationFailures.join('; ') || skipReason}`;
640
+ sleepTask.retryCount = (sleepTask.retryCount ?? 0) + 1;
641
+ logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} failed: ${sleepTask.lastError}`);
642
+ }
643
+ }
644
+ catch (taskErr) {
645
+ sleepTask.status = 'failed';
646
+ sleepTask.completed_at = new Date().toISOString();
647
+ sleepTask.resolution = 'failed_max_retries';
648
+ sleepTask.lastError = String(taskErr);
649
+ sleepTask.retryCount = (sleepTask.retryCount ?? 0) + 1;
650
+ logger?.error?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} threw: ${taskErr}`);
651
+ }
652
+ }
653
+ // --- Phase 3: Write results back (re-acquire lock) ---
654
+ try {
655
+ const resultLock = await requireQueueLock(queuePath, logger, 'sleepReflectionResult');
656
+ try {
657
+ // Re-read queue to merge with any changes made while lock was released
658
+ let freshQueue = [];
659
+ try {
660
+ freshQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
661
+ }
662
+ catch { /* empty queue if corrupted */ }
663
+ // Merge: update tasks by ID
664
+ for (const sleepTask of sleepReflectionTasks) {
665
+ const idx = freshQueue.findIndex((t) => t.id === sleepTask.id);
666
+ if (idx >= 0) {
667
+ freshQueue[idx] = sleepTask;
668
+ }
669
+ }
670
+ fs.writeFileSync(queuePath, JSON.stringify(freshQueue, null, 2), 'utf8');
671
+ // Log completions to EvolutionLogger
672
+ for (const sleepTask of sleepReflectionTasks) {
673
+ if (sleepTask.status === 'completed' || sleepTask.status === 'failed') {
674
+ evoLogger.logCompleted({
675
+ traceId: sleepTask.traceId || sleepTask.id,
676
+ taskId: sleepTask.id,
677
+ resolution: sleepTask.status === 'completed'
678
+ ? (sleepTask.resolution === 'marker_detected' ? 'marker_detected' : 'manual')
679
+ : 'manual',
680
+ durationMs: sleepTask.started_at
681
+ ? Date.now() - new Date(sleepTask.started_at).getTime()
682
+ : undefined,
683
+ });
684
+ }
685
+ }
686
+ }
687
+ finally {
688
+ resultLock();
689
+ }
690
+ }
691
+ catch (resultLockErr) {
692
+ // If we can't re-acquire lock, results are in memory but not persisted.
693
+ // Tasks will appear stuck as in_progress and will be retried on next cycle.
694
+ logger?.warn?.(`[PD:EvolutionWorker] Failed to write sleep_reflection results back: ${String(resultLockErr)}`);
695
+ }
696
+ // Safe to return — pain_diagnosis was already processed above.
697
+ lockReleased = true;
698
+ return;
699
+ }
413
700
  if (queueChanged) {
414
701
  fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
415
702
  }
@@ -419,7 +706,9 @@ async function processEvolutionQueue(wctx, logger, eventLog) {
419
706
  logger.warn(`[PD:EvolutionWorker] Error processing evolution queue: ${String(err)}`);
420
707
  }
421
708
  finally {
422
- releaseLock();
709
+ if (!lockReleased) {
710
+ releaseLock();
711
+ }
423
712
  }
424
713
  }
425
714
  async function processDetectionQueue(wctx, api, eventLog) {
@@ -579,14 +868,16 @@ export async function registerEvolutionTaskSession(workspaceResolve, taskId, ses
579
868
  return false;
580
869
  const releaseLock = await requireQueueLock(queuePath, logger, 'registerEvolutionTaskSession');
581
870
  try {
582
- let queue;
871
+ let rawQueue;
583
872
  try {
584
- queue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
873
+ rawQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
585
874
  }
586
875
  catch (parseErr) {
587
876
  logger?.warn?.(`[PD:EvolutionWorker] Failed to parse EVOLUTION_QUEUE for session registration: ${queuePath} - ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`);
588
877
  return false;
589
878
  }
879
+ // V2: Migrate queue to current schema
880
+ const queue = migrateQueueToV2(rawQueue);
590
881
  const task = queue.find((item) => item.id === taskId && item.status === 'in_progress');
591
882
  if (!task) {
592
883
  logger?.warn?.(`[PD:EvolutionWorker] Could not find in-progress evolution task ${taskId} for session assignment`);
@@ -627,8 +918,25 @@ export const EvolutionWorkerService = {
627
918
  const interval = config.get('intervals.worker_poll_ms') || (15 * 60 * 1000);
628
919
  intervalId = setInterval(() => {
629
920
  void (async () => {
921
+ // V2: Nocturnal idle check — logs workspace idle state on each cycle.
922
+ // This makes nocturnal-runtime a visible part of the worker lifecycle.
923
+ // Phase 2.4: Enqueue sleep_reflection when workspace is idle and not in cooldown.
924
+ const idleResult = checkWorkspaceIdle(wctx.workspaceDir, {});
925
+ if (idleResult.isIdle) {
926
+ logger?.debug?.(`[PD:EvolutionWorker] Workspace idle (${idleResult.idleForMs}ms since last activity)`);
927
+ // Phase 2.4: Enqueue sleep_reflection task if not in global cooldown
928
+ const cooldown = checkCooldown(wctx.stateDir);
929
+ if (!cooldown.globalCooldownActive && !cooldown.quotaExhausted) {
930
+ enqueueSleepReflectionTask(wctx, logger).catch((err) => {
931
+ logger?.error?.(`[PD:EvolutionWorker] Failed to enqueue sleep_reflection task: ${String(err)}`);
932
+ });
933
+ }
934
+ }
935
+ else {
936
+ logger?.debug?.(`[PD:EvolutionWorker] Workspace active (last activity ${idleResult.idleForMs}ms ago)`);
937
+ }
630
938
  await checkPainFlag(wctx, logger);
631
- await processEvolutionQueue(wctx, logger, eventLog);
939
+ await processEvolutionQueue(wctx, logger, eventLog, api ?? undefined);
632
940
  if (api) {
633
941
  await processDetectionQueue(wctx, api, eventLog);
634
942
  }
@@ -643,7 +951,7 @@ export const EvolutionWorkerService = {
643
951
  timeoutId = setTimeout(() => {
644
952
  void (async () => {
645
953
  await checkPainFlag(wctx, logger);
646
- await processEvolutionQueue(wctx, logger, eventLog);
954
+ await processEvolutionQueue(wctx, logger, eventLog, api ?? undefined);
647
955
  if (api) {
648
956
  await processDetectionQueue(wctx, api, eventLog);
649
957
  }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Nocturnal Runtime Service — Idle Detection Source of Truth
3
+ * ===========================================================
4
+ *
5
+ * This module is the authoritative source for workspace idle state used by the
6
+ * nocturnal reflection pipeline. It must NOT use `.last_active.json` as the primary
7
+ * source of truth.
8
+ *
9
+ * SOURCE OF TRUTH HIERARCHY (ordered by priority):
10
+ * 1. SessionState.lastActivityAt — via listSessions(workspaceDir)
11
+ * 2. trajectory timestamps — secondary guardrail only, NOT primary
12
+ * 3. nocturnal-runtime.json — cooldown/quota bookkeeping (ephemeral state)
13
+ *
14
+ * DESIGN CONSTRAINTS:
15
+ * - No `.last_active.json` as primary idle source
16
+ * - trajectory timestamps are a guardrail, not the primary source
17
+ * - cooldown/quota state is persisted in nocturnal-runtime.json
18
+ * - abandoned sessions (>2h inactive) must not block nocturnal flow
19
+ */
20
+ /** File name for nocturnal runtime bookkeeping */
21
+ export declare const NOCTURNAL_RUNTIME_FILE = "nocturnal-runtime.json";
22
+ /** Default idle threshold: workspace is considered idle if no activity for this duration (ms) */
23
+ export declare const DEFAULT_IDLE_THRESHOLD_MS: number;
24
+ /** Default cooldown between nocturnal runs (ms) */
25
+ export declare const DEFAULT_GLOBAL_COOLDOWN_MS: number;
26
+ /** Default per-principle cooldown (ms) */
27
+ export declare const DEFAULT_PRINCIPLE_COOLDOWN_MS: number;
28
+ /** Default maximum nocturnal runs per quota window */
29
+ export declare const DEFAULT_MAX_RUNS_PER_WINDOW = 3;
30
+ /** Default quota window size (ms) */
31
+ export declare const DEFAULT_QUOTA_WINDOW_MS: number;
32
+ /** Abandoned session threshold: sessions inactive for longer than this are ignored (ms) */
33
+ export declare const DEFAULT_ABANDONED_THRESHOLD_MS: number;
34
+ /**
35
+ * Persisted state for nocturnal runtime bookkeeping.
36
+ * Stored in {stateDir}/nocturnal-runtime.json
37
+ */
38
+ export interface NocturnalRuntimeState {
39
+ /** Last time a nocturnal run was started (ISO string) */
40
+ lastRunAt?: string;
41
+ /** Last time a nocturnal run completed successfully */
42
+ lastSuccessfulRunAt?: string;
43
+ /** Cooldown end time for global cooldown (ISO string) */
44
+ globalCooldownUntil?: string;
45
+ /**
46
+ * Per-principle cooldown map.
47
+ * Key: principleId, Value: ISO string of cooldown end time
48
+ */
49
+ principleCooldowns: Record<string, string>;
50
+ /**
51
+ * Sliding window of recent run timestamps.
52
+ * Used for quota enforcement.
53
+ */
54
+ recentRunTimestamps: string[];
55
+ /** Metadata about last run (for debugging) */
56
+ lastRunMeta?: {
57
+ targetPrincipleId?: string;
58
+ sampleCount?: number;
59
+ status: 'success' | 'failed' | 'skipped';
60
+ reason?: string;
61
+ };
62
+ }
63
+ /** Result of an idle check */
64
+ export interface IdleCheckResult {
65
+ /** Whether the workspace is currently idle */
66
+ isIdle: boolean;
67
+ /** Most recent activity timestamp across all sessions (epoch ms) */
68
+ mostRecentActivityAt: number;
69
+ /** How long since the last activity (ms) */
70
+ idleForMs: number;
71
+ /** Number of active (non-abandoned) sessions found */
72
+ activeSessionCount: number;
73
+ /** List of abandoned session IDs (inactive > abandoned threshold) */
74
+ abandonedSessionIds: string[];
75
+ /** Whether trajectory guardrail also confirms idle */
76
+ trajectoryGuardrailConfirmsIdle: boolean;
77
+ /** Reason for the idle determination */
78
+ reason: string;
79
+ }
80
+ /** Result of a cooldown check */
81
+ export interface CooldownCheckResult {
82
+ /** Whether the global cooldown is currently active */
83
+ globalCooldownActive: boolean;
84
+ /** When the global cooldown ends (ISO string), null if not in cooldown */
85
+ globalCooldownUntil: string | null;
86
+ /** Remaining ms until global cooldown expires */
87
+ globalCooldownRemainingMs: number;
88
+ /** Whether the principle-specific cooldown is active */
89
+ principleCooldownActive: boolean;
90
+ /** When the principle cooldown ends (ISO string), null if not in cooldown */
91
+ principleCooldownUntil: string | null;
92
+ /** Remaining ms until principle cooldown expires */
93
+ principleCooldownRemainingMs: number;
94
+ /** Whether the quota has been exhausted */
95
+ quotaExhausted: boolean;
96
+ /** Number of runs remaining in current window */
97
+ runsRemaining: number;
98
+ }
99
+ /**
100
+ * Check if the workspace is currently idle based on session activity.
101
+ *
102
+ * IDLE DETERMINATION LOGIC:
103
+ * - Collect all sessions for the workspace via listSessions()
104
+ * - Filter out abandoned sessions (inactive > abandonedThresholdMs)
105
+ * - Workspace is idle if: no active sessions OR all active sessions have lastActivityAt older than idleThresholdMs
106
+ * - Abandoned sessions do NOT contribute to idle determination
107
+ *
108
+ * @param workspaceDir - Workspace directory to check
109
+ * @param options.idleThresholdMs - Consider idle if no activity for this duration (default: 30 min)
110
+ * @param options.abandonedThresholdMs - Consider session abandoned if inactive for this duration (default: 2 hr)
111
+ * @param trajectoryLastActivityAt - Optional trajectory timestamp as secondary guardrail
112
+ * @returns IdleCheckResult with full diagnostic information
113
+ */
114
+ export declare function checkWorkspaceIdle(workspaceDir: string, options?: {
115
+ idleThresholdMs?: number;
116
+ abandonedThresholdMs?: number;
117
+ }, trajectoryLastActivityAt?: number): IdleCheckResult;
118
+ /**
119
+ * Check if the workspace is currently in a cooldown period.
120
+ *
121
+ * @param stateDir - State directory
122
+ * @param principleId - Optional principle ID to check per-principle cooldown
123
+ * @param options - Cooldown configuration options
124
+ * @returns CooldownCheckResult
125
+ */
126
+ export declare function checkCooldown(stateDir: string, principleId?: string, options?: {
127
+ globalCooldownMs?: number;
128
+ principleCooldownMs?: number;
129
+ maxRunsPerWindow?: number;
130
+ quotaWindowMs?: number;
131
+ }): CooldownCheckResult;
132
+ /**
133
+ * Record that a nocturnal run has started.
134
+ * Updates global cooldown and quota tracking.
135
+ *
136
+ * @param stateDir - State directory
137
+ * @param principleId - Target principle ID for this run
138
+ */
139
+ export declare function recordRunStart(stateDir: string, principleId: string): Promise<void>;
140
+ /**
141
+ * Record the outcome of a nocturnal run.
142
+ *
143
+ * @param stateDir - State directory
144
+ * @param outcome - 'success', 'failed', or 'skipped'
145
+ * @param details - Optional details about the run
146
+ */
147
+ export declare function recordRunEnd(stateDir: string, outcome: 'success' | 'failed' | 'skipped', details?: {
148
+ sampleCount?: number;
149
+ reason?: string;
150
+ }): Promise<void>;
151
+ /**
152
+ * Clear all cooldowns (for testing or admin reset).
153
+ *
154
+ * @param stateDir - State directory
155
+ */
156
+ export declare function clearAllCooldowns(stateDir: string): Promise<void>;
157
+ /**
158
+ * Get the current runtime state (for debugging/inspection).
159
+ *
160
+ * @param stateDir - State directory
161
+ * @returns The current NocturnalRuntimeState
162
+ */
163
+ export declare function getRuntimeState(stateDir: string): Promise<NocturnalRuntimeState>;
164
+ export interface PreflightCheckResult {
165
+ canRun: boolean;
166
+ idle: IdleCheckResult;
167
+ cooldown: CooldownCheckResult;
168
+ /**
169
+ * Human-readable reasons why run is blocked (if canRun is false)
170
+ */
171
+ blockers: string[];
172
+ }
173
+ /**
174
+ * Combined pre-flight check for whether a nocturnal run should proceed.
175
+ * Integrates idle + cooldown + quota checks.
176
+ *
177
+ * @param workspaceDir - Workspace directory
178
+ * @param stateDir - State directory
179
+ * @param principleId - Target principle ID
180
+ * @param trajectoryLastActivityAt - Optional trajectory timestamp as secondary guardrail
181
+ * @param idleCheckOverride - Optional override for idle check result (for testing)
182
+ */
183
+ export declare function checkPreflight(workspaceDir: string, stateDir: string, principleId?: string, trajectoryLastActivityAt?: number, idleCheckOverride?: IdleCheckResult): PreflightCheckResult;