principles-disciple 1.11.0 → 1.13.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 (233) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +66 -0
  3. package/esbuild.config.js +1 -1
  4. package/openclaw.plugin.json +4 -4
  5. package/package.json +2 -3
  6. package/run-nocturnal.mjs +30 -0
  7. package/scripts/db-migrate.mjs +170 -0
  8. package/scripts/sync-plugin.mjs +250 -6
  9. package/src/commands/archive-impl.ts +136 -0
  10. package/src/commands/capabilities.ts +4 -2
  11. package/src/commands/context.ts +5 -1
  12. package/src/commands/disable-impl.ts +151 -0
  13. package/src/commands/evolution-status.ts +64 -19
  14. package/src/commands/export.ts +8 -6
  15. package/src/commands/focus.ts +8 -20
  16. package/src/commands/nocturnal-review.ts +5 -7
  17. package/src/commands/nocturnal-rollout.ts +1 -12
  18. package/src/commands/nocturnal-train.ts +21 -47
  19. package/src/commands/pain.ts +10 -5
  20. package/src/commands/principle-rollback.ts +4 -2
  21. package/src/commands/promote-impl.ts +274 -0
  22. package/src/commands/rollback-impl.ts +234 -0
  23. package/src/commands/rollback.ts +6 -3
  24. package/src/commands/samples.ts +2 -0
  25. package/src/commands/thinking-os.ts +3 -4
  26. package/src/commands/workflow-debug.ts +2 -1
  27. package/src/config/errors.ts +1 -0
  28. package/src/core/AGENTS.md +34 -0
  29. package/src/core/adaptive-thresholds.ts +4 -3
  30. package/src/core/code-implementation-storage.ts +241 -0
  31. package/src/core/config.ts +5 -2
  32. package/src/core/control-ui-db.ts +29 -10
  33. package/src/core/detection-funnel.ts +12 -7
  34. package/src/core/diagnostician-task-store.ts +156 -0
  35. package/src/core/dictionary.ts +4 -4
  36. package/src/core/empathy-keyword-matcher.ts +7 -3
  37. package/src/core/empathy-types.ts +13 -2
  38. package/src/core/event-log.ts +14 -6
  39. package/src/core/evolution-engine.ts +27 -31
  40. package/src/core/evolution-logger.ts +3 -2
  41. package/src/core/evolution-reducer.ts +110 -31
  42. package/src/core/evolution-types.ts +10 -0
  43. package/src/core/external-training-contract.ts +1 -0
  44. package/src/core/focus-history.ts +38 -24
  45. package/src/core/hygiene/tracker.ts +10 -6
  46. package/src/core/init.ts +5 -2
  47. package/src/core/migration.ts +3 -3
  48. package/src/core/model-deployment-registry.ts +6 -4
  49. package/src/core/model-training-registry.ts +5 -3
  50. package/src/core/nocturnal-arbiter.ts +13 -14
  51. package/src/core/nocturnal-artifact-lineage.ts +117 -0
  52. package/src/core/nocturnal-artificer.ts +257 -0
  53. package/src/core/nocturnal-candidate-scoring.ts +4 -2
  54. package/src/core/nocturnal-compliance.ts +67 -19
  55. package/src/core/nocturnal-dataset.ts +95 -2
  56. package/src/core/nocturnal-executability.ts +2 -3
  57. package/src/core/nocturnal-export.ts +6 -3
  58. package/src/core/nocturnal-rule-implementation-validator.ts +245 -0
  59. package/src/core/nocturnal-trajectory-extractor.ts +10 -3
  60. package/src/core/nocturnal-trinity.ts +319 -61
  61. package/src/core/pain-context-extractor.ts +29 -15
  62. package/src/core/pain.ts +7 -5
  63. package/src/core/path-resolver.ts +16 -15
  64. package/src/core/paths.ts +2 -1
  65. package/src/core/pd-task-reconciler.ts +463 -0
  66. package/src/core/pd-task-service.ts +42 -0
  67. package/src/core/pd-task-store.ts +77 -0
  68. package/src/core/pd-task-types.ts +128 -0
  69. package/src/core/principle-internalization/deprecated-readiness.ts +91 -0
  70. package/src/core/principle-internalization/internalization-routing-policy.ts +208 -0
  71. package/src/core/principle-internalization/lifecycle-metrics.ts +149 -0
  72. package/src/core/principle-internalization/lifecycle-read-model.ts +243 -0
  73. package/src/core/principle-internalization/lifecycle-refresh.ts +11 -0
  74. package/src/core/principle-internalization/principle-lifecycle-service.ts +167 -0
  75. package/src/core/principle-training-state.ts +95 -370
  76. package/src/core/principle-tree-ledger.ts +733 -0
  77. package/src/core/principle-tree-migration.ts +195 -0
  78. package/src/core/profile.ts +3 -1
  79. package/src/core/promotion-gate.ts +14 -18
  80. package/src/core/replay-engine.ts +562 -0
  81. package/src/core/risk-calculator.ts +6 -4
  82. package/src/core/rule-host-helpers.ts +39 -0
  83. package/src/core/rule-host-types.ts +82 -0
  84. package/src/core/rule-host.ts +245 -0
  85. package/src/core/rule-implementation-runtime.ts +38 -0
  86. package/src/core/schema/db-types.ts +16 -0
  87. package/src/core/schema/index.ts +26 -0
  88. package/src/core/schema/migration-runner.ts +207 -0
  89. package/src/core/schema/migrations/001-init-trajectory.ts +211 -0
  90. package/src/core/schema/migrations/002-init-central.ts +122 -0
  91. package/src/core/schema/migrations/003-init-workflow.ts +55 -0
  92. package/src/core/schema/migrations/004-add-thinking-and-gfi.ts +74 -0
  93. package/src/core/schema/migrations/index.ts +31 -0
  94. package/src/core/schema/schema-definitions.ts +650 -0
  95. package/src/core/session-tracker.ts +6 -4
  96. package/src/core/shadow-observation-registry.ts +6 -3
  97. package/src/core/system-logger.ts +2 -2
  98. package/src/core/thinking-models.ts +182 -46
  99. package/src/core/thinking-os-parser.ts +156 -0
  100. package/src/core/training-program.ts +7 -7
  101. package/src/core/trajectory.ts +42 -36
  102. package/src/core/workspace-context.ts +77 -11
  103. package/src/core/workspace-dir-validation.ts +152 -0
  104. package/src/hooks/AGENTS.md +31 -0
  105. package/src/hooks/bash-risk.ts +3 -1
  106. package/src/hooks/edit-verification.ts +9 -5
  107. package/src/hooks/gate-block-helper.ts +5 -1
  108. package/src/hooks/gate.ts +152 -5
  109. package/src/hooks/gfi-gate.ts +9 -2
  110. package/src/hooks/lifecycle-routing.ts +124 -0
  111. package/src/hooks/lifecycle.ts +12 -12
  112. package/src/hooks/llm.ts +17 -109
  113. package/src/hooks/message-sanitize.ts +5 -3
  114. package/src/hooks/pain.ts +19 -15
  115. package/src/hooks/progressive-trust-gate.ts +7 -1
  116. package/src/hooks/prompt.ts +169 -60
  117. package/src/hooks/subagent.ts +5 -4
  118. package/src/hooks/thinking-checkpoint.ts +2 -0
  119. package/src/hooks/trajectory-collector.ts +15 -12
  120. package/src/http/principles-console-route.ts +31 -68
  121. package/src/i18n/commands.ts +2 -2
  122. package/src/index.ts +130 -40
  123. package/src/service/central-database.ts +131 -43
  124. package/src/service/central-health-service.ts +47 -0
  125. package/src/service/central-overview-service.ts +135 -0
  126. package/src/service/central-sync-service.ts +87 -0
  127. package/src/service/control-ui-query-service.ts +46 -36
  128. package/src/service/event-log-auditor.ts +261 -0
  129. package/src/service/evolution-query-service.ts +23 -22
  130. package/src/service/evolution-worker.ts +565 -261
  131. package/src/service/health-query-service.ts +213 -36
  132. package/src/service/nocturnal-runtime.ts +8 -4
  133. package/src/service/nocturnal-service.ts +503 -59
  134. package/src/service/nocturnal-target-selector.ts +5 -7
  135. package/src/service/runtime-summary-service.ts +2 -1
  136. package/src/service/subagent-workflow/deep-reflect-workflow-manager.ts +25 -336
  137. package/src/service/subagent-workflow/dynamic-timeout.ts +30 -0
  138. package/src/service/subagent-workflow/empathy-observer-workflow-manager.ts +48 -386
  139. package/src/service/subagent-workflow/index.ts +2 -0
  140. package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +169 -284
  141. package/src/service/subagent-workflow/runtime-direct-driver.ts +114 -16
  142. package/src/service/subagent-workflow/subagent-error-utils.ts +25 -0
  143. package/src/service/subagent-workflow/types.ts +9 -4
  144. package/src/service/subagent-workflow/workflow-manager-base.ts +573 -0
  145. package/src/service/subagent-workflow/workflow-store.ts +71 -11
  146. package/src/service/trajectory-service.ts +2 -1
  147. package/src/tools/critique-prompt.ts +1 -1
  148. package/src/tools/deep-reflect.ts +175 -209
  149. package/src/tools/model-index.ts +2 -1
  150. package/src/types/event-types.ts +2 -2
  151. package/src/types/principle-tree-schema.ts +29 -23
  152. package/src/utils/file-lock.ts +5 -3
  153. package/src/utils/io.ts +5 -2
  154. package/src/utils/nlp.ts +5 -46
  155. package/src/utils/node-vm-polyfill.ts +11 -0
  156. package/src/utils/plugin-logger.ts +2 -0
  157. package/src/utils/retry.ts +572 -0
  158. package/src/utils/subagent-probe.ts +1 -1
  159. package/templates/langs/en/core/AGENTS.md +0 -13
  160. package/templates/langs/en/core/SOUL.md +1 -31
  161. package/templates/langs/en/core/TOOLS.md +0 -4
  162. package/templates/langs/en/principles/THINKING_OS.md +77 -0
  163. package/templates/langs/en/skills/admin/SKILL.md +0 -1
  164. package/templates/langs/en/skills/evolution-framework-update/SKILL.md +1 -1
  165. package/templates/langs/en/skills/pd-diagnostician/SKILL.md +18 -5
  166. package/templates/langs/zh/core/AGENTS.md +0 -22
  167. package/templates/langs/zh/core/SOUL.md +1 -31
  168. package/templates/langs/zh/core/TOOLS.md +0 -4
  169. package/templates/langs/zh/principles/THINKING_OS.md +77 -0
  170. package/templates/langs/zh/skills/admin/SKILL.md +0 -1
  171. package/templates/langs/zh/skills/evolution-framework-update/SKILL.md +1 -1
  172. package/templates/langs/zh/skills/pd-diagnostician/SKILL.md +25 -4
  173. package/tests/commands/evolution-status.test.ts +119 -0
  174. package/tests/commands/implementation-lifecycle.test.ts +362 -0
  175. package/tests/core/code-implementation-storage.test.ts +398 -0
  176. package/tests/core/evolution-reducer.detector-metadata.test.ts +28 -28
  177. package/tests/core/nocturnal-artifact-lineage.test.ts +53 -0
  178. package/tests/core/nocturnal-artificer.test.ts +241 -0
  179. package/tests/core/nocturnal-compliance-p-principles.test.ts +133 -0
  180. package/tests/core/nocturnal-rule-implementation-validator.test.ts +127 -0
  181. package/tests/core/pd-task-store.test.ts +126 -0
  182. package/tests/core/principle-internalization/deprecated-readiness.test.ts +193 -0
  183. package/tests/core/principle-internalization/internalization-routing-policy.test.ts +212 -0
  184. package/tests/core/principle-internalization/lifecycle-metrics.test.ts +350 -0
  185. package/tests/core/principle-internalization/principle-lifecycle-service.test.ts +211 -0
  186. package/tests/core/principle-training-state.test.ts +228 -1
  187. package/tests/core/principle-tree-ledger.test.ts +423 -0
  188. package/tests/core/regression-v1-9-1.test.ts +265 -0
  189. package/tests/core/replay-engine.test.ts +234 -0
  190. package/tests/core/rule-host-helpers.test.ts +120 -0
  191. package/tests/core/rule-host.test.ts +389 -0
  192. package/tests/core/rule-implementation-runtime.test.ts +64 -0
  193. package/tests/core/workspace-context.test.ts +53 -0
  194. package/tests/core/workspace-dir-validation.test.ts +272 -0
  195. package/tests/hooks/gate-rule-host-pipeline.test.ts +385 -0
  196. package/tests/hooks/pain.test.ts +74 -10
  197. package/tests/hooks/prompt.test.ts +63 -1
  198. package/tests/integration/principle-lifecycle.e2e.test.ts +197 -0
  199. package/tests/integration/tool-hooks-workspace-dir.e2e.test.ts +211 -0
  200. package/tests/service/data-endpoints-regression.test.ts +834 -0
  201. package/tests/service/evolution-worker.test.ts +0 -123
  202. package/tests/service/nocturnal-service-code-candidate.test.ts +330 -0
  203. package/tests/utils/nlp.test.ts +1 -19
  204. package/tests/utils/retry.test.ts +327 -0
  205. package/ui/src/App.tsx +1 -1
  206. package/ui/src/api.ts +4 -0
  207. package/ui/src/charts.tsx +366 -0
  208. package/ui/src/components/WorkspaceConfig.tsx +107 -75
  209. package/ui/src/i18n/ui.ts +89 -31
  210. package/ui/src/pages/EvolutionPage.tsx +1 -1
  211. package/ui/src/pages/OverviewPage.tsx +441 -81
  212. package/ui/src/pages/ThinkingModelsPage.tsx +287 -69
  213. package/ui/src/styles.css +43 -0
  214. package/ui/src/types.ts +17 -1
  215. package/src/agents/nocturnal-dreamer.md +0 -152
  216. package/src/agents/nocturnal-philosopher.md +0 -138
  217. package/src/agents/nocturnal-reflector.md +0 -126
  218. package/src/agents/nocturnal-scribe.md +0 -164
  219. package/templates/workspace/.principles/00-kernel.md +0 -51
  220. package/templates/workspace/.principles/DECISION_POLICY.json +0 -44
  221. package/templates/workspace/.principles/PRINCIPLES.md +0 -20
  222. package/templates/workspace/.principles/PROFILE.json +0 -54
  223. package/templates/workspace/.principles/PROFILE.schema.json +0 -56
  224. package/templates/workspace/.principles/THINKING_OS.md +0 -64
  225. package/templates/workspace/.principles/THINKING_OS_ARCHIVE.md +0 -7
  226. package/templates/workspace/.principles/THINKING_OS_CANDIDATES.md +0 -9
  227. package/templates/workspace/.principles/models/_INDEX.md +0 -27
  228. package/templates/workspace/.principles/models/first_principles.md +0 -62
  229. package/templates/workspace/.principles/models/marketing_4p.md +0 -52
  230. package/templates/workspace/.principles/models/porter_five.md +0 -63
  231. package/templates/workspace/.principles/models/swot.md +0 -60
  232. package/templates/workspace/.principles/models/user_story_map.md +0 -63
  233. package/templates/workspace/.state/WORKBOARD.json +0 -4
@@ -1,3 +1,4 @@
1
+ /* global NodeJS */
1
2
  import * as fs from 'fs';
2
3
  import * as path from 'path';
3
4
  import { createHash } from 'crypto';
@@ -5,25 +6,155 @@ import type { OpenClawPluginServiceContext, OpenClawPluginApi, PluginLogger } fr
5
6
  import { DictionaryService } from '../core/dictionary-service.js';
6
7
  import { DetectionService } from '../core/detection-service.js';
7
8
  import { ensureStateTemplates } from '../core/init.js';
8
- import { extractCommonSubstring } from '../utils/nlp.js';
9
9
  import { SystemLogger } from '../core/system-logger.js';
10
10
  import { WorkspaceContext } from '../core/workspace-context.js';
11
- import { EventLog } from '../core/event-log.js';
11
+ import type { EventLog } from '../core/event-log.js';
12
12
  import { initPersistence, flushAllSessions } from '../core/session-tracker.js';
13
- import { acquireLockAsync, releaseLock, type LockContext } from '../utils/file-lock.js';
14
- import { getEvolutionLogger, type EvolutionStage } from '../core/evolution-logger.js';
13
+ import { acquireLockAsync, releaseLock as releaseImportedLock, type LockContext } from '../utils/file-lock.js';
14
+ import { addDiagnosticianTask, completeDiagnosticianTask } from '../core/diagnostician-task-store.js';
15
+ import { getEvolutionLogger } from '../core/evolution-logger.js';
15
16
  import type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
16
17
  export type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
17
18
  import { LockUnavailableError } from '../config/index.js';
18
19
  import { checkWorkspaceIdle, checkCooldown } from './nocturnal-runtime.js';
19
20
  import { WorkflowStore } from './subagent-workflow/workflow-store.js';
21
+ import type { WorkflowRow } from './subagent-workflow/types.js';
20
22
  import { EmpathyObserverWorkflowManager } from './subagent-workflow/empathy-observer-workflow-manager.js';
21
23
  import { DeepReflectWorkflowManager } from './subagent-workflow/deep-reflect-workflow-manager.js';
22
24
  import { NocturnalWorkflowManager, nocturnalWorkflowSpec } from './subagent-workflow/nocturnal-workflow-manager.js';
25
+ import { createNocturnalTrajectoryExtractor } from '../core/nocturnal-trajectory-extractor.js';
26
+ import { isExpectedSubagentError } from './subagent-workflow/subagent-error-utils.js';
23
27
 
24
28
  const WORKFLOW_TTL_MS = 5 * 60 * 1000; // 5 minutes default TTL for helper workflows
25
29
  import { OpenClawTrinityRuntimeAdapter } from '../core/nocturnal-trinity.js';
26
30
 
31
+ // ── Workflow Watchdog ────────────────────────────────────────────────────────
32
+ // Detects stale/orphaned workflows, invalid results, and cleanup failures.
33
+ // Runs every heartbeat cycle, catching bugs like:
34
+ // #185 — orphaned active workflows
35
+ // #181 — structurally invalid results (all zeros)
36
+ // #180/#183 — expired workflows not swept
37
+ // #182 — unhandled rejections leaving workflows in limbo
38
+
39
+ interface WatchdogResult {
40
+ anomalies: number;
41
+ details: string[];
42
+ }
43
+
44
+ async function runWorkflowWatchdog(
45
+ wctx: WorkspaceContext,
46
+ api: OpenClawPluginApi | null,
47
+ logger?: PluginLogger,
48
+ ): Promise<WatchdogResult> {
49
+ const details: string[] = [];
50
+ const now = Date.now();
51
+ const subagentRuntime = api?.runtime?.subagent;
52
+ const agentSession = api?.runtime?.agent?.session;
53
+
54
+ try {
55
+ const store = new WorkflowStore({ workspaceDir: wctx.workspaceDir });
56
+ try {
57
+ const allWorkflows: WorkflowRow[] = store.listWorkflows();
58
+
59
+ // Check 1: Stale active workflows (active > 2x TTL)
60
+ const staleThreshold = WORKFLOW_TTL_MS * 2;
61
+ const staleActive = allWorkflows.filter(
62
+ (wf: WorkflowRow) => wf.state === 'active' && (now - wf.created_at) > staleThreshold,
63
+ );
64
+ if (staleActive.length > 0) {
65
+ for (const wf of staleActive) {
66
+ const ageMin = Math.round((now - wf.created_at) / 60000);
67
+ details.push(`stale_active: ${wf.workflow_id} (${wf.workflow_type}, ${ageMin}min old)`);
68
+ store.updateWorkflowState(wf.workflow_id, 'terminal_error');
69
+ store.recordEvent(wf.workflow_id, 'watchdog_timeout', 'active', 'terminal_error', `Stale active > ${staleThreshold / 60000}s`, { ageMs: now - wf.created_at });
70
+
71
+ // Cleanup session if possible (#188: gateway-safe fallback)
72
+ if (wf.child_session_key) {
73
+ try {
74
+ if (subagentRuntime) {
75
+ await subagentRuntime.deleteSession({ sessionKey: wf.child_session_key, deleteTranscript: true });
76
+ logger?.info?.(`[PD:Watchdog] Cleaned up stale session: ${wf.child_session_key}`);
77
+ } else if (agentSession) {
78
+ const storePath = agentSession.resolveStorePath();
79
+ const sessionStore = agentSession.loadSessionStore(storePath, { skipCache: true });
80
+ const normalizedKey = wf.child_session_key.toLowerCase();
81
+ if (sessionStore[normalizedKey]) {
82
+ delete sessionStore[normalizedKey];
83
+ await agentSession.saveSessionStore(storePath, sessionStore);
84
+ logger?.info?.(`[PD:Watchdog] Cleaned up stale session via agentSession fallback: ${wf.child_session_key}`);
85
+ }
86
+ }
87
+ } catch (cleanupErr) {
88
+ const errMsg = String(cleanupErr);
89
+ if (errMsg.includes('gateway request') && agentSession) {
90
+ const storePath = agentSession.resolveStorePath();
91
+ const sessionStore = agentSession.loadSessionStore(storePath, { skipCache: true });
92
+ const normalizedKey = wf.child_session_key.toLowerCase();
93
+ if (sessionStore[normalizedKey]) {
94
+ delete sessionStore[normalizedKey];
95
+ await agentSession.saveSessionStore(storePath, sessionStore);
96
+ logger?.info?.(`[PD:Watchdog] Cleaned up stale session via agentSession fallback after gateway error: ${wf.child_session_key}`);
97
+ }
98
+ } else {
99
+ logger?.warn?.(`[PD:Watchdog] Failed to cleanup session ${wf.child_session_key}: ${errMsg}`);
100
+ }
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ // Check 2: Workflows in terminal_error/expired without cleanup
107
+ const unclearedTerminal = allWorkflows.filter(
108
+ (wf: WorkflowRow) => (wf.state === 'terminal_error' || wf.state === 'expired') && wf.cleanup_state === 'pending',
109
+ );
110
+ if (unclearedTerminal.length > 0) {
111
+ details.push(`uncleared_terminal: ${unclearedTerminal.length} workflows (will be swept next cycle)`);
112
+ }
113
+
114
+ // Check 3: Nocturnal workflow result validation (#181 pattern)
115
+ const nocturnalCompleted = allWorkflows.filter(
116
+ (wf: WorkflowRow) => wf.workflow_type === 'nocturnal' && wf.state === 'completed',
117
+ );
118
+ for (const wf of nocturnalCompleted) {
119
+ // Check if the metadata snapshot has all zeros (invalid data)
120
+ try {
121
+ const meta = JSON.parse(wf.metadata_json) as Record<string, unknown>;
122
+ const snapshot = meta.snapshot as Record<string, unknown> | undefined;
123
+ if (snapshot) {
124
+ // #219: Check for fallback data source (partial stats from pain context)
125
+ const dataSource = snapshot._dataSource as string | undefined;
126
+ if (dataSource === 'pain_context_fallback') {
127
+ details.push(`fallback_snapshot: nocturnal workflow ${wf.workflow_id} uses pain-context fallback (stats may be incomplete)`);
128
+ }
129
+ const stats = snapshot.stats as Record<string, number> | undefined;
130
+ if (stats && stats.totalAssistantTurns === 0 && stats.totalToolCalls === 0 && stats.totalPainEvents === 0 && stats.totalGateBlocks === 0) {
131
+ details.push(`empty_snapshot: nocturnal workflow ${wf.workflow_id} has all-zero stats`);
132
+ }
133
+ }
134
+ } catch { /* ignore malformed metadata */ }
135
+ }
136
+
137
+ // Summary
138
+ const stateCounts: Record<string, number> = {};
139
+ for (const wf of allWorkflows) {
140
+ stateCounts[wf.state] = (stateCounts[wf.state] || 0) + 1;
141
+ }
142
+ const stateSummary = Object.entries(stateCounts).map(([s, c]) => `${s}=${c}`).join(', ');
143
+ if (details.length === 0) {
144
+ logger?.debug?.(`[PD:Watchdog] OK — ${allWorkflows.length} workflows (${stateSummary})`);
145
+ } else {
146
+ logger?.info?.(`[PD:Watchdog] ${details.length} anomalies — ${allWorkflows.length} workflows (${stateSummary})`);
147
+ }
148
+ } finally {
149
+ store.dispose();
150
+ }
151
+ } catch (err) {
152
+ logger?.warn?.(`[PD:Watchdog] Failed to scan workflows: ${String(err)}`);
153
+ }
154
+
155
+ return { anomalies: details.length, details };
156
+ }
157
+
27
158
  let timeoutId: NodeJS.Timeout | null = null;
28
159
 
29
160
  /**
@@ -36,7 +167,7 @@ let timeoutId: NodeJS.Timeout | null = null;
36
167
  * Old queue items (without taskKind) are migrated to pain_diagnosis for compatibility.
37
168
  */
38
169
  export type QueueStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'canceled';
39
- export type TaskResolution = 'marker_detected' | 'auto_completed_timeout' | 'failed_max_retries' | 'canceled' | 'late_marker_principle_created' | 'late_marker_no_principle';
170
+ export type TaskResolution = 'marker_detected' | 'auto_completed_timeout' | 'failed_max_retries' | 'canceled' | 'late_marker_principle_created' | 'late_marker_no_principle' | 'stub_fallback';
40
171
 
41
172
  /**
42
173
  * Recent pain context attached to sleep_reflection tasks.
@@ -123,14 +254,6 @@ interface LegacyEvolutionQueueItem {
123
254
  resultRef?: string;
124
255
  }
125
256
 
126
- interface PainCandidateEntry {
127
- count: number;
128
- status: string;
129
- firstSeen: string;
130
- lastSeen: string;
131
- samples: string[];
132
- }
133
-
134
257
  /**
135
258
  * Default values for new V2 fields when migrating legacy items.
136
259
  */
@@ -190,15 +313,11 @@ const PAIN_QUEUE_DEDUP_WINDOW_MS = 30 * 60 * 1000;
190
313
 
191
314
  // P0 fix: File lock constants and helper for queue operations (prevents TOCTOU race)
192
315
  export const EVOLUTION_QUEUE_LOCK_SUFFIX = '.lock';
193
- export const PAIN_CANDIDATES_LOCK_SUFFIX = '.candidates.lock';
194
316
  export const LOCK_MAX_RETRIES = 50;
195
317
  export const LOCK_RETRY_DELAY_MS = 50;
196
318
  export const LOCK_STALE_MS = 30_000;
197
- const PAIN_CANDIDATE_MAX_SAMPLES = 5;
198
- const PAIN_CANDIDATE_SAMPLE_LEN = 1000;
199
- const PAIN_CANDIDATE_FINGERPRINT_HEAD_LEN = 160;
200
- const PAIN_CANDIDATE_FINGERPRINT_TAIL_LEN = 80;
201
319
 
320
+ /* eslint-disable @typescript-eslint/max-params -- Reason: Function requires all parameters for unique task ID generation */
202
321
  export function createEvolutionTaskId(
203
322
  source: string,
204
323
  score: number,
@@ -214,48 +333,7 @@ export function createEvolutionTaskId(
214
333
  .substring(0, 8);
215
334
  }
216
335
 
217
- function normalizePainCandidateText(text: string): string {
218
- return text.replace(/\s+/g, ' ').trim();
219
- }
220
-
221
- export function shouldTrackPainCandidate(text: string): boolean {
222
- const normalized = normalizePainCandidateText(text);
223
- if (!normalized) return false;
224
- if (normalized === 'NO_REPLY') return false;
225
-
226
- // Skip empathy observer payloads: they are classifier telemetry, not user/system pain patterns.
227
- if (
228
- normalized.startsWith('{')
229
- && normalized.endsWith('}')
230
- && normalized.includes('"damageDetected"')
231
- && normalized.includes('"severity"')
232
- && normalized.includes('"confidence"')
233
- ) {
234
- return false;
235
- }
236
-
237
- return true;
238
- }
239
-
240
- export function createPainCandidateFingerprint(text: string): string {
241
- const normalized = normalizePainCandidateText(text);
242
- const head = normalized.substring(0, PAIN_CANDIDATE_FINGERPRINT_HEAD_LEN);
243
- const tail = normalized.slice(-PAIN_CANDIDATE_FINGERPRINT_TAIL_LEN);
244
-
245
- return createHash('md5')
246
- .update(`${normalized.length}:${head}:${tail}`)
247
- .digest('hex')
248
- .substring(0, 8);
249
- }
250
-
251
- export function summarizePainCandidateSample(text: string): string {
252
- return normalizePainCandidateText(text).substring(0, PAIN_CANDIDATE_SAMPLE_LEN);
253
- }
254
-
255
- function isPendingPainCandidate(status: string | undefined): boolean {
256
- return status === undefined || status === 'pending';
257
- }
258
-
336
+ /* eslint-disable no-unused-vars -- Reason: type-level function parameter names in logger union type are documentation */
259
337
  export async function acquireQueueLock(resourcePath: string, logger: PluginLogger | { warn?: (message: string) => void; info?: (message: string) => void } | undefined, lockSuffix: string = EVOLUTION_QUEUE_LOCK_SUFFIX): Promise<() => void> {
260
338
  try {
261
339
  const ctx: LockContext = await acquireLockAsync(resourcePath, {
@@ -264,7 +342,7 @@ export async function acquireQueueLock(resourcePath: string, logger: PluginLogge
264
342
  baseRetryDelayMs: LOCK_RETRY_DELAY_MS,
265
343
  lockStaleMs: LOCK_STALE_MS,
266
344
  });
267
- return () => releaseLock(ctx);
345
+ return () => releaseImportedLock(ctx);
268
346
  } catch (error: unknown) {
269
347
  const warn = logger?.warn;
270
348
  warn?.(`[PD:EvolutionWorker] Failed to acquire lock for ${resourcePath}: ${String(error)}`);
@@ -272,6 +350,7 @@ export async function acquireQueueLock(resourcePath: string, logger: PluginLogge
272
350
  }
273
351
  }
274
352
 
353
+ /* eslint-disable no-unused-vars, @typescript-eslint/max-params -- Reason: type-level function parameter names in logger union type are documentation */
275
354
  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> {
276
355
  try {
277
356
  return await acquireQueueLock(resourcePath, logger, lockSuffix);
@@ -282,10 +361,11 @@ async function requireQueueLock(resourcePath: string, logger: PluginLogger | { w
282
361
 
283
362
  export function extractEvolutionTaskId(task: string): string | null {
284
363
  if (!task) return null;
285
- const match = task.match(/\[ID:\s*([A-Za-z0-9_-]+)\]/);
364
+ const match = /\[ID:\s*([A-Za-z0-9_-]+)\]/.exec(task);
286
365
  return match?.[1] || null;
287
366
  }
288
367
 
368
+ /* eslint-disable @typescript-eslint/max-params -- Reason: Function requires all parameters for duplicate detection */
289
369
  function findRecentDuplicateTask(
290
370
  queue: EvolutionQueueItem[],
291
371
  source: string,
@@ -293,11 +373,13 @@ function findRecentDuplicateTask(
293
373
  now: number,
294
374
  reason?: string
295
375
  ): EvolutionQueueItem | undefined {
376
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define -- Reason: function is defined later in file but used in helper for consistency
296
377
  const key = normalizePainDedupKey(source, preview, reason);
297
378
  return queue.find((task) => {
298
379
  if (task.status === 'completed') return false;
299
380
  const taskTime = new Date(task.enqueued_at || task.timestamp).getTime();
300
381
  if (!Number.isFinite(taskTime) || (now - taskTime) > PAIN_QUEUE_DEDUP_WINDOW_MS) return false;
382
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define -- Reason: function is defined later in file but used in helper for consistency
301
383
  return normalizePainDedupKey(task.source, task.trigger_text_preview || '', task.reason) === key;
302
384
  });
303
385
  }
@@ -315,7 +397,6 @@ export function purgeStaleFailedTasks(
315
397
  queue: EvolutionQueueItem[],
316
398
  logger: PluginLogger,
317
399
  ): { purged: number; remaining: number; byReason: Record<string, number> } {
318
- const beforeCount = queue.length;
319
400
  const cutoff = Date.now() - STALE_FAILED_TASK_MAX_AGE_MS;
320
401
  const byReason: Record<string, number> = {};
321
402
 
@@ -351,6 +432,7 @@ function normalizePainDedupKey(source: string, preview: string, reason?: string)
351
432
  return `${source.trim().toLowerCase()}::${preview.trim().toLowerCase()}::${normalizedReason}`;
352
433
  }
353
434
 
435
+ /* eslint-disable @typescript-eslint/max-params -- Reason: Function requires all parameters for duplicate detection */
354
436
  export function hasRecentDuplicateTask(queue: EvolutionQueueItem[], source: string, preview: string, now: number, reason?: string): boolean {
355
437
  return !!findRecentDuplicateTask(queue, source, preview, now, reason);
356
438
  }
@@ -477,6 +559,7 @@ interface ParsedPainValues {
477
559
  traceId: string; sessionId: string; agentId: string;
478
560
  }
479
561
 
562
+ /* eslint-disable @typescript-eslint/max-params -- Reason: Function requires all parameters for task enqueue */
480
563
  async function doEnqueuePainTask(
481
564
  wctx: WorkspaceContext, logger: PluginLogger, painFlagPath: string,
482
565
  result: WorkerStatusReport['pain_flag'], v: ParsedPainValues,
@@ -496,7 +579,7 @@ async function doEnqueuePainTask(
496
579
  try {
497
580
  let queue: EvolutionQueueItem[] = [];
498
581
  if (fs.existsSync(queuePath)) {
499
- try { queue = JSON.parse(fs.readFileSync(queuePath, 'utf8')); } catch {}
582
+ try { queue = JSON.parse(fs.readFileSync(queuePath, 'utf8')); } catch { /* corrupted queue, treat as empty — safe fallback */ }
500
583
  }
501
584
  const now = Date.now();
502
585
  const dup = findRecentDuplicateTask(queue, v.source, v.preview, now, v.reason);
@@ -561,11 +644,25 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
561
644
  // Try JSON format first (pain skill structured output)
562
645
  // The file may have 'status: queued' and 'task_id: xxx' appended after the JSON object.
563
646
  // Extract just the JSON portion by finding the last '}' and parsing up to that point.
647
+ let parsedAsJson = false;
564
648
  try {
565
649
  const jsonEndIdx = rawPain.lastIndexOf('}');
566
650
  const jsonPortion = jsonEndIdx >= 0 ? rawPain.slice(0, jsonEndIdx + 1) : rawPain;
567
651
  const jsonPain = JSON.parse(jsonPortion);
568
- if (typeof jsonPain === 'object' && jsonPain !== null && jsonPain.pain_score !== undefined) {
652
+
653
+ // Detect if this is a pain flag JSON object: has any of the known pain flag fields
654
+ const isPainJson = typeof jsonPain === 'object' && jsonPain !== null && (
655
+ jsonPain.pain_score !== undefined ||
656
+ jsonPain.score !== undefined ||
657
+ jsonPain.source !== undefined ||
658
+ jsonPain.reason !== undefined ||
659
+ jsonPain.session_id !== undefined ||
660
+ jsonPain.agent_id !== undefined
661
+ );
662
+
663
+ if (isPainJson) {
664
+ parsedAsJson = true;
665
+ // Score resolution: pain_score > score > default 50
569
666
  const jsonScore = typeof jsonPain.pain_score === 'number' ? jsonPain.pain_score :
570
667
  typeof jsonPain.score === 'number' ? jsonPain.score : 50;
571
668
  const jsonSource = jsonPain.source || 'human';
@@ -593,6 +690,14 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
593
690
  }
594
691
  } catch { /* Not JSON — fall through to KV/Markdown parsing */ }
595
692
 
693
+ // If we successfully parsed JSON but it didn't match pain flag fields,
694
+ // don't fall through to KV parsing — it's not a valid pain flag
695
+ if (parsedAsJson) {
696
+ if (logger) logger.warn('[PD:EvolutionWorker] Pain flag parsed as JSON but missing all expected fields — ignoring');
697
+ result.skipped_reason = 'invalid_json_format';
698
+ return result;
699
+ }
700
+
596
701
  const lines = rawPain.split('\n');
597
702
 
598
703
  let score = 0;
@@ -605,6 +710,7 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
605
710
  let agentId = '';
606
711
 
607
712
  for (const line of lines) {
713
+ // KV format: "key: value"
608
714
  if (line.startsWith('score:')) score = parseInt(line.split(':', 2)[1].trim(), 10) || 0;
609
715
  if (line.startsWith('source:')) source = line.split(':', 2)[1].trim();
610
716
  if (line.startsWith('reason:')) reason = line.slice('reason:'.length).trim();
@@ -614,12 +720,25 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
614
720
  if (line.startsWith('session_id:')) sessionId = line.slice('session_id:'.length).trim();
615
721
  if (line.startsWith('agent_id:')) agentId = line.slice('agent_id:'.length).trim();
616
722
 
723
+ // Key=Value fallback format: "key=value" (pain skill manual output)
724
+ // Handles both uppercase (Source=X) and lowercase (source=x) variants
725
+ if (line.startsWith('Source=') || line.startsWith('source=')) source = line.includes('Source=') ? line.slice('Source='.length).trim() : line.slice('source='.length).trim();
726
+ if (line.startsWith('Reason=') || line.startsWith('reason=')) reason = line.includes('Reason=') ? line.slice('Reason='.length).trim() : line.slice('reason='.length).trim();
727
+ if (line.startsWith('Score=') || line.startsWith('score=')) {
728
+ const scoreStr = line.includes('Score=') ? line.slice('Score='.length).trim() : line.slice('score='.length).trim();
729
+ score = parseInt(scoreStr, 10) || 0;
730
+ }
731
+ if (line.startsWith('Time=') || line.startsWith('time=')) {
732
+ const timeStr = line.includes('Time=') ? line.slice('Time='.length).trim() : line.slice('time='.length).trim();
733
+ preview = `Human intervention at ${timeStr}`;
734
+ }
735
+
617
736
  // Markdown format support (pain skill writes **Source**: xxx format)
618
- const mdSource = line.match(/\*\*Source\*\*:\s*(.+)/);
737
+ const mdSource = /\*\*Source\*\*:\s*(.+)/.exec(line);
619
738
  if (mdSource) source = mdSource[1].trim();
620
- const mdReason = line.match(/\*\*Reason\*\*:\s*(.+)/);
739
+ const mdReason = /\*\*Reason\*\*:\s*(.+)/.exec(line);
621
740
  if (mdReason) reason = mdReason[1].trim();
622
- const mdTime = line.match(/\*\*Time\*\*:\s*(.+)/);
741
+ const mdTime = /\*\*Time\*\*:\s*(.+)/.exec(line);
623
742
  if (mdTime) preview = `Human intervention at ${mdTime[1].trim()}`;
624
743
  }
625
744
 
@@ -651,6 +770,7 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
651
770
  return result;
652
771
  }
653
772
 
773
+ /* eslint-disable @typescript-eslint/max-params -- Reason: Function requires all parameters for queue processing */
654
774
  async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogger, eventLog: EventLog, api?: OpenClawPluginApi) {
655
775
  const queuePath = wctx.resolve('EVOLUTION_QUEUE');
656
776
  if (!fs.existsSync(queuePath)) {
@@ -688,13 +808,14 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
688
808
 
689
809
  let queueChanged = rawQueue.some(isLegacyQueueItem);
690
810
 
691
- const config = wctx.config;
811
+ const {config} = wctx;
692
812
  const timeout = config.get('intervals.task_timeout_ms') || (60 * 60 * 1000); // Default 1 hour
693
813
 
694
814
  // V2: Recover stuck in_progress sleep_reflection tasks.
695
815
  // If the worker crashes or the result write-back fails after Phase 1 claimed
696
816
  // the task, it stays in_progress indefinitely. Detect via timeout and mark
697
817
  // as failed so a fresh task can be enqueued on the next idle cycle.
818
+ // #214: Also expire the underlying nocturnal workflow to prevent resource leaks.
698
819
  for (const task of queue.filter(t => t.status === 'in_progress' && t.taskKind === 'sleep_reflection')) {
699
820
  const startedAt = new Date(task.started_at || task.timestamp);
700
821
  const age = Date.now() - startedAt.getTime();
@@ -702,16 +823,68 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
702
823
  task.status = 'failed';
703
824
  task.completed_at = new Date().toISOString();
704
825
  task.resolution = 'failed_max_retries';
705
- task.lastError = `sleep_reflection timed out after ${Math.round(timeout / 60000)} minutes`;
706
826
  task.retryCount = (task.retryCount ?? 0) + 1;
707
827
  queueChanged = true;
708
- logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${task.id} timed out after ${Math.round(age / 60000)} minutes, marking as failed`);
828
+
829
+ // #219: Fetch real failure reason from workflow events for better diagnostics
830
+ let detailedError = `sleep_reflection timed out after ${Math.round(timeout / 60000)} minutes`;
831
+ if (task.resultRef && !task.resultRef.startsWith('trinity-draft')) {
832
+ try {
833
+ const wfStore = new WorkflowStore({ workspaceDir: wctx.workspaceDir });
834
+ const events = wfStore.getEvents(task.resultRef);
835
+ // Find the most recent failure event
836
+ const failureEvent = events.filter(e =>
837
+ e.event_type.includes('failed') || e.event_type.includes('error')
838
+ ).pop();
839
+ if (failureEvent) {
840
+ const payload = failureEvent.payload_json ? JSON.parse(failureEvent.payload_json) : {};
841
+ detailedError = `sleep_reflection failed: ${failureEvent.reason}`;
842
+ if (payload.skipReason) {
843
+ detailedError += ` (skipReason: ${payload.skipReason})`;
844
+ }
845
+ if (payload.failures && payload.failures.length > 0) {
846
+ detailedError += ` | failures: ${payload.failures.slice(0, 3).join(', ')}`;
847
+ }
848
+ }
849
+ } catch (fetchErr) {
850
+ logger?.debug?.(`[PD:EvolutionWorker] Could not fetch workflow events for ${task.resultRef}: ${String(fetchErr)}`);
851
+ }
852
+ }
853
+ task.lastError = detailedError;
854
+
855
+ logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${task.id} timed out after ${Math.round(age / 60000)} minutes, marking as failed. Reason: ${detailedError}`);
709
856
  evoLogger.logCompleted({
710
857
  traceId: task.traceId || task.id,
711
858
  taskId: task.id,
712
859
  resolution: 'manual',
713
860
  durationMs: age,
714
861
  });
862
+
863
+ // #214: Expire the underlying nocturnal workflow to prevent resource leak.
864
+ // The task's resultRef holds the workflowId if one was started.
865
+ if (task.resultRef && !task.resultRef.startsWith('trinity-draft')) {
866
+ try {
867
+ const nocturnalMgr = new NocturnalWorkflowManager({
868
+ workspaceDir: wctx.workspaceDir,
869
+ stateDir: wctx.stateDir,
870
+ logger: api?.logger || logger,
871
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Reason: api is guaranteed non-null in this recovery path where runtimeAdapter is required
872
+ runtimeAdapter: new OpenClawTrinityRuntimeAdapter(api!),
873
+ });
874
+ try {
875
+ // Force-expire this specific workflow regardless of TTL
876
+ nocturnalMgr.expireWorkflow(
877
+ task.resultRef,
878
+ `Sleep reflection task ${task.id} timed out after ${Math.round(age / 60000)} min`,
879
+ );
880
+ logger?.info?.(`[PD:EvolutionWorker] Expired nocturnal workflow ${task.resultRef} for timed-out sleep task ${task.id}`);
881
+ } finally {
882
+ nocturnalMgr.dispose();
883
+ }
884
+ } catch (expireErr) {
885
+ logger?.warn?.(`[PD:EvolutionWorker] Could not expire nocturnal workflow ${task.resultRef}: ${String(expireErr)}`);
886
+ }
887
+ }
715
888
  }
716
889
  }
717
890
 
@@ -744,24 +917,80 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
744
917
  task.completed_at = new Date().toISOString();
745
918
  task.resolution = 'marker_detected';
746
919
  } else {
747
- logger.info(`[PD:EvolutionWorker] Creating principle from report for task ${task.id}`);
748
- const principleId = wctx.evolutionReducer.createPrincipleFromDiagnosis({
749
- painId: task.id,
750
- painType: task.source === 'Human Intervention' ? 'user_frustration' : 'tool_failure',
751
- triggerPattern: principle.trigger_pattern,
752
- action: principle.action,
753
- source: task.source || 'heartbeat_diagnostician',
754
- evaluability: principle.evaluability || 'manual_only',
755
- abstractedPrinciple: principle.abstracted_principle,
756
- });
757
- if (principleId) {
758
- logger.info(`[PD:EvolutionWorker] Created principle ${principleId} from marker fallback for task ${task.id}`);
920
+ // ── Server-side dedup guard (defense against LLM ignoring duplicate check) ──
921
+ const existingPrinciples = wctx.evolutionReducer.getActivePrinciples();
922
+ let serverDuplicate: string | null = null;
923
+ if (existingPrinciples.length > 0) {
924
+ const newTrigger = (principle.trigger_pattern || '').toLowerCase();
925
+ const newAbstracted = (principle.abstracted_principle || '').toLowerCase();
926
+ for (const ep of existingPrinciples) {
927
+ const epTrigger = (ep.trigger || '').toLowerCase();
928
+ const epAbstracted = (ep.abstractedPrinciple || '').toLowerCase();
929
+ const epText = (ep.text || '').toLowerCase();
930
+
931
+ // Check 1: abstracted principle overlap (>70% keyword match)
932
+ const newKeywords = newAbstracted.split(/\s+/).filter((w: string) => w.length > 3);
933
+ const epKeywords = epAbstracted.split(/\s+/).filter((w: string) => w.length > 3);
934
+ if (newKeywords.length > 0 && epKeywords.length > 0) {
935
+ const overlap = newKeywords.filter((k: string) => epKeywords.includes(k)).length;
936
+ const overlapRatio = overlap / Math.max(newKeywords.length, epKeywords.length);
937
+ if (overlapRatio > 0.7) {
938
+ serverDuplicate = ep.id;
939
+ break;
940
+ }
941
+ }
942
+
943
+ // Check 2: trigger pattern contains same key terms
944
+ if (newTrigger.length > 10 && epTrigger.length > 10) {
945
+ const sharedTerms = newTrigger.split(/[\s|\\.+*?()[\]{}^$-]+/).filter((t: string) => t.length > 3);
946
+ if (sharedTerms.length > 0 && sharedTerms.every((t: string) => epTrigger.includes(t))) {
947
+ serverDuplicate = ep.id;
948
+ break;
949
+ }
950
+ }
951
+
952
+ // Check 3: text overlap (LLM often reuses text from existing principle)
953
+ if (epText.length > 20 && newTrigger.length > 20) {
954
+ const sharedPhrases = epText.split(/\s+/).filter(w => w.length > 5);
955
+ const matchCount = sharedPhrases.filter(w => newTrigger.includes(w)).length;
956
+ if (matchCount >= 3) {
957
+ serverDuplicate = ep.id;
958
+ break;
959
+ }
960
+ }
961
+ }
962
+ }
963
+
964
+ if (serverDuplicate) {
965
+ logger.info(`[PD:EvolutionWorker] Server-side dedup: new principle overlaps with existing ${serverDuplicate} — skipping creation for task ${task.id}`);
966
+ task.status = 'completed';
967
+ task.completed_at = new Date().toISOString();
968
+ task.resolution = 'marker_detected';
759
969
  } else {
760
- logger.warn(`[PD:EvolutionWorker] createPrincipleFromDiagnosis returned null for task ${task.id} (may be duplicate or blacklisted)`);
970
+ logger.info(`[PD:EvolutionWorker] Creating principle from report for task ${task.id}`);
971
+ const principleId = wctx.evolutionReducer.createPrincipleFromDiagnosis({
972
+ painId: task.id,
973
+ painType: task.source === 'Human Intervention' ? 'user_frustration' : 'tool_failure',
974
+ triggerPattern: principle.trigger_pattern,
975
+ action: principle.action,
976
+ source: task.source || 'heartbeat_diagnostician',
977
+ // #212: Default to weak_heuristic so principles are auto-evaluable
978
+ // without requiring full detectorMetadata from the diagnostician.
979
+ evaluability: principle.evaluability || 'weak_heuristic',
980
+ // Review fix: Accept both snake_case and camelCase from LLM output
981
+ detectorMetadata: principle.detector_metadata || principle.detectorMetadata,
982
+ abstractedPrinciple: principle.abstracted_principle,
983
+ coreAxiomId: principle.core_axiom_id || principle.coreAxiomId,
984
+ });
985
+ if (principleId) {
986
+ logger.info(`[PD:EvolutionWorker] Created principle ${principleId} from marker fallback for task ${task.id}`);
987
+ } else {
988
+ logger.warn(`[PD:EvolutionWorker] createPrincipleFromDiagnosis returned null for task ${task.id} (may be duplicate or blacklisted)`);
989
+ }
990
+ task.status = 'completed';
991
+ task.completed_at = new Date().toISOString();
992
+ task.resolution = 'marker_detected';
761
993
  }
762
- task.status = 'completed';
763
- task.completed_at = new Date().toISOString();
764
- task.resolution = 'marker_detected';
765
994
  }
766
995
  } else {
767
996
  logger.warn(`[PD:EvolutionWorker] Diagnostician report for task ${task.id} missing principle fields — diagnostician did not produce a principle`);
@@ -778,7 +1007,16 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
778
1007
  task.resolution = 'marker_detected';
779
1008
  try {
780
1009
  fs.unlinkSync(completeMarker);
781
- } catch {}
1010
+ } catch { /* marker may have been deleted already, not critical */ }
1011
+
1012
+ // #190: Clean up diagnostician report file after processing
1013
+ try {
1014
+ const cleanupReportPath = path.join(wctx.stateDir, `.diagnostician_report_${task.id}.json`);
1015
+ if (fs.existsSync(cleanupReportPath)) fs.unlinkSync(cleanupReportPath);
1016
+ } catch { /* report may not exist, not critical */ }
1017
+
1018
+ // FIX (#187): Remove the task from the diagnostician task store
1019
+ await completeDiagnosticianTask(wctx.stateDir, task.id);
782
1020
 
783
1021
  // Log to EvolutionLogger
784
1022
  const durationMs = task.started_at
@@ -812,14 +1050,14 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
812
1050
  if (age > timeout) {
813
1051
  const timeoutMinutes = Math.round(timeout / 60000);
814
1052
 
815
- const completeMarker = path.join(wctx.stateDir, `.evolution_complete_${task.id}`);
816
- const reportPath = path.join(wctx.stateDir, `.diagnostician_report_${task.id}.json`);
1053
+ const timeoutCompleteMarker = path.join(wctx.stateDir, `.evolution_complete_${task.id}`);
1054
+ const timeoutReportPath = path.join(wctx.stateDir, `.diagnostician_report_${task.id}.json`);
817
1055
 
818
- if (fs.existsSync(completeMarker) && fs.existsSync(reportPath)) {
1056
+ if (fs.existsSync(timeoutCompleteMarker) && fs.existsSync(timeoutReportPath)) {
819
1057
  if (logger) logger.info(`[PD:EvolutionWorker] Task ${task.id} timed out but marker found — creating principle anyway`);
820
1058
  let principleCreated = false;
821
1059
  try {
822
- const reportData = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
1060
+ const reportData = JSON.parse(fs.readFileSync(timeoutReportPath, 'utf8'));
823
1061
  const principle = reportData?.principle
824
1062
  || reportData?.phases?.principle_extraction?.principle
825
1063
  || reportData?.diagnosis_report?.principle
@@ -834,9 +1072,13 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
834
1072
  triggerPattern: principle.trigger_pattern,
835
1073
  action: principle.action,
836
1074
  source: task.source || 'heartbeat_diagnostician',
837
- evaluability: principle.evaluability || 'manual_only',
1075
+ // #212: Default to weak_heuristic so principles are auto-evaluable.
1076
+ evaluability: principle.evaluability || 'weak_heuristic',
1077
+ // Review fix: Accept both snake_case and camelCase from LLM output
1078
+ detectorMetadata: principle.detector_metadata || principle.detectorMetadata,
838
1079
  abstractedPrinciple: principle.abstracted_principle,
839
- });
1080
+ coreAxiomId: principle.core_axiom_id || principle.coreAxiomId,
1081
+ });
840
1082
  if (principleId) {
841
1083
  logger.info(`[PD:EvolutionWorker] Created principle ${principleId} from late marker for task ${task.id}`);
842
1084
  principleCreated = true;
@@ -846,10 +1088,20 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
846
1088
  } catch (err) {
847
1089
  logger.warn(`[PD:EvolutionWorker] Failed to parse late diagnostician report for task ${task.id}: ${String(err)}`);
848
1090
  }
849
- try { fs.unlinkSync(completeMarker); } catch {}
1091
+ try { fs.unlinkSync(completeMarker); } catch { /* marker may not exist, not critical */ }
1092
+ // #190: Clean up diagnostician report file
1093
+ try {
1094
+ const lateReportPath = path.join(wctx.stateDir, `.diagnostician_report_${task.id}.json`);
1095
+ if (fs.existsSync(lateReportPath)) fs.unlinkSync(lateReportPath);
1096
+ } catch { /* report may not exist, not critical */ }
850
1097
  task.resolution = principleCreated ? 'late_marker_principle_created' : 'late_marker_no_principle';
851
1098
  } else {
852
1099
  if (logger) logger.info(`[PD:EvolutionWorker] Task ${task.id} auto-completed after ${timeoutMinutes} minute timeout`);
1100
+ // #190: Clean up diagnostician report file even on timeout (may have been written late)
1101
+ try {
1102
+ const autoTimeoutReportPath = path.join(wctx.stateDir, `.diagnostician_report_${task.id}.json`);
1103
+ if (fs.existsSync(autoTimeoutReportPath)) fs.unlinkSync(autoTimeoutReportPath);
1104
+ } catch { /* report may not exist, not critical */ }
853
1105
  task.resolution = 'auto_completed_timeout';
854
1106
  }
855
1107
 
@@ -892,19 +1144,22 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
892
1144
  if (pendingTasks.length > 0) {
893
1145
  // V2: Also sort by priority within same score
894
1146
  const priorityWeight = { high: 3, medium: 2, low: 1 };
895
- const highestScoreTask = pendingTasks.sort((a, b) => {
1147
+ const [highestScoreTask] = pendingTasks.sort((a, b) => {
896
1148
  const scoreDiff = b.score - a.score;
897
1149
  if (scoreDiff !== 0) return scoreDiff;
898
1150
  return (priorityWeight[b.priority] || 2) - (priorityWeight[a.priority] || 2);
899
- })[0];
1151
+ });
900
1152
  const nowIso = new Date().toISOString();
901
1153
 
902
1154
  const taskDescription = `Diagnose systemic pain [ID: ${highestScoreTask.id}]. Source: ${highestScoreTask.source}. Reason: ${highestScoreTask.reason}. ` +
903
1155
  `Trigger text: "${highestScoreTask.trigger_text_preview || 'N/A'}"`;
904
1156
 
905
- // Prepare HEARTBEAT content first
906
- // Use shared diagnostician protocol (consistent with pd-diagnostician skill)
907
- const heartbeatPath = wctx.resolve('HEARTBEAT');
1157
+ // Prepare diagnostician task content
1158
+ // FIX (#187): Write diagnostician tasks to .state/diagnostician_tasks.json
1159
+ // instead of HEARTBEAT.md. HEARTBEAT.md is a shared file that gets overwritten
1160
+ // by the main session heartbeat, causing a race condition where the diagnostician
1161
+ // task prompt is lost. The task store is in .state/ which is not modified by
1162
+ // the main session.
908
1163
  const markerFilePath = path.join(wctx.stateDir, `.evolution_complete_${highestScoreTask.id}`);
909
1164
  const reportFilePath = path.join(wctx.stateDir, `.diagnostician_report_${highestScoreTask.id}.json`);
910
1165
 
@@ -935,7 +1190,11 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
935
1190
  existingPrinciplesRef += `\n\n**Suggested Rules from Existing Principles**:\n${ruleLines.join('\n')}`;
936
1191
  }
937
1192
  }
938
- } catch {}
1193
+ } catch (err) {
1194
+ // #184: Log warning instead of silently swallowing — diagnostician needs
1195
+ // existing principles context for duplicate detection.
1196
+ logger?.warn?.(`[PD:EvolutionWorker] Failed to load active principles for duplicate detection: ${String(err)}`);
1197
+ }
939
1198
 
940
1199
  // ── Context Enrichment (CTX-01): Dual-path strategy ──
941
1200
  // P1: OpenClaw built-in tools (sessions_history) - safe, visibility-limited
@@ -955,8 +1214,8 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
955
1214
 
956
1215
  // Also try to extract failed tool context if this is a tool failure
957
1216
  if (highestScoreTask.source === 'tool_failure') {
958
- const toolMatch = highestScoreTask.reason?.match(/Tool ([\w-]+) failed/);
959
- const fileMatch = highestScoreTask.reason?.match(/on (.+?)(?=\s*Error:|$)/i);
1217
+ const toolMatch = /Tool ([\w-]+) failed/.exec(highestScoreTask.reason);
1218
+ const fileMatch = /on (.+?)(?=\s*Error:|$)/i.exec(highestScoreTask.reason);
960
1219
  if (toolMatch) {
961
1220
  const toolContext = await extractFailedToolContext(
962
1221
  highestScoreTask.session_id,
@@ -997,6 +1256,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
997
1256
  `3. **read_file / search_file_content** — Search codebase`,
998
1257
  ``,
999
1258
  `**P1 SOP**: sessions_history(sessionKey="agent:${highestScoreTask.agent_id || 'main'}:run:${highestScoreTask.session_id || 'N/A'}", limit=30)`,
1259
+ highestScoreTask.session_id === 'N/A' || !highestScoreTask.session_id ? `\n\n**⚠️ IMPORTANT**: session_id is N/A — P1 sessions_history tool CANNOT be used. You MUST rely on P2 pre-extracted context below, the pain reason, and your own reasoning. Do NOT hallucinate session details.` : '',
1000
1260
  ``,
1001
1261
  `## Pre-extracted Context (P2 - JSONL Fallback)`,
1002
1262
  `If OpenClaw tools cannot access the session (visibility limits),`,
@@ -1018,17 +1278,19 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1018
1278
  ` The JSON structure MUST match the output format defined in the pd-diagnostician skill.`,
1019
1279
  `2. Mark the task complete by creating a marker file: ${markerFilePath}`,
1020
1280
  ` The marker file should contain: "diagnostic_completed: <timestamp>\\noutcome: <summary>"`,
1021
- `3. Replace this HEARTBEAT.md content with "HEARTBEAT_OK"`,
1281
+ `3. After writing both files, reply with "DIAGNOSTICIAN_DONE: ${highestScoreTask.id}"`,
1022
1282
  existingPrinciplesRef,
1023
1283
  ].join('\n');
1024
1284
 
1025
- // Try to write HEARTBEAT.md FIRST
1026
- // Only mark task as in_progress after successful write to avoid stuck tasks
1285
+ // FIX (#187): Write to diagnostician_tasks.json instead of HEARTBEAT.md
1286
+ // HEARTBEAT.md is a shared file that gets overwritten by the main session
1287
+ // heartbeat, causing a race condition. The task store is in .state/ and is
1288
+ // not modified by the main session.
1027
1289
  try {
1028
- fs.writeFileSync(heartbeatPath, heartbeatContent, 'utf8');
1029
- if (logger) logger.info(`[PD:EvolutionWorker] Wrote diagnostician task to HEARTBEAT.md for task ${highestScoreTask.id}`);
1290
+ await addDiagnosticianTask(wctx.stateDir, highestScoreTask.id, heartbeatContent);
1291
+ if (logger) logger.info(`[PD:EvolutionWorker] Wrote diagnostician task to diagnostician_tasks.json for task ${highestScoreTask.id}`);
1030
1292
 
1031
- // HEARTBEAT write succeeded, now mark task as in_progress
1293
+ // Task store write succeeded, now mark task as in_progress
1032
1294
  highestScoreTask.task = taskDescription;
1033
1295
  highestScoreTask.status = 'in_progress';
1034
1296
  highestScoreTask.started_at = nowIso;
@@ -1058,9 +1320,9 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1058
1320
  });
1059
1321
  }
1060
1322
  } catch (heartbeatErr) {
1061
- // HEARTBEAT write failed - keep task as pending for next cycle retry
1062
- if (logger) logger.error(`[PD:EvolutionWorker] Failed to write HEARTBEAT.md for task ${highestScoreTask.id}: ${String(heartbeatErr)}. Task will remain pending for next cycle.`);
1063
- SystemLogger.log(wctx.workspaceDir, 'HEARTBEAT_WRITE_FAILED', `Task ${highestScoreTask.id} HEARTBEAT write failed: ${String(heartbeatErr)}`);
1323
+ // Diagnostician task store write failed - keep task as pending for next cycle retry
1324
+ if (logger) logger.error(`[PD:EvolutionWorker] Failed to write diagnostician task for task ${highestScoreTask.id}: ${String(heartbeatErr)}. Task will remain pending for next cycle.`);
1325
+ SystemLogger.log(wctx.workspaceDir, 'DIAGNOSTICIAN_TASK_WRITE_FAILED', `Task ${highestScoreTask.id} diagnostician task write failed: ${String(heartbeatErr)}`);
1064
1326
  }
1065
1327
  }
1066
1328
 
@@ -1069,14 +1331,24 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1069
1331
  // then re-acquire the lock to write results. This prevents the long-running
1070
1332
  // nocturnal reflection from blocking all other queue consumers.
1071
1333
  // Safe to return early here because pain_diagnosis was already handled above.
1072
- const sleepReflectionTasks = queue.filter(t => t.status === 'pending' && t.taskKind === 'sleep_reflection');
1334
+
1335
+ // FIX: Also poll in_progress tasks that were started in a previous cycle.
1336
+ // Previously only 'pending' tasks were filtered, so an in_progress task from
1337
+ // a previous heartbeat cycle would never be re-polled until the 1-hour
1338
+ // stuck task recovery kicked in.
1339
+ const pendingSleepTasks = queue.filter(t => t.status === 'pending' && t.taskKind === 'sleep_reflection');
1340
+ const pollingSleepTasks = queue.filter(t =>
1341
+ t.status === 'in_progress' && t.taskKind === 'sleep_reflection' && t.resultRef && !t.resultRef.startsWith('trinity-draft')
1342
+ );
1343
+ const sleepReflectionTasks = [...pendingSleepTasks, ...pollingSleepTasks];
1073
1344
  if (sleepReflectionTasks.length > 0) {
1074
- // --- Phase 1: Claim tasks (inside lock) ---
1075
- for (const sleepTask of sleepReflectionTasks) {
1345
+ // --- Phase 1: Claim only pending tasks (inside lock) ---
1346
+ // in_progress tasks from previous cycles are already claimed, don't re-claim them
1347
+ for (const sleepTask of pendingSleepTasks) {
1076
1348
  sleepTask.status = 'in_progress';
1077
1349
  sleepTask.started_at = new Date().toISOString();
1078
1350
  }
1079
- queueChanged = true;
1351
+ queueChanged = queueChanged || pendingSleepTasks.length > 0;
1080
1352
 
1081
1353
  // Write claimed state (includes any pain changes from above) and release lock
1082
1354
  if (queueChanged) {
@@ -1085,10 +1357,19 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1085
1357
  releaseLock();
1086
1358
  for (const sleepTask of sleepReflectionTasks) {
1087
1359
  try {
1088
- logger?.info?.(`[PD:EvolutionWorker] Processing sleep_reflection task ${sleepTask.id}`);
1360
+ // FIX: For in_progress tasks from a previous cycle, just poll the workflow.
1361
+ // Don't start a new workflow — that was already done when the task was first claimed.
1362
+ const isPollingTask = !!sleepTask.resultRef && !sleepTask.resultRef.startsWith('trinity-draft');
1363
+
1364
+ if (isPollingTask) {
1365
+ logger?.debug?.(`[PD:EvolutionWorker] Polling existing sleep_reflection task ${sleepTask.id} (workflowId: ${sleepTask.resultRef})`);
1366
+ } else {
1367
+ logger?.info?.(`[PD:EvolutionWorker] Processing sleep_reflection task ${sleepTask.id}`);
1368
+ }
1089
1369
 
1090
1370
  // NOC-14: Use NocturnalWorkflowManager for sleep_reflection tasks
1091
1371
  // Lazy-create manager (needs runtimeAdapter from api)
1372
+ // eslint-disable-next-line @typescript-eslint/init-declarations -- assigned in if block, else block continues
1092
1373
  let nocturnalManager: NocturnalWorkflowManager | undefined;
1093
1374
  if (api) {
1094
1375
  nocturnalManager = new NocturnalWorkflowManager({
@@ -1108,57 +1389,142 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1108
1389
  continue;
1109
1390
  }
1110
1391
 
1111
- // Start workflow via NocturnalWorkflowManager instead of direct executeNocturnalReflectionAsync
1112
- // Pass taskId in metadata for correlation
1113
- const workflowHandle = await nocturnalManager.startWorkflow(nocturnalWorkflowSpec, {
1114
- parentSessionId: `sleep_reflection:${sleepTask.id}`,
1115
- workspaceDir: wctx.workspaceDir,
1116
- taskInput: {},
1117
- metadata: {
1118
- snapshot: sleepTask.recentPainContext ? {
1392
+ // eslint-disable-next-line @typescript-eslint/init-declarations -- assigned in both if/else branches
1393
+ let workflowId: string;
1394
+
1395
+ if (isPollingTask) {
1396
+ // Poll-only path: skip workflow start, use existing workflowId
1397
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Reason: isPollingTask flag is only set when resultRef is expected to be present
1398
+ workflowId = sleepTask.resultRef!;
1399
+ } else {
1400
+ // Start workflow via NocturnalWorkflowManager instead of direct executeNocturnalReflectionAsync
1401
+ // Pass taskId in metadata for correlation
1402
+
1403
+ // #181: Build a proper snapshot from trajectory.db instead of hardcoded zeros
1404
+ // eslint-disable-next-line @typescript-eslint/init-declarations -- undefined is valid zero value, assigned conditionally in if/fallback blocks
1405
+ let snapshotData: Record<string, unknown> | undefined;
1406
+ if (sleepTask.recentPainContext) {
1407
+ try {
1408
+ const extractor = createNocturnalTrajectoryExtractor(wctx.workspaceDir);
1409
+ const fullSnapshot = extractor.getNocturnalSessionSnapshot(sleepTask.id);
1410
+ if (fullSnapshot) {
1411
+ snapshotData = {
1412
+ sessionId: fullSnapshot.sessionId,
1413
+ sessionStart: fullSnapshot.startedAt,
1414
+ stats: fullSnapshot.stats,
1415
+ recentPain: fullSnapshot.painEvents.slice(-5), // last 5
1416
+ };
1417
+ }
1418
+ } catch (snapErr) {
1419
+ logger?.warn?.(`[PD:EvolutionWorker] Failed to build trajectory snapshot for ${sleepTask.id}: ${String(snapErr)}`);
1420
+ }
1421
+ }
1422
+ // Fallback: use pain context only if trajectory extractor failed
1423
+ if (!snapshotData && sleepTask.recentPainContext) {
1424
+ // #200: Log fallback usage to make data gaps visible
1425
+ logger?.warn?.(`[PD:EvolutionWorker] Using pain-context fallback for ${sleepTask.id}: trajectory stats unavailable (stats will be partial)`);
1426
+ snapshotData = {
1119
1427
  sessionId: sleepTask.id,
1120
1428
  sessionStart: sleepTask.timestamp,
1121
- stats: { totalAssistantTurns: 0, totalToolCalls: 0, failureCount: 0, totalPainEvents: sleepTask.recentPainContext.recentPainCount, totalGateBlocks: 0 },
1429
+ stats: {
1430
+ totalAssistantTurns: 0,
1431
+ totalToolCalls: 0,
1432
+ failureCount: 0,
1433
+ totalPainEvents: sleepTask.recentPainContext.recentPainCount,
1434
+ totalGateBlocks: 0,
1435
+ },
1122
1436
  recentPain: sleepTask.recentPainContext.mostRecent ? [sleepTask.recentPainContext.mostRecent] : [],
1123
- } : undefined,
1124
- principleId: 'default',
1125
- taskId: sleepTask.id, // NOC-14: correlation ID for evolution worker
1126
- },
1127
- });
1437
+ // #200: Mark data source so downstream can handle appropriately
1438
+ _dataSource: 'pain_context_fallback',
1439
+ };
1440
+ }
1441
+
1442
+ const workflowHandle = await nocturnalManager.startWorkflow(nocturnalWorkflowSpec, {
1443
+ parentSessionId: `sleep_reflection:${sleepTask.id}`,
1444
+ workspaceDir: wctx.workspaceDir,
1445
+ taskInput: {},
1446
+ metadata: {
1447
+ snapshot: snapshotData,
1448
+ // #205: Remove hardcoded 'default' - let NocturnalTargetSelector choose
1449
+ // via executeNocturnalReflectionAsync when no principleId is provided
1450
+ taskId: sleepTask.id, // NOC-14: correlation ID for evolution worker
1451
+ // Pass painContext to Selector for principle ranking bias
1452
+ painContext: sleepTask.recentPainContext,
1453
+ },
1454
+ });
1128
1455
 
1129
- // Store workflowId on task for polling on subsequent cycles
1130
- sleepTask.resultRef = workflowHandle.workflowId;
1456
+ // Store workflowId on task for polling on subsequent cycles
1457
+ sleepTask.resultRef = workflowHandle.workflowId;
1458
+ // eslint-disable-next-line @typescript-eslint/prefer-destructuring -- Reason: workflowId is reassignable outer let - destructuring would shadow
1459
+ workflowId = workflowHandle.workflowId;
1460
+ }
1131
1461
 
1132
1462
  // Workflow is running asynchronously. Check if it completed in this cycle
1133
1463
  // by polling getWorkflowDebugSummary.
1134
- const summary = await nocturnalManager.getWorkflowDebugSummary(workflowHandle.workflowId);
1464
+ const summary = await nocturnalManager.getWorkflowDebugSummary(workflowId);
1135
1465
  if (summary) {
1136
1466
  if (summary.state === 'completed') {
1137
1467
  sleepTask.status = 'completed';
1138
1468
  sleepTask.completed_at = new Date().toISOString();
1139
1469
  sleepTask.resolution = 'marker_detected';
1140
- sleepTask.resultRef = summary.metadata?.nocturnalResult ? 'trinity-draft' : workflowHandle.workflowId;
1470
+ sleepTask.resultRef = summary.metadata?.nocturnalResult ? 'trinity-draft' : workflowId;
1141
1471
  logger?.info?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} workflow completed`);
1142
1472
  } else if (summary.state === 'terminal_error') {
1143
- sleepTask.status = 'failed';
1144
- sleepTask.completed_at = new Date().toISOString();
1145
- sleepTask.resolution = 'failed_max_retries';
1473
+ // #208/#209: Classify terminal_error reason before hardcoding to failed.
1474
+ // The async executeNocturnalReflectionAsync catches subagent errors and
1475
+ // records them as terminal_error. Without this check, expected errors
1476
+ // (daemon mode, process isolation) would always become failed_max_retries.
1146
1477
  const lastEvent = summary.recentEvents[summary.recentEvents.length - 1];
1147
- sleepTask.lastError = `Workflow terminal_error: ${lastEvent?.reason ?? 'unknown'}`;
1478
+ const errorReason = lastEvent?.reason ?? 'unknown';
1479
+ // #219: Include payload details for better diagnostics
1480
+ let detailedError = `Workflow terminal_error: ${errorReason}`;
1481
+ try {
1482
+ const payload = lastEvent?.payload ?? {};
1483
+ if (payload.skipReason) {
1484
+ detailedError += ` (skipReason: ${payload.skipReason})`;
1485
+ }
1486
+ if (payload.failures && Array.isArray(payload.failures) && payload.failures.length > 0) {
1487
+ detailedError += ` | failures: ${(payload.failures as string[]).slice(0, 3).join(', ')}`;
1488
+ }
1489
+ } catch { /* ignore parse errors */ }
1490
+ sleepTask.lastError = detailedError;
1148
1491
  sleepTask.retryCount = (sleepTask.retryCount ?? 0) + 1;
1149
- logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} workflow failed: ${sleepTask.lastError}`);
1492
+
1493
+ if (isExpectedSubagentError(errorReason)) {
1494
+ // #202: Expected subagent unavailability — use stub fallback
1495
+ sleepTask.status = 'completed';
1496
+ sleepTask.completed_at = new Date().toISOString();
1497
+ sleepTask.resolution = 'stub_fallback';
1498
+ logger?.info?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} workflow completed with stub fallback (expected subagent error: ${errorReason})`);
1499
+ } else {
1500
+ sleepTask.status = 'failed';
1501
+ sleepTask.completed_at = new Date().toISOString();
1502
+ sleepTask.resolution = 'failed_max_retries';
1503
+ logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} workflow failed: ${sleepTask.lastError}`);
1504
+ }
1150
1505
  } else {
1151
1506
  // Workflow still active, keep task in_progress for next cycle
1152
1507
  logger?.info?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} workflow ${summary.state}, will poll again next cycle`);
1153
1508
  }
1154
1509
  }
1155
1510
  } catch (taskErr) {
1156
- sleepTask.status = 'failed';
1157
- sleepTask.completed_at = new Date().toISOString();
1158
- sleepTask.resolution = 'failed_max_retries';
1159
- sleepTask.lastError = String(taskErr);
1160
- sleepTask.retryCount = (sleepTask.retryCount ?? 0) + 1;
1161
- logger?.error?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} threw: ${taskErr}`);
1511
+ // #202: Handle expected subagent unavailability (e.g., process isolation in daemon mode)
1512
+ // When subagent is unavailable due to gateway running in separate process,
1513
+ // use stub fallback instead of failing the task.
1514
+ if (isExpectedSubagentError(taskErr)) {
1515
+ sleepTask.status = 'completed';
1516
+ sleepTask.completed_at = new Date().toISOString();
1517
+ sleepTask.resolution = 'stub_fallback';
1518
+ sleepTask.lastError = String(taskErr);
1519
+ logger?.info?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} completed with stub fallback (subagent unavailable)`);
1520
+ } else {
1521
+ sleepTask.status = 'failed';
1522
+ sleepTask.completed_at = new Date().toISOString();
1523
+ sleepTask.resolution = 'failed_max_retries';
1524
+ sleepTask.lastError = String(taskErr);
1525
+ sleepTask.retryCount = (sleepTask.retryCount ?? 0) + 1;
1526
+ logger?.error?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} threw: ${taskErr}`);
1527
+ }
1162
1528
  }
1163
1529
  }
1164
1530
 
@@ -1238,7 +1604,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1238
1604
  }
1239
1605
 
1240
1606
  async function processDetectionQueue(wctx: WorkspaceContext, api: OpenClawPluginApi, eventLog: EventLog) {
1241
- const logger = api.logger;
1607
+ const {logger} = api;
1242
1608
  try {
1243
1609
  const funnel = DetectionService.get(wctx.stateDir);
1244
1610
  const queue = funnel.flushQueue();
@@ -1280,8 +1646,7 @@ async function processDetectionQueue(wctx: WorkspaceContext, api: OpenClawPlugin
1280
1646
  continue;
1281
1647
  }
1282
1648
  }
1283
- // No L3 hit - fall through to track as pain candidate
1284
- await trackPainCandidate(text, wctx);
1649
+ // No L3 hit pain candidate tracking removed (D-05)
1285
1650
  }
1286
1651
  }
1287
1652
  } catch (err) {
@@ -1289,108 +1654,10 @@ async function processDetectionQueue(wctx: WorkspaceContext, api: OpenClawPlugin
1289
1654
  }
1290
1655
  }
1291
1656
 
1292
- export async function trackPainCandidate(text: string, wctx: WorkspaceContext) {
1293
- if (!shouldTrackPainCandidate(text)) return;
1294
-
1295
- const candidatePath = wctx.resolve('PAIN_CANDIDATES');
1296
- const releaseLock = await requireQueueLock(candidatePath, console, 'trackPainCandidate', PAIN_CANDIDATES_LOCK_SUFFIX);
1297
-
1298
- try {
1299
- let data: { candidates: Record<string, PainCandidateEntry> } = { candidates: {} };
1300
- if (fs.existsSync(candidatePath)) {
1301
- try {
1302
- data = JSON.parse(fs.readFileSync(candidatePath, 'utf8'));
1303
- } catch (e) {
1304
- // Keep going with empty data if parse fails, but log it
1305
- // eslint-disable-next-line no-console
1306
- console.warn(`[PD:EvolutionWorker] Failed to parse pain candidates: ${String(e)}`);
1307
- }
1308
- }
1309
-
1310
- const fingerprint = createPainCandidateFingerprint(text);
1311
- const now = new Date().toISOString();
1312
- if (!data.candidates[fingerprint]) {
1313
- data.candidates[fingerprint] = { count: 0, status: 'pending', firstSeen: now, lastSeen: now, samples: [] };
1314
- }
1315
-
1316
- const cand = data.candidates[fingerprint];
1317
- cand.status = cand.status || 'pending';
1318
- cand.count++;
1319
- cand.lastSeen = now;
1320
-
1321
- const sample = summarizePainCandidateSample(text);
1322
- if (cand.samples.length < PAIN_CANDIDATE_MAX_SAMPLES && !cand.samples.includes(sample)) {
1323
- cand.samples.push(sample);
1324
- }
1325
-
1326
- fs.writeFileSync(candidatePath, JSON.stringify(data, null, 2), 'utf8');
1327
- } finally {
1328
- releaseLock();
1329
- }
1330
- }
1331
-
1332
- export async function processPromotion(wctx: WorkspaceContext, logger: PluginLogger, eventLog: EventLog) {
1333
- const candidatePath = wctx.resolve('PAIN_CANDIDATES');
1334
- if (!fs.existsSync(candidatePath)) return;
1335
-
1336
- const releaseLock = await requireQueueLock(candidatePath, logger, 'processPromotion', PAIN_CANDIDATES_LOCK_SUFFIX);
1337
-
1338
- try {
1339
- const config = wctx.config;
1340
- const dictionary = wctx.dictionary;
1341
- const data: { candidates: Record<string, PainCandidateEntry> } = JSON.parse(fs.readFileSync(candidatePath, 'utf8'));
1342
- const countThreshold = config.get('thresholds.promotion_count_threshold') || 3;
1343
-
1344
- let promotedCount = 0;
1345
- let changed = false;
1346
-
1347
- for (const [fingerprint, cand] of Object.entries(data.candidates)) {
1348
- if (isPendingPainCandidate(cand.status) && cand.count >= countThreshold) {
1349
- // Normalize undefined status to 'pending'
1350
- if (cand.status !== 'pending') {
1351
- cand.status = 'pending';
1352
- changed = true;
1353
- }
1354
- const commonPhrases = extractCommonSubstring(cand.samples);
1355
-
1356
- if (commonPhrases.length > 0) {
1357
- const phrase = commonPhrases[0];
1358
- const ruleId = `P_PROMOTED_${fingerprint.toUpperCase()}`;
1359
-
1360
- if (hasEquivalentPromotedRule(dictionary, phrase)) {
1361
- cand.status = 'duplicate';
1362
- changed = true;
1363
- logger?.info?.(`[PD:EvolutionWorker] Skipping duplicate promoted rule for candidate ${fingerprint}: ${phrase}`);
1364
- continue;
1365
- }
1366
-
1367
- if (logger) logger.info(`[PD:EvolutionWorker] Promoting candidate ${fingerprint} to formal rule: ${ruleId}`);
1368
- SystemLogger.log(wctx.workspaceDir, 'RULE_PROMOTED', `Candidate ${fingerprint} promoted to rule ${ruleId}`);
1369
-
1370
- dictionary.addRule(ruleId, {
1371
- type: 'exact_match',
1372
- phrases: [phrase],
1373
- severity: config.get('scores.default_confusion') || 35,
1374
- status: 'active'
1375
- });
1376
-
1377
- cand.status = 'promoted';
1378
- promotedCount++;
1379
- changed = true;
1380
- }
1381
- }
1382
- }
1383
-
1384
- if (changed) {
1385
- fs.writeFileSync(candidatePath, JSON.stringify(data, null, 2), 'utf8');
1386
- }
1387
- } catch (err) {
1388
- if (logger) logger.warn(`[PD:EvolutionWorker] Error during rule promotion: ${String(err)}`);
1389
- } finally {
1390
- releaseLock();
1391
- }
1392
- }
1657
+ // PAIN_CANDIDATES system removed (D-05, D-06): trackPainCandidate and processPromotion deleted
1658
+ // Evolution queue is now the single active pain→principle path
1393
1659
 
1660
+ /* eslint-disable no-unused-vars, @typescript-eslint/max-params -- Reason: type-level function parameter names in logger union type and unused workspaceResolve key are documentation/signature */
1394
1661
  export async function registerEvolutionTaskSession(
1395
1662
  workspaceResolve: (key: string) => string,
1396
1663
  taskId: string,
@@ -1403,6 +1670,7 @@ export async function registerEvolutionTaskSession(
1403
1670
  const releaseLock = await requireQueueLock(queuePath, logger, 'registerEvolutionTaskSession');
1404
1671
 
1405
1672
  try {
1673
+ // eslint-disable-next-line @typescript-eslint/init-declarations -- assigned in try, catch has early return
1406
1674
  let rawQueue: RawQueueItem[];
1407
1675
  try {
1408
1676
  rawQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
@@ -1442,12 +1710,14 @@ export async function registerEvolutionTaskSession(
1442
1710
  * Production evidence shows directive stopped updating on 2026-03-22 and is stale.
1443
1711
  */
1444
1712
 
1713
+ /* eslint-disable no-unused-vars -- Reason: interface method parameters are type signatures */
1445
1714
  export interface ExtendedEvolutionWorkerService {
1446
1715
  id: string;
1447
1716
  api: OpenClawPluginApi | null;
1448
1717
  start: (ctx: OpenClawPluginServiceContext) => void | Promise<void>;
1449
1718
  stop?: (ctx: OpenClawPluginServiceContext) => void | Promise<void>;
1450
1719
  }
1720
+ /* eslint-enable no-unused-vars */
1451
1721
 
1452
1722
  interface WorkerStatusReport {
1453
1723
  timestamp: string;
@@ -1462,9 +1732,13 @@ function writeWorkerStatus(stateDir: string, report: WorkerStatusReport): void {
1462
1732
  try {
1463
1733
  const statusPath = path.join(stateDir, 'worker-status.json');
1464
1734
  fs.writeFileSync(statusPath, JSON.stringify(report, null, 2), 'utf8');
1465
- } catch {}
1735
+ } catch (err) {
1736
+ // Non-critical: worker-status.json is for monitoring, not core logic
1737
+ console.warn(`[PD:EvolutionWorker] Failed to write worker-status.json: ${String(err)}`);
1738
+ }
1466
1739
  }
1467
1740
 
1741
+ /* eslint-disable @typescript-eslint/max-params -- Reason: Function requires all parameters for queue processing */
1468
1742
  async function processEvolutionQueueWithResult(
1469
1743
  wctx: WorkspaceContext,
1470
1744
  logger: PluginLogger,
@@ -1480,7 +1754,7 @@ async function processEvolutionQueueWithResult(
1480
1754
  return { queue: queueResult, errors };
1481
1755
  }
1482
1756
 
1483
- const queue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
1757
+ const queue: EvolutionQueueItem[] = JSON.parse(fs.readFileSync(queuePath, 'utf8')) as EvolutionQueueItem[];
1484
1758
 
1485
1759
  // Purge stale failed tasks before processing (keeps queue lean)
1486
1760
  const purgeResult = purgeStaleFailedTasks(queue, logger);
@@ -1490,10 +1764,10 @@ async function processEvolutionQueueWithResult(
1490
1764
  }
1491
1765
 
1492
1766
  queueResult.total = queue.length;
1493
- queueResult.pending = queue.filter((t: any) => t.status === 'pending').length;
1494
- queueResult.in_progress = queue.filter((t: any) => t.status === 'in_progress').length;
1495
- queueResult.failed_this_cycle = queue.filter((t: any) => t.status === 'failed').length;
1496
- queueResult.completed_this_cycle = queue.filter((t: any) => t.status === 'completed').length;
1767
+ queueResult.pending = queue.filter((t) => t.status === 'pending').length;
1768
+ queueResult.in_progress = queue.filter((t) => t.status === 'in_progress').length;
1769
+ queueResult.failed_this_cycle = queue.filter((t) => t.status === 'failed').length;
1770
+ queueResult.completed_this_cycle = queue.filter((t) => t.status === 'completed').length;
1497
1771
 
1498
1772
  // Log queue health snapshot every cycle
1499
1773
  logger.info(`[PD:EvolutionWorker] Queue snapshot: total=${queueResult.total} pending=${queueResult.pending} in_progress=${queueResult.in_progress} completed=${queueResult.completed_this_cycle} failed=${queueResult.failed_this_cycle} purged=${purgeResult.purged}`);
@@ -1514,7 +1788,7 @@ export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
1514
1788
 
1515
1789
  start(ctx: OpenClawPluginServiceContext): void {
1516
1790
  const logger = ctx?.logger || console;
1517
- const api = this.api;
1791
+ const {api} = this;
1518
1792
  const workspaceDir = ctx?.workspaceDir;
1519
1793
 
1520
1794
  if (!workspaceDir) {
@@ -1526,9 +1800,9 @@ export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
1526
1800
  if (logger) logger.info(`[PD:EvolutionWorker] Starting with workspaceDir=${wctx.workspaceDir}, stateDir=${wctx.stateDir}`);
1527
1801
 
1528
1802
  initPersistence(wctx.stateDir);
1529
- const eventLog = wctx.eventLog;
1803
+ const {eventLog} = wctx;
1530
1804
 
1531
- const config = wctx.config;
1805
+ const {config} = wctx;
1532
1806
  const language = config.get('language') || 'en';
1533
1807
  ensureStateTemplates({ logger }, wctx.stateDir, language);
1534
1808
 
@@ -1602,17 +1876,19 @@ export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
1602
1876
  if (api) {
1603
1877
  await processDetectionQueue(wctx, api, eventLog);
1604
1878
  }
1605
- await processPromotion(wctx, logger, eventLog);
1879
+ // processPromotion removed (D-06) promotion via PAIN_CANDIDATES no longer needed
1606
1880
 
1607
1881
  try {
1608
1882
  // Delegate to workflow managers' sweepExpiredWorkflows so that
1609
1883
  // session/transcript cleanup runs via driver.deleteSession().
1610
1884
  const subagentRuntime = api?.runtime?.subagent;
1885
+ const agentSession = api?.runtime?.agent?.session;
1611
1886
  if (subagentRuntime) {
1612
1887
  const empathyMgr = new EmpathyObserverWorkflowManager({
1613
1888
  workspaceDir: wctx.workspaceDir,
1614
1889
  logger: api.logger,
1615
1890
  subagent: subagentRuntime,
1891
+ agentSession,
1616
1892
  });
1617
1893
  let swept = 0;
1618
1894
  try {
@@ -1625,6 +1901,7 @@ export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
1625
1901
  workspaceDir: wctx.workspaceDir,
1626
1902
  logger: api.logger,
1627
1903
  subagent: subagentRuntime,
1904
+ agentSession,
1628
1905
  });
1629
1906
  try {
1630
1907
  swept += await deepReflectMgr.sweepExpiredWorkflows(WORKFLOW_TTL_MS);
@@ -1632,6 +1909,20 @@ export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
1632
1909
  deepReflectMgr.dispose();
1633
1910
  }
1634
1911
 
1912
+ // #183 + #188: Sweep Nocturnal workflows too (with gateway-safe fallback)
1913
+ try {
1914
+ const nocturnalMgr = new NocturnalWorkflowManager({
1915
+ workspaceDir: wctx.workspaceDir,
1916
+ stateDir: wctx.stateDir,
1917
+ logger: api.logger,
1918
+ runtimeAdapter: new OpenClawTrinityRuntimeAdapter(api),
1919
+ });
1920
+ swept += await nocturnalMgr.sweepExpiredWorkflows(WORKFLOW_TTL_MS, subagentRuntime, agentSession);
1921
+ nocturnalMgr.dispose();
1922
+ } catch (noctSweepErr) {
1923
+ logger?.warn?.(`[PD:EvolutionWorker] Nocturnal sweep failed: ${String(noctSweepErr)}`);
1924
+ }
1925
+
1635
1926
  if (swept > 0) {
1636
1927
  logger?.info?.(`[PD:EvolutionWorker] Swept ${swept} expired workflows (with session cleanup)`);
1637
1928
  }
@@ -1654,6 +1945,19 @@ export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
1654
1945
  logger?.warn?.(`[PD:EvolutionWorker] ${errMsg}`);
1655
1946
  }
1656
1947
 
1948
+ // ── Workflow Watchdog: detect stale active workflows ──
1949
+ // This catches bugs like #185 (orphaned active), #181 (empty results),
1950
+ // #180/#183 (expired without cleanup), #182 (unhandled rejection).
1951
+ try {
1952
+ const watchdogResult = await runWorkflowWatchdog(wctx, api, logger);
1953
+ if (watchdogResult.anomalies > 0) {
1954
+ logger?.warn?.(`[PD:Watchdog] ${watchdogResult.anomalies} anomalies: ${watchdogResult.details.join('; ')}`);
1955
+ cycleResult.errors.push(...watchdogResult.details);
1956
+ }
1957
+ } catch (watchdogErr) {
1958
+ logger?.warn?.(`[PD:Watchdog] Watchdog failed: ${String(watchdogErr)}`);
1959
+ }
1960
+
1657
1961
  wctx.dictionary.flush();
1658
1962
  flushAllSessions();
1659
1963
 
@@ -1686,7 +1990,7 @@ export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
1686
1990
  if (api) {
1687
1991
  await processDetectionQueue(wctx, api, eventLog);
1688
1992
  }
1689
- await processPromotion(wctx, logger, eventLog);
1993
+ // processPromotion removed (D-06)
1690
1994
  timeoutId = setTimeout(runCycle, interval);
1691
1995
  })().catch((err) => {
1692
1996
  if (logger) logger.error(`[PD:EvolutionWorker] Startup worker cycle failed: ${String(err)}`);