principles-disciple 1.7.5 → 1.7.8

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 (129) hide show
  1. package/dist/commands/context.js +5 -15
  2. package/dist/commands/evolution-status.js +29 -48
  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/config/defaults/runtime.d.ts +40 -0
  12. package/dist/config/defaults/runtime.js +44 -0
  13. package/dist/config/errors.d.ts +84 -0
  14. package/dist/config/errors.js +94 -0
  15. package/dist/config/index.d.ts +7 -0
  16. package/dist/config/index.js +7 -0
  17. package/dist/constants/diagnostician.d.ts +0 -4
  18. package/dist/constants/diagnostician.js +0 -4
  19. package/dist/constants/tools.d.ts +2 -2
  20. package/dist/constants/tools.js +1 -1
  21. package/dist/core/adaptive-thresholds.d.ts +186 -0
  22. package/dist/core/adaptive-thresholds.js +300 -0
  23. package/dist/core/config.d.ts +2 -38
  24. package/dist/core/config.js +6 -61
  25. package/dist/core/control-ui-db.d.ts +27 -0
  26. package/dist/core/control-ui-db.js +18 -0
  27. package/dist/core/event-log.d.ts +1 -2
  28. package/dist/core/event-log.js +0 -3
  29. package/dist/core/evolution-engine.js +1 -21
  30. package/dist/core/evolution-reducer.d.ts +7 -1
  31. package/dist/core/evolution-reducer.js +56 -4
  32. package/dist/core/evolution-types.d.ts +61 -9
  33. package/dist/core/evolution-types.js +31 -9
  34. package/dist/core/external-training-contract.d.ts +276 -0
  35. package/dist/core/external-training-contract.js +269 -0
  36. package/dist/core/local-worker-routing.d.ts +175 -0
  37. package/dist/core/local-worker-routing.js +525 -0
  38. package/dist/core/model-deployment-registry.d.ts +218 -0
  39. package/dist/core/model-deployment-registry.js +503 -0
  40. package/dist/core/model-training-registry.d.ts +295 -0
  41. package/dist/core/model-training-registry.js +475 -0
  42. package/dist/core/nocturnal-arbiter.d.ts +159 -0
  43. package/dist/core/nocturnal-arbiter.js +534 -0
  44. package/dist/core/nocturnal-candidate-scoring.d.ts +137 -0
  45. package/dist/core/nocturnal-candidate-scoring.js +266 -0
  46. package/dist/core/nocturnal-compliance.d.ts +175 -0
  47. package/dist/core/nocturnal-compliance.js +824 -0
  48. package/dist/core/nocturnal-dataset.d.ts +224 -0
  49. package/dist/core/nocturnal-dataset.js +443 -0
  50. package/dist/core/nocturnal-executability.d.ts +85 -0
  51. package/dist/core/nocturnal-executability.js +331 -0
  52. package/dist/core/nocturnal-export.d.ts +124 -0
  53. package/dist/core/nocturnal-export.js +275 -0
  54. package/dist/core/nocturnal-paths.d.ts +124 -0
  55. package/dist/core/nocturnal-paths.js +214 -0
  56. package/dist/core/nocturnal-trajectory-extractor.d.ts +242 -0
  57. package/dist/core/nocturnal-trajectory-extractor.js +307 -0
  58. package/dist/core/nocturnal-trinity.d.ts +311 -0
  59. package/dist/core/nocturnal-trinity.js +880 -0
  60. package/dist/core/path-resolver.js +2 -1
  61. package/dist/core/paths.d.ts +6 -0
  62. package/dist/core/paths.js +6 -0
  63. package/dist/core/principle-training-state.d.ts +121 -0
  64. package/dist/core/principle-training-state.js +321 -0
  65. package/dist/core/promotion-gate.d.ts +238 -0
  66. package/dist/core/promotion-gate.js +529 -0
  67. package/dist/core/session-tracker.d.ts +10 -0
  68. package/dist/core/session-tracker.js +14 -0
  69. package/dist/core/shadow-observation-registry.d.ts +217 -0
  70. package/dist/core/shadow-observation-registry.js +308 -0
  71. package/dist/core/training-program.d.ts +233 -0
  72. package/dist/core/training-program.js +433 -0
  73. package/dist/core/trajectory.d.ts +155 -1
  74. package/dist/core/trajectory.js +292 -8
  75. package/dist/core/workspace-context.d.ts +0 -6
  76. package/dist/core/workspace-context.js +0 -12
  77. package/dist/hooks/bash-risk.d.ts +57 -0
  78. package/dist/hooks/bash-risk.js +137 -0
  79. package/dist/hooks/edit-verification.d.ts +62 -0
  80. package/dist/hooks/edit-verification.js +256 -0
  81. package/dist/hooks/gate-block-helper.d.ts +44 -0
  82. package/dist/hooks/gate-block-helper.js +119 -0
  83. package/dist/hooks/gate.d.ts +18 -0
  84. package/dist/hooks/gate.js +62 -751
  85. package/dist/hooks/gfi-gate.d.ts +40 -0
  86. package/dist/hooks/gfi-gate.js +113 -0
  87. package/dist/hooks/pain.js +6 -9
  88. package/dist/hooks/progressive-trust-gate.d.ts +51 -0
  89. package/dist/hooks/progressive-trust-gate.js +89 -0
  90. package/dist/hooks/prompt.d.ts +11 -11
  91. package/dist/hooks/prompt.js +167 -77
  92. package/dist/hooks/subagent.js +43 -6
  93. package/dist/hooks/thinking-checkpoint.d.ts +37 -0
  94. package/dist/hooks/thinking-checkpoint.js +51 -0
  95. package/dist/http/principles-console-route.js +13 -3
  96. package/dist/i18n/commands.js +8 -8
  97. package/dist/index.js +129 -28
  98. package/dist/service/central-database.js +2 -1
  99. package/dist/service/control-ui-query-service.d.ts +1 -1
  100. package/dist/service/control-ui-query-service.js +3 -3
  101. package/dist/service/evolution-query-service.d.ts +1 -1
  102. package/dist/service/evolution-query-service.js +5 -5
  103. package/dist/service/evolution-worker.d.ts +52 -4
  104. package/dist/service/evolution-worker.js +328 -16
  105. package/dist/service/nocturnal-runtime.d.ts +183 -0
  106. package/dist/service/nocturnal-runtime.js +352 -0
  107. package/dist/service/nocturnal-service.d.ts +163 -0
  108. package/dist/service/nocturnal-service.js +787 -0
  109. package/dist/service/nocturnal-target-selector.d.ts +145 -0
  110. package/dist/service/nocturnal-target-selector.js +315 -0
  111. package/dist/service/phase3-input-filter.d.ts +48 -12
  112. package/dist/service/phase3-input-filter.js +84 -18
  113. package/dist/service/runtime-summary-service.d.ts +34 -10
  114. package/dist/service/runtime-summary-service.js +87 -48
  115. package/dist/tools/deep-reflect.js +2 -1
  116. package/dist/types/event-types.d.ts +4 -10
  117. package/dist/types/runtime-summary.d.ts +47 -0
  118. package/dist/types/runtime-summary.js +1 -0
  119. package/dist/types.d.ts +0 -3
  120. package/dist/types.js +0 -2
  121. package/openclaw.plugin.json +1 -1
  122. package/package.json +1 -1
  123. package/templates/langs/en/skills/pd-mentor/SKILL.md +5 -5
  124. package/templates/langs/zh/skills/pd-mentor/SKILL.md +5 -5
  125. package/templates/pain_settings.json +0 -6
  126. package/dist/commands/trust.d.ts +0 -4
  127. package/dist/commands/trust.js +0 -78
  128. package/dist/core/trust-engine.d.ts +0 -96
  129. package/dist/core/trust-engine.js +0 -286
@@ -11,7 +11,61 @@ import { initPersistence, flushAllSessions } from '../core/session-tracker.js';
11
11
  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
+ 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';
14
18
  let intervalId = null;
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
+ }
15
69
  const PAIN_QUEUE_DEDUP_WINDOW_MS = 30 * 60 * 1000;
16
70
  // P0 fix: File lock constants and helper for queue operations (prevents TOCTOU race)
17
71
  export const EVOLUTION_QUEUE_LOCK_SUFFIX = '.lock';
@@ -84,8 +138,8 @@ async function requireQueueLock(resourcePath, logger, scope, lockSuffix = EVOLUT
84
138
  try {
85
139
  return await acquireQueueLock(resourcePath, logger, lockSuffix);
86
140
  }
87
- catch {
88
- throw new Error(`[PD:EvolutionWorker] ${scope}: queue lock unavailable for ${resourcePath}`);
141
+ catch (err) {
142
+ throw new LockUnavailableError(resourcePath, scope, { cause: err });
89
143
  }
90
144
  }
91
145
  export function extractEvolutionTaskId(task) {
@@ -128,6 +182,97 @@ export function hasEquivalentPromotedRule(dictionary, phrase) {
128
182
  return false;
129
183
  });
130
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
+ }
131
276
  async function checkPainFlag(wctx, logger) {
132
277
  try {
133
278
  const painFlagPath = wctx.resolve('PAIN_FLAG');
@@ -188,8 +333,11 @@ async function checkPainFlag(wctx, logger) {
188
333
  const taskId = createEvolutionTaskId(source, score, preview, reason, now);
189
334
  const nowIso = new Date(now).toISOString();
190
335
  const effectiveTraceId = traceId || taskId;
336
+ // V2: New queue items include all V2 fields with pain_diagnosis defaults
191
337
  queue.push({
192
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
193
341
  score,
194
342
  source,
195
343
  reason,
@@ -200,6 +348,8 @@ async function checkPainFlag(wctx, logger) {
200
348
  session_id: sessionId || undefined,
201
349
  agent_id: agentId || undefined,
202
350
  traceId: effectiveTraceId,
351
+ retryCount: 0, // V2: No retries yet
352
+ maxRetries: 3, // V2: Default max retries
203
353
  });
204
354
  fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
205
355
  fs.appendFileSync(painFlagPath, `\nstatus: queued\ntask_id: ${taskId}\n`, 'utf8');
@@ -232,16 +382,17 @@ async function checkPainFlag(wctx, logger) {
232
382
  logger.warn(`[PD:EvolutionWorker] Error processing pain flag: ${String(err)}`);
233
383
  }
234
384
  }
235
- async function processEvolutionQueue(wctx, logger, eventLog) {
385
+ async function processEvolutionQueue(wctx, logger, eventLog, api) {
236
386
  const queuePath = wctx.resolve('EVOLUTION_QUEUE');
237
387
  if (!fs.existsSync(queuePath))
238
388
  return;
239
389
  const releaseLock = await requireQueueLock(queuePath, logger, 'processEvolutionQueue');
240
390
  const evoLogger = getEvolutionLogger(wctx.workspaceDir, wctx.trajectory);
391
+ let lockReleased = false;
241
392
  try {
242
- let queue = [];
393
+ let rawQueue = [];
243
394
  try {
244
- queue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
395
+ rawQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
245
396
  }
246
397
  catch (e) {
247
398
  // Backup corrupted file instead of silently discarding
@@ -260,11 +411,36 @@ async function processEvolutionQueue(wctx, logger, eventLog) {
260
411
  }
261
412
  return;
262
413
  }
263
- let queueChanged = false;
414
+ // V2: Migrate queue to current schema if needed
415
+ const queue = migrateQueueToV2(rawQueue);
416
+ let queueChanged = rawQueue.some(isLegacyQueueItem);
264
417
  const config = wctx.config;
265
418
  const timeout = config.get('intervals.task_timeout_ms') || (60 * 60 * 1000); // Default 1 hour
266
- // Check in_progress tasks for completion
267
- 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')) {
268
444
  const startedAt = new Date(task.started_at || task.timestamp);
269
445
  // Condition 1: Check for marker file (created by diagnostician on completion)
270
446
  const completeMarker = path.join(wctx.stateDir, `.evolution_complete_${task.id}`);
@@ -336,9 +512,21 @@ async function processEvolutionQueue(wctx, logger, eventLog) {
336
512
  queueChanged = true;
337
513
  }
338
514
  }
339
- 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');
340
521
  if (pendingTasks.length > 0) {
341
- 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];
342
530
  const nowIso = new Date().toISOString();
343
531
  const taskDescription = `Diagnose systemic pain [ID: ${highestScoreTask.id}]. Source: ${highestScoreTask.source}. Reason: ${highestScoreTask.reason}. ` +
344
532
  `Trigger text: "${highestScoreTask.trigger_text_preview || 'N/A'}"`;
@@ -408,6 +596,107 @@ async function processEvolutionQueue(wctx, logger, eventLog) {
408
596
  SystemLogger.log(wctx.workspaceDir, 'HEARTBEAT_WRITE_FAILED', `Task ${highestScoreTask.id} HEARTBEAT write failed: ${String(heartbeatErr)}`);
409
597
  }
410
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
+ }
411
700
  if (queueChanged) {
412
701
  fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
413
702
  }
@@ -417,7 +706,9 @@ async function processEvolutionQueue(wctx, logger, eventLog) {
417
706
  logger.warn(`[PD:EvolutionWorker] Error processing evolution queue: ${String(err)}`);
418
707
  }
419
708
  finally {
420
- releaseLock();
709
+ if (!lockReleased) {
710
+ releaseLock();
711
+ }
421
712
  }
422
713
  }
423
714
  async function processDetectionQueue(wctx, api, eventLog) {
@@ -577,14 +868,16 @@ export async function registerEvolutionTaskSession(workspaceResolve, taskId, ses
577
868
  return false;
578
869
  const releaseLock = await requireQueueLock(queuePath, logger, 'registerEvolutionTaskSession');
579
870
  try {
580
- let queue;
871
+ let rawQueue;
581
872
  try {
582
- queue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
873
+ rawQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
583
874
  }
584
875
  catch (parseErr) {
585
876
  logger?.warn?.(`[PD:EvolutionWorker] Failed to parse EVOLUTION_QUEUE for session registration: ${queuePath} - ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`);
586
877
  return false;
587
878
  }
879
+ // V2: Migrate queue to current schema
880
+ const queue = migrateQueueToV2(rawQueue);
588
881
  const task = queue.find((item) => item.id === taskId && item.status === 'in_progress');
589
882
  if (!task) {
590
883
  logger?.warn?.(`[PD:EvolutionWorker] Could not find in-progress evolution task ${taskId} for session assignment`);
@@ -625,8 +918,25 @@ export const EvolutionWorkerService = {
625
918
  const interval = config.get('intervals.worker_poll_ms') || (15 * 60 * 1000);
626
919
  intervalId = setInterval(() => {
627
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
+ }
628
938
  await checkPainFlag(wctx, logger);
629
- await processEvolutionQueue(wctx, logger, eventLog);
939
+ await processEvolutionQueue(wctx, logger, eventLog, api ?? undefined);
630
940
  if (api) {
631
941
  await processDetectionQueue(wctx, api, eventLog);
632
942
  }
@@ -638,10 +948,10 @@ export const EvolutionWorkerService = {
638
948
  logger.error(`[PD:EvolutionWorker] Error in worker interval: ${String(err)}`);
639
949
  });
640
950
  }, interval);
641
- setTimeout(() => {
951
+ timeoutId = setTimeout(() => {
642
952
  void (async () => {
643
953
  await checkPainFlag(wctx, logger);
644
- await processEvolutionQueue(wctx, logger, eventLog);
954
+ await processEvolutionQueue(wctx, logger, eventLog, api ?? undefined);
645
955
  if (api) {
646
956
  await processDetectionQueue(wctx, api, eventLog);
647
957
  }
@@ -657,6 +967,8 @@ export const EvolutionWorkerService = {
657
967
  ctx.logger.info('[PD:EvolutionWorker] Stopping background service...');
658
968
  if (intervalId)
659
969
  clearInterval(intervalId);
970
+ if (timeoutId)
971
+ clearTimeout(timeoutId);
660
972
  flushAllSessions();
661
973
  }
662
974
  };
@@ -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;