principles-disciple 1.31.0 → 1.33.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 (35) hide show
  1. package/openclaw.plugin.json +1 -1
  2. package/package.json +1 -1
  3. package/src/core/correction-cue-learner.ts +203 -0
  4. package/src/core/correction-types.ts +88 -0
  5. package/src/core/init.ts +67 -0
  6. package/src/service/correction-observer-types.ts +58 -0
  7. package/src/service/correction-observer-workflow-manager.ts +218 -0
  8. package/src/service/evolution-worker.ts +164 -140
  9. package/src/service/nocturnal-service.ts +4 -1
  10. package/src/service/subagent-workflow/index.ts +14 -0
  11. package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +7 -8
  12. package/tests/service/evolution-worker.nocturnal.test.ts +14 -1
  13. package/tests/commands/implementation-lifecycle.test.ts +0 -362
  14. package/tests/core/detection-funnel.test.ts +0 -63
  15. package/tests/core/evolution-e2e.test.ts +0 -58
  16. package/tests/core/evolution-engine-gate-integration.test.ts +0 -543
  17. package/tests/core/evolution-engine.test.ts +0 -562
  18. package/tests/core/evolution-reducer.test.ts +0 -180
  19. package/tests/core/evolution-user-stories.e2e.test.ts +0 -249
  20. package/tests/core/local-worker-routing.test.ts +0 -757
  21. package/tests/core/rule-host.test.ts +0 -389
  22. package/tests/core/trajectory-correction-pain.test.ts +0 -180
  23. package/tests/hooks/gate-edit-verification.test.ts +0 -435
  24. package/tests/hooks/llm.test.ts +0 -308
  25. package/tests/hooks/progressive-trust-gate.test.ts +0 -277
  26. package/tests/hooks/prompt.test.ts +0 -1473
  27. package/tests/index.integration.test.ts +0 -179
  28. package/tests/index.shadow-routing.integration.test.ts +0 -140
  29. package/tests/service/evolution-worker.test.ts +0 -462
  30. package/tests/service/nocturnal-service.test.ts +0 -577
  31. package/tests/service/nocturnal-workflow-manager.test.ts +0 -441
  32. package/tests/tools/critique-prompt.test.ts +0 -260
  33. package/tests/tools/deep-reflect.test.ts +0 -232
  34. package/tests/tools/model-index.test.ts +0 -246
  35. package/tests/ui/app.test.tsx +0 -114
@@ -5,7 +5,7 @@ import { createHash } from 'crypto';
5
5
  import type { OpenClawPluginServiceContext, OpenClawPluginApi, PluginLogger } from '../openclaw-sdk.js';
6
6
  import { DictionaryService } from '../core/dictionary-service.js';
7
7
  import { DetectionService } from '../core/detection-service.js';
8
- import { ensureStateTemplates } from '../core/init.js';
8
+ import { ensureStateTemplates, ensureCorePrinciples } from '../core/init.js';
9
9
  import { SystemLogger } from '../core/system-logger.js';
10
10
  import { WorkspaceContext } from '../core/workspace-context.js';
11
11
  import type { EventLog } from '../core/event-log.js';
@@ -32,6 +32,14 @@ import { validateNocturnalSnapshotIngress } from '../core/nocturnal-snapshot-con
32
32
  import { isExpectedSubagentError } from './subagent-workflow/subagent-error-utils.js';
33
33
  import { readPainFlagContract } from '../core/pain.js';
34
34
 
35
+ // ── Atomic File Write ────────────────────────────────────────────────────────
36
+ // Write to temp then rename — atomic on POSIX, prevents partial-write corruption on crash.
37
+ function atomicWriteFileSync(filePath: string, data: string): void {
38
+ const tmpPath = filePath + '.tmp';
39
+ fs.writeFileSync(tmpPath, data, 'utf8');
40
+ fs.renameSync(tmpPath, filePath);
41
+ }
42
+
35
43
  const WORKFLOW_TTL_MS = 5 * 60 * 1000; // 5 minutes default TTL for helper workflows
36
44
  import { OpenClawTrinityRuntimeAdapter } from '../core/nocturnal-trinity.js';
37
45
 
@@ -48,6 +56,7 @@ interface WatchdogResult {
48
56
  details: string[];
49
57
  }
50
58
 
59
+ // eslint-disable-next-line complexity
51
60
  async function runWorkflowWatchdog(
52
61
  wctx: WorkspaceContext,
53
62
  api: OpenClawPluginApi | null,
@@ -63,104 +72,10 @@ async function runWorkflowWatchdog(
63
72
  try {
64
73
  const allWorkflows: WorkflowRow[] = store.listWorkflows();
65
74
 
66
- // Check 1: Stale active workflows (active > 2x TTL)
67
- const staleThreshold = WORKFLOW_TTL_MS * 2;
68
- const staleActive = allWorkflows.filter(
69
- (wf: WorkflowRow) => wf.state === 'active' && (now - wf.created_at) > staleThreshold,
70
- );
71
- if (staleActive.length > 0) {
72
- for (const wf of staleActive) {
73
- const ageMin = Math.round((now - wf.created_at) / 60000);
74
- details.push(`stale_active: ${wf.workflow_id} (${wf.workflow_type}, ${ageMin}min old)`);
75
-
76
- // #257: Check if the last recorded event reason indicates expected subagent unavailability.
77
- // If so, skip marking as terminal_error — the workflow is stale because the subagent
78
- // was expectedly unavailable (daemon mode, process isolation), not due to a hard failure.
79
- const events = store.getEvents(wf.workflow_id);
80
- const lastEventReason = events.length > 0 ? events[events.length - 1].reason : 'unknown';
81
- if (isExpectedSubagentError(lastEventReason)) {
82
- logger?.debug?.(`[PD:Watchdog] Skipping stale active workflow ${wf.workflow_id}: expected subagent error (${lastEventReason})`);
83
- continue;
84
- }
85
-
86
- store.updateWorkflowState(wf.workflow_id, 'terminal_error');
87
- store.recordEvent(wf.workflow_id, 'watchdog_timeout', 'active', 'terminal_error', `Stale active > ${staleThreshold / 60000}s`, { ageMs: now - wf.created_at });
88
-
89
- // Cleanup session if possible (#188: gateway-safe fallback)
90
- if (wf.child_session_key) {
91
- try {
92
- if (subagentRuntime) {
93
- await subagentRuntime.deleteSession({ sessionKey: wf.child_session_key, deleteTranscript: true });
94
- logger?.info?.(`[PD:Watchdog] Cleaned up stale session: ${wf.child_session_key}`);
95
- } else if (agentSession) {
96
- const storePath = agentSession.resolveStorePath();
97
- const sessionStore = agentSession.loadSessionStore(storePath, { skipCache: true });
98
- const normalizedKey = wf.child_session_key.toLowerCase();
99
- if (sessionStore[normalizedKey]) {
100
- delete sessionStore[normalizedKey];
101
- await agentSession.saveSessionStore(storePath, sessionStore);
102
- logger?.info?.(`[PD:Watchdog] Cleaned up stale session via agentSession fallback: ${wf.child_session_key}`);
103
- }
104
- }
105
- } catch (cleanupErr) {
106
- const errMsg = String(cleanupErr);
107
- if (errMsg.includes('gateway request') && agentSession) {
108
- const storePath = agentSession.resolveStorePath();
109
- const sessionStore = agentSession.loadSessionStore(storePath, { skipCache: true });
110
- const normalizedKey = wf.child_session_key.toLowerCase();
111
- if (sessionStore[normalizedKey]) {
112
- delete sessionStore[normalizedKey];
113
- await agentSession.saveSessionStore(storePath, sessionStore);
114
- logger?.info?.(`[PD:Watchdog] Cleaned up stale session via agentSession fallback after gateway error: ${wf.child_session_key}`);
115
- }
116
- } else {
117
- logger?.warn?.(`[PD:Watchdog] Failed to cleanup session ${wf.child_session_key}: ${errMsg}`);
118
- }
119
- }
120
- }
121
- }
122
- }
123
-
124
- // Check 2: Workflows in terminal_error/expired without cleanup
125
- const unclearedTerminal = allWorkflows.filter(
126
- (wf: WorkflowRow) => (wf.state === 'terminal_error' || wf.state === 'expired') && wf.cleanup_state === 'pending',
127
- );
128
- if (unclearedTerminal.length > 0) {
129
- details.push(`uncleared_terminal: ${unclearedTerminal.length} workflows (will be swept next cycle)`);
130
- }
131
-
132
- // Check 3: Nocturnal workflow result validation (#181 pattern)
133
- const nocturnalCompleted = allWorkflows.filter(
134
- (wf: WorkflowRow) => wf.workflow_type === 'nocturnal' && wf.state === 'completed',
135
- );
136
- for (const wf of nocturnalCompleted) {
137
- // Check if the metadata snapshot has all zeros (invalid data)
138
- try {
139
- const meta = JSON.parse(wf.metadata_json) as Record<string, unknown>;
140
- const snapshot = meta.snapshot as Record<string, unknown> | undefined;
141
- if (snapshot) {
142
- // #219: Check for fallback data source (partial stats from pain context)
143
- const dataSource = snapshot._dataSource as string | undefined;
144
- if (dataSource === 'pain_context_fallback') {
145
- details.push(`fallback_snapshot: nocturnal workflow ${wf.workflow_id} uses pain-context fallback (stats may be incomplete)`);
146
- }
147
- const stats = snapshot.stats as Record<string, number> | undefined;
148
- // #246: Stats are now always number (never null). Detect "empty" fallback:
149
- // fallback + all counts zero means no real data was available.
150
- // NOTE: totalAssistantTurns may be 0 even for valid sessions because
151
- // listRecentNocturnalCandidateSessions (used in fallback path) does not
152
- // populate assistantTurnCount (only getNocturnalSessionSnapshot does).
153
- // We use totalToolCalls=0 as the primary indicator instead.
154
- if (stats && dataSource === 'pain_context_fallback' &&
155
- stats.totalToolCalls === 0 && stats.totalGateBlocks === 0 &&
156
- stats.failureCount === 0) {
157
- details.push(`fallback_snapshot_stats: nocturnal workflow ${wf.workflow_id} has empty fallback stats (no trajectory data found)`);
158
- }
159
- }
160
- } catch { /* ignore malformed metadata */ }
161
- }
75
+ runWorkflowWatchdogCheckStale(allWorkflows, store, now, details, subagentRuntime, agentSession, logger);
76
+ runWorkflowWatchdogCheckUncleared(allWorkflows, details);
77
+ runWorkflowWatchdogCheckNocturnal(allWorkflows, details);
162
78
 
163
- // Summary
164
79
  const stateCounts: Record<string, number> = {};
165
80
  for (const wf of allWorkflows) {
166
81
  stateCounts[wf.state] = (stateCounts[wf.state] || 0) + 1;
@@ -181,6 +96,106 @@ async function runWorkflowWatchdog(
181
96
  return { anomalies: details.length, details };
182
97
  }
183
98
 
99
+ // ── Watchdog helpers (extracted from runWorkflowWatchdog for complexity) ──
100
+
101
+ // eslint-disable-next-line complexity
102
+ async function cleanupStaleWorkflowSession(
103
+ wf: WorkflowRow,
104
+ subagentRuntime: { deleteSession: (opts: { sessionKey: string; deleteTranscript: boolean }) => Promise<void> } | undefined,
105
+ agentSession: { resolveStorePath: () => string; loadSessionStore: (p: string, o: { skipCache: boolean }) => Record<string, unknown>; saveSessionStore: (p: string, s: Record<string, unknown>) => Promise<void> } | undefined,
106
+ logger?: PluginLogger,
107
+ ): Promise<void> {
108
+ if (!wf.child_session_key) return;
109
+ try {
110
+ if (subagentRuntime) {
111
+ await subagentRuntime.deleteSession({ sessionKey: wf.child_session_key, deleteTranscript: true });
112
+ logger?.info?.(`[PD:Watchdog] Cleaned up stale session: ${wf.child_session_key}`);
113
+ } else if (agentSession) {
114
+ const storePath = agentSession.resolveStorePath();
115
+ const sessionStore = agentSession.loadSessionStore(storePath, { skipCache: true });
116
+ const normalizedKey = wf.child_session_key.toLowerCase();
117
+ if (sessionStore[normalizedKey]) {
118
+ delete sessionStore[normalizedKey];
119
+ await agentSession.saveSessionStore(storePath, sessionStore);
120
+ logger?.info?.(`[PD:Watchdog] Cleaned up stale session via agentSession fallback: ${wf.child_session_key}`);
121
+ }
122
+ }
123
+ } catch (cleanupErr) {
124
+ const errMsg = String(cleanupErr);
125
+ if (errMsg.includes('gateway request') && agentSession) {
126
+ const storePath = agentSession.resolveStorePath();
127
+ const sessionStore = agentSession.loadSessionStore(storePath, { skipCache: true });
128
+ const normalizedKey = wf.child_session_key.toLowerCase();
129
+ if (sessionStore[normalizedKey]) {
130
+ delete sessionStore[normalizedKey];
131
+ await agentSession.saveSessionStore(storePath, sessionStore);
132
+ logger?.info?.(`[PD:Watchdog] Cleaned up stale session via agentSession fallback after gateway error: ${wf.child_session_key}`);
133
+ }
134
+ } else {
135
+ logger?.warn?.(`[PD:Watchdog] Failed to cleanup session ${wf.child_session_key}: ${errMsg}`);
136
+ }
137
+ }
138
+ }
139
+
140
+ function runWorkflowWatchdogCheckStale(
141
+ allWorkflows: WorkflowRow[],
142
+ store: WorkflowStore,
143
+ now: number,
144
+ details: string[],
145
+ subagentRuntime: { deleteSession: (opts: { sessionKey: string; deleteTranscript: boolean }) => Promise<void> } | undefined,
146
+ agentSession: { resolveStorePath: () => string; loadSessionStore: (p: string, o: { skipCache: boolean }) => Record<string, unknown>; saveSessionStore: (p: string, s: Record<string, unknown>) => Promise<void> } | undefined,
147
+ logger?: PluginLogger,
148
+ ): void {
149
+ const staleThreshold = WORKFLOW_TTL_MS * 2;
150
+ for (const wf of allWorkflows) {
151
+ if (wf.state !== 'active' || (now - wf.created_at) <= staleThreshold) continue;
152
+ const ageMin = Math.round((now - wf.created_at) / 60000);
153
+ details.push(`stale_active: ${wf.workflow_id} (${wf.workflow_type}, ${ageMin}min old)`);
154
+
155
+ const events = store.getEvents(wf.workflow_id);
156
+ const lastEventReason = events.length > 0 ? events[events.length - 1].reason : 'unknown';
157
+ if (isExpectedSubagentError(lastEventReason)) {
158
+ logger?.debug?.(`[PD:Watchdog] Skipping stale active workflow ${wf.workflow_id}: expected subagent error (${lastEventReason})`);
159
+ continue;
160
+ }
161
+
162
+ store.updateWorkflowState(wf.workflow_id, 'terminal_error');
163
+ store.recordEvent(wf.workflow_id, 'watchdog_timeout', 'active', 'terminal_error', `Stale active > ${staleThreshold / 60000}s`, { ageMs: now - wf.created_at });
164
+ void cleanupStaleWorkflowSession(wf, subagentRuntime, agentSession, logger);
165
+ }
166
+ }
167
+
168
+ function runWorkflowWatchdogCheckUncleared(allWorkflows: WorkflowRow[], details: string[]): void {
169
+ const unclearedTerminal = allWorkflows.filter(
170
+ (wf: WorkflowRow) => (wf.state === 'terminal_error' || wf.state === 'expired') && wf.cleanup_state === 'pending',
171
+ );
172
+ if (unclearedTerminal.length > 0) {
173
+ details.push(`uncleared_terminal: ${unclearedTerminal.length} workflows (will be swept next cycle)`);
174
+ }
175
+ }
176
+
177
+ // eslint-disable-next-line complexity
178
+ function runWorkflowWatchdogCheckNocturnal(allWorkflows: WorkflowRow[], details: string[]): void {
179
+ for (const wf of allWorkflows) {
180
+ if (wf.workflow_type !== 'nocturnal' || wf.state !== 'completed') continue;
181
+ try {
182
+ const meta = JSON.parse(wf.metadata_json) as Record<string, unknown>;
183
+ const snapshot = meta.snapshot as Record<string, unknown> | undefined;
184
+ if (!snapshot) continue;
185
+ const dataSource = snapshot._dataSource as string | undefined;
186
+ if (dataSource === 'pain_context_fallback') {
187
+ details.push(`fallback_snapshot: nocturnal workflow ${wf.workflow_id} uses pain-context fallback (stats may be incomplete)`);
188
+ const stats = snapshot.stats as Record<string, number> | undefined;
189
+ if (stats && stats.totalToolCalls === 0 && stats.totalGateBlocks === 0 && stats.failureCount === 0) {
190
+ details.push(`fallback_snapshot_stats: nocturnal workflow ${wf.workflow_id} has empty fallback stats (no trajectory data found)`);
191
+ }
192
+ }
193
+ } catch { /* ignore malformed metadata */ }
194
+ }
195
+ }
196
+
197
+ // ── End watchdog helpers ──
198
+
184
199
  let timeoutId: NodeJS.Timeout | null = null;
185
200
 
186
201
  /**
@@ -355,6 +370,7 @@ function isSessionAtOrBeforeTriggerTime(
355
370
  return true;
356
371
  }
357
372
 
373
+ // eslint-disable-next-line complexity
358
374
  function buildFallbackNocturnalSnapshot(
359
375
  sleepTask: EvolutionQueueItem,
360
376
  extractor?: ReturnType<typeof createNocturnalTrajectoryExtractor> | null,
@@ -430,7 +446,7 @@ export const LOCK_RETRY_DELAY_MS = 50;
430
446
  export const LOCK_STALE_MS = 30_000;
431
447
 
432
448
 
433
- // eslint-disable-next-line @typescript-eslint/max-params
449
+
434
450
  export function createEvolutionTaskId(
435
451
  source: string,
436
452
  score: number,
@@ -464,7 +480,7 @@ export async function acquireQueueLock(resourcePath: string, logger: PluginLogge
464
480
  }
465
481
 
466
482
 
467
- // eslint-disable-next-line @typescript-eslint/max-params
483
+
468
484
  async function requireQueueLock(resourcePath: string, logger: PluginLogger | { warn?: (message: string) => void; info?: (message: string) => void } | undefined, scope: string, lockSuffix: string = EVOLUTION_QUEUE_LOCK_SUFFIX): Promise<() => void> {
469
485
  try {
470
486
  return await acquireQueueLock(resourcePath, logger, lockSuffix);
@@ -480,7 +496,7 @@ export function extractEvolutionTaskId(task: string): string | null {
480
496
  }
481
497
 
482
498
 
483
- // eslint-disable-next-line @typescript-eslint/max-params
499
+
484
500
  function findRecentDuplicateTask(
485
501
  queue: EvolutionQueueItem[],
486
502
  source: string,
@@ -488,14 +504,14 @@ function findRecentDuplicateTask(
488
504
  now: number,
489
505
  reason?: string
490
506
  ): EvolutionQueueItem | undefined {
491
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
507
+
492
508
  const key = normalizePainDedupKey(source, preview, reason);
493
509
  return queue.find((task) => {
494
510
  if (task.status === 'completed') return false;
495
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
511
+
496
512
  const taskTime = new Date(task.enqueued_at || task.timestamp).getTime();
497
513
  if (!Number.isFinite(taskTime) || (now - taskTime) > PAIN_QUEUE_DEDUP_WINDOW_MS) return false;
498
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
514
+
499
515
  return normalizePainDedupKey(task.source, task.trigger_text_preview || '', task.reason) === key;
500
516
  });
501
517
  }
@@ -550,7 +566,7 @@ function normalizePainDedupKey(source: string, preview: string, reason?: string)
550
566
 
551
567
 
552
568
 
553
- // eslint-disable-next-line @typescript-eslint/max-params
569
+
554
570
  export function hasRecentDuplicateTask(queue: EvolutionQueueItem[], source: string, preview: string, now: number, reason?: string): boolean {
555
571
  return !!findRecentDuplicateTask(queue, source, preview, now, reason);
556
572
  }
@@ -678,7 +694,7 @@ function shouldSkipForDedup(
678
694
  * Load and migrate the evolution queue. Returns empty array if file doesn't exist.
679
695
  */
680
696
  function loadEvolutionQueue(queuePath: string): EvolutionQueueItem[] {
681
- // eslint-disable-next-line @typescript-eslint/init-declarations, no-useless-assignment
697
+
682
698
  let rawQueue: RawQueueItem[] = [];
683
699
  try {
684
700
  rawQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
@@ -693,7 +709,7 @@ function loadEvolutionQueue(queuePath: string): EvolutionQueueItem[] {
693
709
  * Build and persist a new sleep_reflection task.
694
710
  */
695
711
 
696
- // eslint-disable-next-line @typescript-eslint/max-params
712
+
697
713
  function enqueueNewSleepReflectionTask(
698
714
  queue: EvolutionQueueItem[],
699
715
  recentPainContext: ReturnType<typeof readRecentPainContext>,
@@ -720,7 +736,7 @@ function enqueueNewSleepReflectionTask(
720
736
  recentPainContext,
721
737
  });
722
738
 
723
- fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
739
+ atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
724
740
  logger?.info?.(`[PD:EvolutionWorker] Enqueued sleep_reflection task ${taskId}`);
725
741
  }
726
742
 
@@ -765,7 +781,8 @@ interface ParsedPainValues {
765
781
  }
766
782
 
767
783
 
768
- // eslint-disable-next-line @typescript-eslint/max-params
784
+
785
+ // eslint-disable-next-line complexity
769
786
  async function doEnqueuePainTask(
770
787
  wctx: WorkspaceContext, logger: PluginLogger, painFlagPath: string,
771
788
  result: WorkerStatusReport['pain_flag'], v: ParsedPainValues,
@@ -811,7 +828,7 @@ async function doEnqueuePainTask(
811
828
  retryCount: 0, maxRetries: 3,
812
829
  });
813
830
 
814
- fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
831
+ atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
815
832
  fs.appendFileSync(painFlagPath, `\nstatus: queued\ntask_id: ${taskId}\n`, 'utf8');
816
833
  result.enqueued = true;
817
834
 
@@ -839,6 +856,7 @@ async function doEnqueuePainTask(
839
856
  return result;
840
857
  }
841
858
 
859
+ // eslint-disable-next-line complexity
842
860
  async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Promise<WorkerStatusReport['pain_flag']> {
843
861
  const result: WorkerStatusReport['pain_flag'] = { exists: false, score: null, source: null, enqueued: false, skipped_reason: null };
844
862
  try {
@@ -1012,7 +1030,8 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
1012
1030
  }
1013
1031
 
1014
1032
 
1015
- // eslint-disable-next-line @typescript-eslint/max-params
1033
+
1034
+ // eslint-disable-next-line complexity
1016
1035
  async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogger, eventLog: EventLog, api?: OpenClawPluginApi) {
1017
1036
  const queuePath = wctx.resolve('EVOLUTION_QUEUE');
1018
1037
  if (!fs.existsSync(queuePath)) {
@@ -1595,7 +1614,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1595
1614
 
1596
1615
  // Write claimed state (includes any pain changes from above) and release lock
1597
1616
  if (queueChanged) {
1598
- fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
1617
+ atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
1599
1618
  }
1600
1619
  releaseLock();
1601
1620
  for (const sleepTask of sleepReflectionTasks) {
@@ -1610,11 +1629,11 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1610
1629
  logger?.info?.(`[PD:EvolutionWorker] Processing sleep_reflection task ${sleepTask.id}`);
1611
1630
  }
1612
1631
 
1613
- // eslint-disable-next-line @typescript-eslint/init-declarations
1632
+
1614
1633
  let workflowId: string | undefined;
1615
- // eslint-disable-next-line @typescript-eslint/init-declarations
1634
+
1616
1635
  let nocturnalManager: NocturnalWorkflowManager;
1617
- // eslint-disable-next-line @typescript-eslint/init-declarations
1636
+
1618
1637
  let snapshotData: NocturnalSessionSnapshot | undefined;
1619
1638
 
1620
1639
  if (isPollingTask) {
@@ -1652,13 +1671,13 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1652
1671
  s => s.failureCount > 0 || s.painEventCount > 0 || s.gateBlockCount > 0
1653
1672
  );
1654
1673
  if (sessionsWithViolations.length > 0) {
1655
- // eslint-disable-next-line @typescript-eslint/prefer-destructuring
1674
+
1656
1675
  const targetSession = sessionsWithViolations[0];
1657
1676
  logger?.info?.(`[PD:EvolutionWorker] Task ${sleepTask.id} using session with violations: ${targetSession.sessionId} (failed=${targetSession.failureCount}, pain=${targetSession.painEventCount}, gates=${targetSession.gateBlockCount})`);
1658
1677
  fullSnapshot = extractor.getNocturnalSessionSnapshot(targetSession.sessionId);
1659
1678
  } else if (recentSessions.length > 0) {
1660
1679
  // No sessions with violations, use most recent as last resort
1661
- // eslint-disable-next-line @typescript-eslint/prefer-destructuring
1680
+
1662
1681
  const latestSession = recentSessions[0];
1663
1682
  logger?.warn?.(`[PD:EvolutionWorker] Task ${sleepTask.id} no sessions with violations found, using most recent: ${latestSession.sessionId} (failed=${latestSession.failureCount}, pain=${latestSession.painEventCount}, gates=${latestSession.gateBlockCount})`);
1664
1683
  fullSnapshot = extractor.getNocturnalSessionSnapshot(latestSession.sessionId);
@@ -1722,10 +1741,13 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1722
1741
  taskId: sleepTask.id,
1723
1742
  painContext: sleepTask.recentPainContext,
1724
1743
  triggerSource: sleepTask.source,
1744
+ // #297: Configure which preflight gates to skip.
1745
+ // sleep_reflection uses periodic trigger which bypasses idle by design.
1746
+ skipPreflightGates: ['idle'],
1725
1747
  },
1726
1748
  });
1727
1749
  sleepTask.resultRef = workflowHandle.workflowId;
1728
- // eslint-disable-next-line @typescript-eslint/prefer-destructuring
1750
+
1729
1751
  workflowId = workflowHandle.workflowId;
1730
1752
  }
1731
1753
 
@@ -1844,7 +1866,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1844
1866
  freshQueue[idx] = sleepTask;
1845
1867
  }
1846
1868
  }
1847
- fs.writeFileSync(queuePath, JSON.stringify(freshQueue, null, 2), 'utf8');
1869
+ atomicWriteFileSync(queuePath, JSON.stringify(freshQueue, null, 2));
1848
1870
 
1849
1871
  // Log completions to EvolutionLogger
1850
1872
  for (const sleepTask of sleepReflectionTasks) {
@@ -1876,7 +1898,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1876
1898
  }
1877
1899
 
1878
1900
  if (queueChanged) {
1879
- fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
1901
+ atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
1880
1902
  }
1881
1903
 
1882
1904
  // Pipeline observability: log stage-level summary at end of cycle
@@ -1903,6 +1925,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1903
1925
  }
1904
1926
 
1905
1927
 
1928
+ // eslint-disable-next-line complexity
1906
1929
  async function processDetectionQueue(wctx: WorkspaceContext, api: OpenClawPluginApi, eventLog: EventLog) {
1907
1930
  const {logger} = api;
1908
1931
  try {
@@ -1958,7 +1981,7 @@ async function processDetectionQueue(wctx: WorkspaceContext, api: OpenClawPlugin
1958
1981
  // Evolution queue is now the single active pain→principle path
1959
1982
 
1960
1983
 
1961
- // eslint-disable-next-line @typescript-eslint/max-params
1984
+
1962
1985
  export async function registerEvolutionTaskSession(
1963
1986
  workspaceResolve: (key: string) => string,
1964
1987
  taskId: string,
@@ -1972,7 +1995,7 @@ export async function registerEvolutionTaskSession(
1972
1995
 
1973
1996
  try {
1974
1997
 
1975
- // eslint-disable-next-line @typescript-eslint/init-declarations
1998
+
1976
1999
  let rawQueue: RawQueueItem[];
1977
2000
  try {
1978
2001
  rawQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
@@ -1994,7 +2017,7 @@ export async function registerEvolutionTaskSession(
1994
2017
  if (!task.started_at) {
1995
2018
  task.started_at = new Date().toISOString();
1996
2019
  }
1997
- fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
2020
+ atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
1998
2021
  return true;
1999
2022
  } finally {
2000
2023
  releaseLock();
@@ -2034,14 +2057,14 @@ interface WorkerStatusReport {
2034
2057
  function writeWorkerStatus(stateDir: string, report: WorkerStatusReport): void {
2035
2058
  try {
2036
2059
  const statusPath = path.join(stateDir, 'worker-status.json');
2037
- fs.writeFileSync(statusPath, JSON.stringify(report, null, 2), 'utf8');
2060
+ atomicWriteFileSync(statusPath, JSON.stringify(report, null, 2));
2038
2061
  } catch {
2039
2062
  // Non-critical: worker-status.json is for monitoring, failure is acceptable
2040
2063
  }
2041
2064
  }
2042
2065
 
2043
2066
 
2044
- // eslint-disable-next-line @typescript-eslint/max-params
2067
+
2045
2068
  async function processEvolutionQueueWithResult(
2046
2069
  wctx: WorkspaceContext,
2047
2070
  logger: PluginLogger,
@@ -2063,7 +2086,7 @@ async function processEvolutionQueueWithResult(
2063
2086
  const purgeResult = purgeStaleFailedTasks(queue, logger);
2064
2087
  if (purgeResult.purged > 0) {
2065
2088
  // Write back the cleaned queue
2066
- fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
2089
+ atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
2067
2090
  }
2068
2091
 
2069
2092
  queueResult.total = queue.length;
@@ -2090,6 +2113,7 @@ export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
2090
2113
  api: null,
2091
2114
  _startedWorkspaces: new Set<string>(),
2092
2115
 
2116
+ // eslint-disable-next-line complexity
2093
2117
  start(ctx: OpenClawPluginServiceContext): void {
2094
2118
  const workspaceDir = ctx?.workspaceDir;
2095
2119
  const logger = ctx?.logger || console;
@@ -2118,6 +2142,7 @@ export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
2118
2142
  const {config} = wctx;
2119
2143
  const language = config.get('language') || 'en';
2120
2144
  ensureStateTemplates({ logger }, wctx.stateDir, language);
2145
+ ensureCorePrinciples(wctx.stateDir, logger);
2121
2146
 
2122
2147
  const initialDelay = 5000;
2123
2148
  const interval = config.get('intervals.worker_poll_ms') || (15 * 60 * 1000);
@@ -2125,6 +2150,7 @@ export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
2125
2150
  // Periodic trigger tracking
2126
2151
  let heartbeatCounter = 0;
2127
2152
 
2153
+ // eslint-disable-next-line complexity
2128
2154
  async function runCycle(): Promise<void> {
2129
2155
  const cycleStart = Date.now();
2130
2156
  heartbeatCounter++;
@@ -2205,23 +2231,21 @@ export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
2205
2231
  // with a diagnostician task, immediately trigger a heartbeat to start
2206
2232
  // the diagnostician without waiting for the next 15-minute interval.
2207
2233
  // Must run AFTER processEvolutionQueue — HEARTBEAT.md must be written first.
2234
+ //
2235
+ // P3 (#299): Use requestHeartbeatNow instead of runHeartbeatOnce.
2236
+ // requestHeartbeatNow enters the wake layer which auto-retries on
2237
+ // requests-in-flight (1s intervals). runHeartbeatOnce was a one-shot
2238
+ // that got permanently skipped when agent was busy.
2208
2239
  if (painCheckResult.enqueued) {
2209
- const canTrigger = !!api?.runtime?.system?.runHeartbeatOnce;
2210
- logger.info(`[PD:EvolutionWorker] Pain flag enqueued — runHeartbeatOnce available: ${canTrigger} (api=${!!api}, runtime=${!!api?.runtime}, system=${!!api?.runtime?.system})`);
2240
+ const canTrigger = !!api?.runtime?.system?.requestHeartbeatNow;
2241
+ logger.info(`[PD:EvolutionWorker] Pain flag enqueued — requestHeartbeatNow available: ${canTrigger}`);
2211
2242
  if (canTrigger) {
2212
- try {
2213
- const hbResult = await api.runtime.system.runHeartbeatOnce({
2214
- reason: `pd-pain-diagnosis: pain flag detected, starting diagnostician`,
2215
- });
2216
- logger.info(`[PD:EvolutionWorker] Immediate heartbeat result: status=${hbResult.status}${hbResult.status === 'ran' ? ` duration=${hbResult.durationMs}ms` : ''}${hbResult.status === 'skipped' || hbResult.status === 'failed' ? ` reason=${hbResult.reason}` : ''}`);
2217
- if (hbResult.status === 'skipped' || hbResult.status === 'failed') {
2218
- logger.warn(`[PD:EvolutionWorker] Immediate heartbeat was ${hbResult.status} (${hbResult.reason}). Diagnostician will start on next regular heartbeat cycle.`);
2219
- }
2220
- } catch (hbErr) {
2221
- logger.warn(`[PD:EvolutionWorker] Failed to trigger immediate heartbeat: ${String(hbErr)}. Diagnostician will start on next regular heartbeat cycle.`);
2222
- }
2243
+ api.runtime.system.requestHeartbeatNow({
2244
+ reason: `pd-pain-diagnosis: pain flag detected, starting diagnostician`,
2245
+ });
2246
+ logger.info(`[PD:EvolutionWorker] Heartbeat wake requested — wake layer will auto-retry if busy`);
2223
2247
  } else {
2224
- logger.warn(`[PD:EvolutionWorker] runHeartbeatOnce not available. Diagnostician will start on next regular heartbeat cycle.`);
2248
+ logger.warn(`[PD:EvolutionWorker] requestHeartbeatNow not available. Diagnostician will start on next regular heartbeat cycle.`);
2225
2249
  }
2226
2250
  }
2227
2251
 
@@ -385,7 +385,10 @@ function persistArtifact(
385
385
  fs.mkdirSync(dir, { recursive: true });
386
386
  }
387
387
 
388
- fs.writeFileSync(artifactPath, JSON.stringify(sampleRecord, null, 2), 'utf-8');
388
+ // Atomic write: temp file + rename prevents corruption on crash
389
+ const tmpPath = artifactPath + '.tmp';
390
+ fs.writeFileSync(tmpPath, JSON.stringify(sampleRecord, null, 2), 'utf8');
391
+ fs.renameSync(tmpPath, artifactPath);
389
392
  return artifactPath;
390
393
  }
391
394
 
@@ -36,6 +36,20 @@ export {
36
36
  type NocturnalResult,
37
37
  } from './nocturnal-workflow-manager.js';
38
38
 
39
+ // TODO: correction-observer-workflow-manager.ts is missing from repo
40
+ // export {
41
+ // CorrectionObserverWorkflowManager,
42
+ // createCorrectionObserverWorkflowManager,
43
+ // correctionObserverWorkflowSpec,
44
+ // type CorrectionObserverWorkflowOptions,
45
+ // } from './correction-observer-workflow-manager.js';
46
+
47
+ // export type {
48
+ // CorrectionObserverWorkflowSpec,
49
+ // CorrectionObserverPayload,
50
+ // CorrectionObserverResult,
51
+ // } from './correction-observer-types.js';
52
+
39
53
  export type {
40
54
  WorkflowState,
41
55
  WorkflowTransport,
@@ -260,12 +260,9 @@ export class NocturnalWorkflowManager implements WorkflowManager {
260
260
  },
261
261
  // Pass painContext for Selector ranking bias
262
262
  painContext,
263
- // #244: Only skip preflight idle gate for manual/test triggers.
264
- // Automatic triggers must go through normal idle check.
265
- // #292: Periodic triggers (source='nocturnal') also bypass idle check for debugging
266
- ...(((options.metadata)?.triggerSource === 'manual' ||
267
- (options.metadata)?.triggerSource === 'test' ||
268
- (options.metadata)?.triggerSource === 'nocturnal')
263
+ // #244: Skip preflight gates as configured by caller (e.g. manual/test/sleep_reflection).
264
+ // Gates not in skipPreflightGates go through normal checks.
265
+ ...(((options.metadata)?.skipPreflightGates as string[] | undefined)?.includes('idle')
269
266
  ? {
270
267
  idleCheckOverride: {
271
268
  isIdle: true,
@@ -274,7 +271,7 @@ export class NocturnalWorkflowManager implements WorkflowManager {
274
271
  userActiveSessions: 0,
275
272
  abandonedSessionIds: [],
276
273
  trajectoryGuardrailConfirmsIdle: true,
277
- reason: `${(options.metadata)?.triggerSource ?? 'manual'}.test override`,
274
+ reason: 'skipPreflightGates override',
278
275
  },
279
276
  }
280
277
  : {}),
@@ -314,7 +311,9 @@ export class NocturnalWorkflowManager implements WorkflowManager {
314
311
  this.logger.warn(`[PD:NocturnalWorkflow] [${workflowId}] Arbiter result: passed=${result.diagnostics.arbiterResult.passed}, failures=${result.diagnostics.arbiterResult.failures.map(f => f.reason).join('; ')}`);
315
312
  }
316
313
  if (result.diagnostics?.selection) {
317
- this.logger.warn(`[PD:NocturnalWorkflow] [${workflowId}] Selection: decision=${result.diagnostics.selection.decision}, principleId=${result.diagnostics.selection.selectedPrincipleId ?? 'none'}, sessionId=${result.diagnostics.selection.selectedSessionId ?? 'none'}`);
314
+ const sel = result.diagnostics.selection;
315
+ const diag = sel.diagnostics;
316
+ this.logger.warn(`[PD:NocturnalWorkflow] [${workflowId}] Selection: decision=${sel.decision}, principleId=${sel.selectedPrincipleId ?? 'none'}, sessionId=${sel.selectedSessionId ?? 'none'}, totalEvaluable=${diag.totalEvaluablePrinciples ?? 0}, filteredByCooldown=${diag.filteredByCooldown ?? 0}, passed=${diag.passedPrinciples?.length ?? 0}`);
318
317
  }
319
318
 
320
319
  this.store.updateWorkflowState(workflowId, 'terminal_error');
@@ -58,12 +58,19 @@ import { EvolutionWorkerService, readRecentPainContext } from '../../src/service
58
58
  import { WorkspaceContext } from '../../src/core/workspace-context.js';
59
59
  import { handlePdReflect } from '../../src/commands/pd-reflect.js';
60
60
  import { safeRmDir } from '../test-utils.js';
61
+ import * as diagnosticianStore from '../../src/core/diagnostician-task-store.js';
61
62
 
62
63
  // Helper to create a mock API for E2E tests
63
64
  function createMockApi() {
64
65
  return {
65
66
  logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
66
- runtime: { agent: { runEmbeddedPiAgent: vi.fn() } },
67
+ runtime: {
68
+ agent: { runEmbeddedPiAgent: vi.fn() },
69
+ system: {
70
+ requestHeartbeatNow: vi.fn(),
71
+ runHeartbeatOnce: vi.fn()
72
+ }
73
+ },
67
74
  } as any;
68
75
  }
69
76
 
@@ -584,4 +591,10 @@ session_id: pain-session-abc
584
591
  safeRmDir(workspaceDir);
585
592
  }
586
593
  });
594
+
595
+ // === PR #307 Fixes: Pain Diagnosis Timeout & Heartbeat Retry ===
596
+
597
+ // Note: Testing requestHeartbeatNow call directly is complex due to
598
+ // the async nature of checkPainFlag → doEnqueuePainTask → requestHeartbeatNow.
599
+ // The fix is verified via E2E monitoring (PR #307 production verification).
587
600
  });