principles-disciple 1.72.0 → 1.74.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 (319) hide show
  1. package/INSTALL.md +1 -3
  2. package/openclaw.plugin.json +10 -5
  3. package/package.json +17 -19
  4. package/scripts/acceptance-test.mjs +16 -73
  5. package/scripts/sync-plugin.mjs +382 -77
  6. package/src/commands/archive-impl.ts +2 -1
  7. package/src/commands/capabilities.ts +2 -2
  8. package/src/commands/context.ts +2 -2
  9. package/src/commands/disable-impl.ts +2 -1
  10. package/src/commands/evolution-status.ts +16 -16
  11. package/src/commands/export.ts +12 -67
  12. package/src/commands/pain.ts +91 -1
  13. package/src/commands/principle-rollback.ts +2 -1
  14. package/src/commands/promote-impl.ts +7 -43
  15. package/src/commands/rollback-impl.ts +2 -1
  16. package/src/commands/rollback.ts +2 -1
  17. package/src/commands/samples.ts +2 -1
  18. package/src/commands/thinking-os.ts +2 -1
  19. package/src/config/errors.ts +18 -2
  20. package/src/constants/diagnostician.ts +2 -2
  21. package/src/constants/tools.ts +2 -1
  22. package/src/core/__tests__/focus-history.test.ts +210 -0
  23. package/src/core/config.ts +1 -1
  24. package/src/core/correction-cue-learner.ts +2 -136
  25. package/src/core/correction-types.ts +16 -88
  26. package/src/core/dictionary.ts +19 -20
  27. package/src/core/empathy-keyword-matcher.ts +17 -289
  28. package/src/core/empathy-types.ts +18 -229
  29. package/src/core/event-log.ts +29 -132
  30. package/src/core/evolution-reducer.ts +21 -2
  31. package/src/core/evolution-types.ts +76 -464
  32. package/src/core/file-store.ts +80 -0
  33. package/src/core/focus-history.ts +228 -955
  34. package/src/core/local-worker-routing.ts +34 -314
  35. package/src/core/merge-gate-audit.ts +0 -195
  36. package/src/core/migration.ts +0 -1
  37. package/src/core/pain-diagnostic-gate.ts +154 -0
  38. package/src/core/pain-signal.ts +21 -138
  39. package/src/core/pain.ts +15 -88
  40. package/src/core/path-resolver.ts +0 -1
  41. package/src/core/paths.ts +0 -1
  42. package/src/core/pd-task-reconciler.ts +26 -115
  43. package/src/core/pd-task-service.ts +9 -9
  44. package/src/core/pd-task-types.ts +23 -127
  45. package/src/core/principle-compiler/__tests__/compiler-replay-gate.test.ts +174 -0
  46. package/src/core/principle-compiler/code-validator.ts +15 -42
  47. package/src/core/principle-compiler/compiler.ts +100 -15
  48. package/src/core/principle-compiler/index.ts +5 -2
  49. package/src/core/principle-compiler/template-generator.ts +4 -104
  50. package/src/core/principle-injection.ts +10 -202
  51. package/src/core/principle-internalization/filesystem-lifecycle-datasource.ts +42 -0
  52. package/src/core/principle-internalization/lifecycle-read-model.ts +39 -242
  53. package/src/core/principle-internalization/principle-lifecycle-service.ts +12 -10
  54. package/src/core/principle-tree-ledger-adapter.ts +145 -0
  55. package/src/core/principle-tree-ledger.ts +8 -6
  56. package/src/core/reflection/reflection-context.ts +14 -109
  57. package/src/core/replay-engine.ts +8 -500
  58. package/src/core/rule-host-helpers.ts +5 -35
  59. package/src/core/rule-host-types.ts +10 -82
  60. package/src/core/rule-host.ts +6 -63
  61. package/src/core/runtime-v2-prompt-activation-reader.ts +231 -0
  62. package/src/core/session-tracker.ts +87 -101
  63. package/src/core/shadow-observation-registry.ts +19 -48
  64. package/src/core/trajectory.ts +3 -1
  65. package/src/core/workflow-funnel-loader.ts +62 -68
  66. package/src/core/workspace-context.ts +46 -0
  67. package/src/core/workspace-dir-service.ts +1 -1
  68. package/src/core/workspace-dir-validation.ts +18 -9
  69. package/src/hooks/AGENTS.md +1 -1
  70. package/src/hooks/gate-block-helper.ts +71 -64
  71. package/src/hooks/gate.ts +183 -31
  72. package/src/hooks/lifecycle.ts +30 -32
  73. package/src/hooks/llm.ts +60 -32
  74. package/src/hooks/pain.ts +297 -103
  75. package/src/hooks/prompt.ts +400 -440
  76. package/src/hooks/subagent.ts +2 -29
  77. package/src/i18n/commands.ts +2 -10
  78. package/src/index.ts +95 -85
  79. package/src/openclaw-sdk.ts +311 -0
  80. package/src/service/central-database.ts +8 -4
  81. package/src/service/evolution-queue-migration.ts +2 -1
  82. package/src/service/evolution-worker.ts +163 -1786
  83. package/src/service/internalization-trigger-adapter.ts +302 -0
  84. package/src/service/keyword-optimization-service.ts +4 -4
  85. package/src/service/monitoring-query-service.ts +1 -215
  86. package/src/service/queue-io.ts +60 -331
  87. package/src/service/runtime-summary-service.ts +59 -16
  88. package/src/service/subagent-workflow/index.ts +0 -41
  89. package/src/service/subagent-workflow/types.ts +9 -120
  90. package/src/service/subagent-workflow/workflow-store.ts +2 -119
  91. package/src/service/workflow-watchdog.ts +0 -43
  92. package/src/types/event-payload.ts +16 -74
  93. package/src/types/event-types.ts +38 -547
  94. package/src/types/hygiene-types.ts +7 -30
  95. package/src/types/principle-tree-schema.ts +20 -222
  96. package/src/types/queue.ts +15 -70
  97. package/src/types/runtime-summary.ts +5 -49
  98. package/src/utils/io.ts +8 -20
  99. package/src/utils/retry.ts +1 -1
  100. package/src/utils/shadow-fingerprint.ts +2 -2
  101. package/src/utils/workspace-resolver.ts +50 -0
  102. package/templates/langs/en/core/AGENTS.md +7 -7
  103. package/templates/langs/en/core/BOOT.md +1 -1
  104. package/templates/langs/en/core/HEARTBEAT.md +2 -2
  105. package/templates/langs/en/principles/THINKING_OS.md +3 -2
  106. package/templates/langs/en/skills/ai-sprint-orchestration/references/agent-registry.json +1 -72
  107. package/templates/langs/en/skills/ai-sprint-orchestration/references/specs/bugfix-complex-template.json +6 -6
  108. package/templates/langs/en/skills/ai-sprint-orchestration/references/specs/feature-complex-template.json +6 -6
  109. package/templates/langs/en/skills/ai-sprint-orchestration/references/specs/workflow-validation-minimal-verify.json +2 -12
  110. package/templates/langs/en/skills/ai-sprint-orchestration/references/specs/workflow-validation-minimal.json +2 -12
  111. package/templates/langs/en/skills/ai-sprint-orchestration/scripts/run.mjs +51 -15
  112. package/templates/langs/en/skills/evolve-task/SKILL.md +3 -3
  113. package/templates/langs/en/skills/pd-cli-operator/SKILL.md +67 -0
  114. package/templates/langs/en/skills/pd-diagnostician/SKILL.md +1 -1
  115. package/templates/langs/en/skills/pd-mentor/SKILL.md +2 -3
  116. package/templates/langs/en/skills/pd-pain-signal/SKILL.md +17 -39
  117. package/templates/langs/en/skills/pd-runtime-v2/SKILL.md +61 -0
  118. package/templates/langs/zh/core/AGENTS.md +7 -7
  119. package/templates/langs/zh/core/BOOT.md +1 -1
  120. package/templates/langs/zh/core/HEARTBEAT.md +2 -2
  121. package/templates/langs/zh/principles/THINKING_OS.md +3 -2
  122. package/templates/langs/zh/skills/ai-sprint-orchestration/references/agent-registry.json +1 -72
  123. package/templates/langs/zh/skills/ai-sprint-orchestration/references/specs/bugfix-complex-template.json +6 -6
  124. package/templates/langs/zh/skills/ai-sprint-orchestration/references/specs/feature-complex-template.json +6 -6
  125. package/templates/langs/zh/skills/ai-sprint-orchestration/references/specs/nocturnal-trinity-quality-enhancement.json +8 -8
  126. package/templates/langs/zh/skills/ai-sprint-orchestration/references/specs/workflow-validation-minimal-verify.json +2 -12
  127. package/templates/langs/zh/skills/ai-sprint-orchestration/references/specs/workflow-validation-minimal.json +2 -12
  128. package/templates/langs/zh/skills/ai-sprint-orchestration/scripts/run.mjs +51 -15
  129. package/templates/langs/zh/skills/ai-sprint-orchestration/test/run.test.mjs +21 -5
  130. package/templates/langs/zh/skills/evolve-task/SKILL.md +4 -4
  131. package/templates/langs/zh/skills/pd-cli-operator/SKILL.md +67 -0
  132. package/templates/langs/zh/skills/pd-diagnostician/SKILL.md +1 -1
  133. package/templates/langs/zh/skills/pd-mentor/SKILL.md +2 -3
  134. package/templates/langs/zh/skills/pd-pain-signal/SKILL.md +17 -38
  135. package/templates/langs/zh/skills/pd-runtime-v2/SKILL.md +61 -0
  136. package/tests/build-artifacts.test.ts +1 -3
  137. package/tests/commands/evolution-status.test.ts +0 -118
  138. package/tests/core/bootstrap-rules.test.ts +1 -1
  139. package/tests/core/config.test.ts +1 -1
  140. package/tests/core/event-log.test.ts +35 -0
  141. package/tests/core/evolution-engine.test.ts +610 -0
  142. package/tests/core/file-store.test.ts +102 -0
  143. package/tests/core/focus-history.test.ts +203 -11
  144. package/tests/core/merge-gate-audit.test.ts +2 -169
  145. package/tests/core/migration.test.ts +7 -7
  146. package/tests/core/model-deployment-registry.test.ts +7 -1
  147. package/tests/core/model-training-registry.test.ts +19 -0
  148. package/tests/core/observability.test.ts +0 -1
  149. package/tests/core/pain-diagnostic-gate.test.ts +498 -0
  150. package/tests/core/pain.test.ts +0 -1
  151. package/tests/core/path-resolver.test.ts +1 -1
  152. package/tests/core/paths-refactor.test.ts +0 -22
  153. package/tests/core/principle-internalization/deprecated-readiness.test.ts +2 -2
  154. package/tests/core/principle-internalization/lifecycle-metrics.test.ts +2 -2
  155. package/tests/core/principle-internalization/{internalization-routing-policy.test.ts → lifecycle-routing-policy.test.ts} +6 -6
  156. package/tests/core/principle-internalization/lineage-source-retired.test.ts +56 -0
  157. package/tests/core/principle-internalization/principle-lifecycle-service.test.ts +1 -23
  158. package/tests/core/principle-tree-ledger-adapter.test.ts +253 -0
  159. package/tests/core/reflection-context.test.ts +0 -14
  160. package/tests/core/replay-engine.test.ts +127 -215
  161. package/tests/core/rule-host-helpers.test.ts +2 -2
  162. package/tests/core/rule-implementation-runtime.test.ts +0 -27
  163. package/tests/core/workflow-funnel-loader.test.ts +162 -0
  164. package/tests/core/workspace-context.test.ts +2 -2
  165. package/tests/core/workspace-dir-validation.test.ts +8 -1
  166. package/tests/core-anti-growth.test.ts +191 -0
  167. package/tests/hook-workspace-nextaction-contract.test.ts +42 -0
  168. package/tests/hooks/confirm-first-removal.test.ts +188 -0
  169. package/tests/hooks/gate-auto-correct-shadow.test.ts +310 -0
  170. package/tests/hooks/gate-auto-correct.test.ts +665 -0
  171. package/tests/hooks/gate-no-path-write-tool.test.ts +172 -0
  172. package/tests/hooks/gate-rule-host-pipeline.test.ts +2 -1
  173. package/tests/hooks/pain.test.ts +269 -12
  174. package/tests/hooks/prompt-characterization.test.ts +500 -0
  175. package/tests/hooks/prompt-size-guard.test.ts +32 -17
  176. package/tests/hooks/runtime-v2-prompt-activation.test.ts +869 -0
  177. package/tests/index.test.ts +94 -1
  178. package/tests/integration/auto-entry-gate.test.ts +248 -0
  179. package/tests/integration/internalization-trigger-guard.test.ts +69 -0
  180. package/tests/integration/m8-legacy-paths.test.ts +63 -0
  181. package/tests/integration/runtime-v2-pain-guard.test.ts +125 -0
  182. package/tests/plugin-config-resolution-cutover.test.ts +359 -0
  183. package/tests/runtime-v2-discovery-guard.test.ts +154 -0
  184. package/tests/service/central-database.test.ts +457 -0
  185. package/tests/service/evolution-worker.correction-observer.test.ts +173 -0
  186. package/tests/service/evolution-worker.timeout.test.ts +11 -129
  187. package/tests/service/internalization-trigger-adapter.test.ts +251 -0
  188. package/tests/service/monitoring-query-service.test.ts +1 -47
  189. package/tests/service/queue-io.test.ts +1 -62
  190. package/tests/service/runtime-summary-service.test.ts +3 -1
  191. package/tests/service/workflow-watchdog.test.ts +0 -91
  192. package/tests/utils/file-lock.test.ts +5 -3
  193. package/tests/utils/session-key.test.ts +52 -0
  194. package/tests/utils/subagent-probe.test.ts +48 -1
  195. package/vitest.config.ts +4 -11
  196. package/.planning/codebase/ARCHITECTURE.md +0 -157
  197. package/.planning/codebase/CONCERNS.md +0 -145
  198. package/.planning/codebase/CONVENTIONS.md +0 -148
  199. package/.planning/codebase/INTEGRATIONS.md +0 -81
  200. package/.planning/codebase/STACK.md +0 -87
  201. package/.planning/codebase/STRUCTURE.md +0 -193
  202. package/.planning/codebase/TESTING.md +0 -243
  203. package/.planning/phases/01-basic-visualization/01-GAP-CLOSURE-VERIFICATION.md +0 -113
  204. package/docs/COMMAND_REFERENCE.md +0 -76
  205. package/docs/COMMAND_REFERENCE_EN.md +0 -79
  206. package/scripts/build-web.mjs +0 -46
  207. package/scripts/diagnose-nocturnal.mjs +0 -537
  208. package/scripts/seed-nocturnal-scenarios.mjs +0 -384
  209. package/src/commands/nocturnal-review.ts +0 -322
  210. package/src/commands/nocturnal-rollout.ts +0 -790
  211. package/src/commands/nocturnal-train.ts +0 -986
  212. package/src/commands/pd-reflect.ts +0 -88
  213. package/src/core/adaptive-thresholds.ts +0 -478
  214. package/src/core/diagnostician-task-store.ts +0 -192
  215. package/src/core/nocturnal-arbiter.ts +0 -715
  216. package/src/core/nocturnal-artifact-lineage.ts +0 -116
  217. package/src/core/nocturnal-artificer.ts +0 -257
  218. package/src/core/nocturnal-candidate-scoring.ts +0 -530
  219. package/src/core/nocturnal-compliance.ts +0 -1146
  220. package/src/core/nocturnal-dataset.ts +0 -763
  221. package/src/core/nocturnal-executability.ts +0 -428
  222. package/src/core/nocturnal-export.ts +0 -499
  223. package/src/core/nocturnal-paths.ts +0 -240
  224. package/src/core/nocturnal-reasoning-deriver.ts +0 -343
  225. package/src/core/nocturnal-rule-implementation-validator.ts +0 -246
  226. package/src/core/nocturnal-snapshot-contract.ts +0 -99
  227. package/src/core/nocturnal-trajectory-extractor.ts +0 -512
  228. package/src/core/nocturnal-trinity-types.ts +0 -218
  229. package/src/core/nocturnal-trinity.ts +0 -2680
  230. package/src/core/principle-internalization/deprecated-readiness.ts +0 -93
  231. package/src/core/principle-internalization/internalization-routing-policy.ts +0 -208
  232. package/src/core/principle-internalization/lifecycle-metrics.ts +0 -152
  233. package/src/http/principles-console-route.ts +0 -709
  234. package/src/service/central-health-service.ts +0 -49
  235. package/src/service/central-overview-service.ts +0 -138
  236. package/src/service/control-ui-query-service.ts +0 -900
  237. package/src/service/cooldown-strategy.ts +0 -97
  238. package/src/service/evolution-pain-context.ts +0 -79
  239. package/src/service/evolution-query-service.ts +0 -407
  240. package/src/service/health-query-service.ts +0 -1038
  241. package/src/service/nocturnal-config.ts +0 -214
  242. package/src/service/nocturnal-runtime.ts +0 -734
  243. package/src/service/nocturnal-service.ts +0 -1605
  244. package/src/service/nocturnal-target-selector.ts +0 -545
  245. package/src/service/sleep-cycle.ts +0 -157
  246. package/src/service/startup-reconciler.ts +0 -112
  247. package/src/service/subagent-workflow/correction-observer-types.ts +0 -82
  248. package/src/service/subagent-workflow/correction-observer-workflow-manager.ts +0 -250
  249. package/src/service/subagent-workflow/deep-reflect-workflow-manager.ts +0 -1
  250. package/src/service/subagent-workflow/dynamic-timeout.ts +0 -30
  251. package/src/service/subagent-workflow/empathy-observer-workflow-manager.ts +0 -268
  252. package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +0 -795
  253. package/src/service/subagent-workflow/runtime-direct-driver.ts +0 -268
  254. package/src/service/subagent-workflow/workflow-manager-base.ts +0 -580
  255. package/src/tools/write-pain-flag.ts +0 -215
  256. package/templates/langs/en/skills/plan-script/SKILL.md +0 -32
  257. package/templates/langs/zh/skills/plan-script/SKILL.md +0 -32
  258. package/tests/commands/nocturnal-review.test.ts +0 -448
  259. package/tests/commands/nocturnal-train.test.ts +0 -97
  260. package/tests/commands/pd-reflect.test.ts +0 -49
  261. package/tests/core/adaptive-thresholds.test.ts +0 -261
  262. package/tests/core/nocturnal-arbiter.test.ts +0 -559
  263. package/tests/core/nocturnal-artifact-lineage.test.ts +0 -53
  264. package/tests/core/nocturnal-artificer.test.ts +0 -241
  265. package/tests/core/nocturnal-candidate-scoring.test.ts +0 -532
  266. package/tests/core/nocturnal-compliance-p-principles.test.ts +0 -133
  267. package/tests/core/nocturnal-compliance.test.ts +0 -646
  268. package/tests/core/nocturnal-dataset.test.ts +0 -892
  269. package/tests/core/nocturnal-e2e.test.ts +0 -234
  270. package/tests/core/nocturnal-executability.test.ts +0 -357
  271. package/tests/core/nocturnal-export.test.ts +0 -517
  272. package/tests/core/nocturnal-reasoning-deriver.test.ts +0 -372
  273. package/tests/core/nocturnal-reviewed-subset-comparison.test.ts +0 -428
  274. package/tests/core/nocturnal-rule-implementation-validator.test.ts +0 -127
  275. package/tests/core/nocturnal-snapshot-contract.test.ts +0 -121
  276. package/tests/core/nocturnal-trajectory-extractor.test.ts +0 -634
  277. package/tests/core/nocturnal-trinity.test.ts +0 -2053
  278. package/tests/core/pain-auto-repair.test.ts +0 -96
  279. package/tests/core/pain-integration.test.ts +0 -510
  280. package/tests/fixtures/nocturnal-reviewed-subset.json +0 -183
  281. package/tests/http/principles-console-route.test.ts +0 -162
  282. package/tests/integration/chaos-resilience.test.ts +0 -348
  283. package/tests/integration/empathy-workflow-integration.test.ts +0 -626
  284. package/tests/integration/pain-diagnostician-loop.e2e.test.ts +0 -380
  285. package/tests/service/control-ui-query-service.test.ts +0 -121
  286. package/tests/service/cooldown-strategy.test.ts +0 -164
  287. package/tests/service/data-endpoints-regression.test.ts +0 -834
  288. package/tests/service/empathy-observer-workflow-manager.test.ts +0 -175
  289. package/tests/service/evolution-worker.nocturnal.test.ts +0 -601
  290. package/tests/service/nocturnal-runtime-hardening.test.ts +0 -118
  291. package/tests/service/nocturnal-runtime.test.ts +0 -473
  292. package/tests/service/nocturnal-service-code-candidate.test.ts +0 -330
  293. package/tests/service/nocturnal-target-selector.test.ts +0 -615
  294. package/tests/service/startup-reconciler.test.ts +0 -148
  295. package/tests/tools/write-pain-flag.test.ts +0 -358
  296. package/ui/src/App.tsx +0 -45
  297. package/ui/src/api.ts +0 -220
  298. package/ui/src/charts.tsx +0 -955
  299. package/ui/src/components/ErrorState.tsx +0 -6
  300. package/ui/src/components/Loading.tsx +0 -13
  301. package/ui/src/components/ProtectedRoute.tsx +0 -12
  302. package/ui/src/components/Shell.tsx +0 -91
  303. package/ui/src/components/WorkspaceConfig.tsx +0 -178
  304. package/ui/src/components/index.ts +0 -5
  305. package/ui/src/context/auth.tsx +0 -80
  306. package/ui/src/context/theme.tsx +0 -66
  307. package/ui/src/hooks/useAutoRefresh.ts +0 -39
  308. package/ui/src/i18n/ui.ts +0 -473
  309. package/ui/src/main.tsx +0 -16
  310. package/ui/src/pages/EvolutionPage.tsx +0 -333
  311. package/ui/src/pages/FeedbackPage.tsx +0 -138
  312. package/ui/src/pages/GateMonitorPage.tsx +0 -136
  313. package/ui/src/pages/LoginPage.tsx +0 -89
  314. package/ui/src/pages/OverviewPage.tsx +0 -599
  315. package/ui/src/pages/SamplesPage.tsx +0 -174
  316. package/ui/src/pages/ThinkingModelsPage.tsx +0 -702
  317. package/ui/src/styles.css +0 -2020
  318. package/ui/src/types.ts +0 -384
  319. package/ui/src/utils/format.ts +0 -15
@@ -12,48 +12,30 @@ import { SystemLogger } from '../core/system-logger.js';
12
12
  import { WorkspaceContext } from '../core/workspace-context.js';
13
13
  import type { EventLog } from '../core/event-log.js';
14
14
  import { initPersistence, flushAllSessions } from '../core/session-tracker.js';
15
- import { addDiagnosticianTask, completeDiagnosticianTask, requeueDiagnosticianTask } from '../core/diagnostician-task-store.js';
16
- import { getEvolutionLogger } from '../core/evolution-logger.js';
17
15
  import type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
18
16
  import type { PrincipleEvaluability } from '../types/principle-tree-schema.js';
19
17
  export type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
20
18
  import { atomicWriteFileSync } from '../utils/io.js';
21
- import { validatePainSignal, type PainSignalValidationResult } from '../core/pain-signal.js';
22
19
 
23
20
  // Re-export queue I/O (extracted to queue-io.ts)
24
21
  export { loadEvolutionQueue, saveEvolutionQueue, withQueueLock, acquireQueueLock, requireQueueLock } from './queue-io.js';
25
- export { enqueueSleepReflectionTask, enqueueKeywordOptimizationTask } from './queue-io.js';
26
22
  export { EVOLUTION_QUEUE_LOCK_SUFFIX, LOCK_MAX_RETRIES, LOCK_RETRY_DELAY_MS, LOCK_STALE_MS } from './queue-io.js';
27
- import { saveEvolutionQueue, requireQueueLock, hasPendingTask, enqueueSleepReflectionTask, enqueueKeywordOptimizationTask, createEvolutionTaskId } from './queue-io.js';
28
- import type { RecentPainContext } from './queue-io.js';
29
- export type { RecentPainContext } from './queue-io.js';
30
- import { checkWorkspaceIdle, checkCooldown, recordCooldown } from './nocturnal-runtime.js';
31
- import { loadCooldownEscalationConfig, loadNocturnalConfigMerged } from './nocturnal-config.js';
23
+ import { saveEvolutionQueue, requireQueueLock } from './queue-io.js';
32
24
  import { WorkflowStore } from './subagent-workflow/workflow-store.js';
33
- import { EmpathyObserverWorkflowManager } from './subagent-workflow/empathy-observer-workflow-manager.js';
34
- import { NocturnalWorkflowManager, nocturnalWorkflowSpec } from './subagent-workflow/nocturnal-workflow-manager.js';
35
25
  import {
36
- createNocturnalTrajectoryExtractor,
37
- type NocturnalPainEvent,
38
- type NocturnalSessionSnapshot,
39
- } from '../core/nocturnal-trajectory-extractor.js';
40
- import { validateNocturnalSnapshotIngress } from '../core/nocturnal-snapshot-contract.js';
26
+ WorkflowFunnelLoader,
27
+ PiAiRuntimeAdapter,
28
+ CorrectionObserver,
29
+ AgentScheduler,
30
+ } from '@principles/core/runtime-v2';
31
+ import { KeywordOptimizationService } from './keyword-optimization-service.js';
32
+ import { CorrectionCueLearner } from '../core/correction-cue-learner.js';
33
+
41
34
  import { PrincipleCompiler } from '../core/principle-compiler/index.js';
42
35
  import { loadLedger, updatePrinciple } from '../core/principle-tree-ledger.js';
43
- import { isExpectedSubagentError } from './subagent-workflow/subagent-error-utils.js';
44
- import { readPainFlagContract } from '../core/pain.js';
45
- import { CorrectionObserverWorkflowManager, correctionObserverWorkflowSpec } from './subagent-workflow/correction-observer-workflow-manager.js';
46
36
  import { findRecentDuplicateTask } from './evolution-dedup.js';
47
- import type { CorrectionObserverPayload } from './subagent-workflow/correction-observer-types.js';
48
- import { KeywordOptimizationService } from './keyword-optimization-service.js';
49
37
  import { TrajectoryRegistry } from '../core/trajectory.js';
50
- import { CorrectionCueLearner } from '../core/correction-cue-learner.js';
51
- import { classifyFailure, type ClassifiableTaskKind } from './failure-classifier.js';
52
- import { recordPersistentFailure, resetFailureState, isTaskKindInCooldown } from './cooldown-strategy.js';
53
- import { reconcileStartup } from './startup-reconciler.js';
54
- import { clearPainFlag } from '../core/pain-lifecycle.js';
55
38
  import { WORKFLOW_TTL_MS } from '../config/defaults/runtime.js';
56
- import { OpenClawTrinityRuntimeAdapter } from '../core/nocturnal-trinity.js';
57
39
 
58
40
  // ── Queue Event Payload Validation ─────────────────────────────────────────
59
41
 
@@ -99,16 +81,15 @@ export type { WatchdogResult };
99
81
  let timeoutId: NodeJS.Timeout | null = null;
100
82
 
101
83
  /**
102
- * Queue V2 Schema - Supports multiple task kinds while preserving pain_diagnosis semantics.
103
- *
104
- * taskKind semantics:
105
- * - pain_diagnosis: User-adjacent, triggers HEARTBEAT, injects into user prompts
106
- * - sleep_reflection: Background-only, never injects into user prompts, no HEARTBEAT
84
+ * Queue V2 Schema - Supports background evolution task kinds.
107
85
  *
108
- * Old queue items (without taskKind) are migrated to pain_diagnosis for compatibility.
86
+ * Pain diagnosis is Runtime v2 only: after_tool_call / pd pain record ->
87
+ * PainSignalBridge -> DiagnosticianRunner. EvolutionWorker does not read
88
+ * .pain_flag or process pain_diagnosis queue items.
109
89
  */
90
+ /** @deprecated Use PDTaskStatus from '@principles/core/runtime-v2'. M2 migration will replace this. */
110
91
  export type QueueStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'canceled';
111
- export type TaskResolution = 'marker_detected' | 'auto_completed_timeout' | 'failed_max_retries' | 'runtime_unavailable' | 'canceled' | 'late_marker_principle_created' | 'late_marker_no_principle' | 'stub_fallback' | 'skipped_thin_violation' | 'noise_classified';
92
+ export type TaskResolution = 'marker_detected' | 'auto_completed_timeout' | 'failed_max_retries' | 'runtime_unavailable' | 'canceled' | 'late_marker_principle_created' | 'late_marker_no_principle' | 'stub_fallback' | 'skipped_thin_violation' | 'noise_classified' | 'retired';
112
93
 
113
94
  export interface EvolutionQueueItem {
114
95
  // Core identity
@@ -118,7 +99,7 @@ export interface EvolutionQueueItem {
118
99
  source: string;
119
100
  traceId?: string; // Trace ID for linking events across the evolution lifecycle
120
101
 
121
- // Legacy fields (still used for pain_diagnosis)
102
+ // Legacy fields kept for existing queue records and background task metadata.
122
103
  task?: string;
123
104
  score: number;
124
105
  reason: string;
@@ -141,12 +122,6 @@ export interface EvolutionQueueItem {
141
122
  // V2 result reference
142
123
  resultRef?: string; // V2: reference to result artifact
143
124
 
144
- // V2: Recent pain context for sleep_reflection tasks
145
- // Attaches explicit recent pain signal without merging task kinds.
146
- // Used by target selector for ranking bias and context enrichment.
147
- recentPainContext?: RecentPainContext;
148
-
149
- /** Trajectory pain_events row ID — set when pain flag includes pain_event_id */
150
125
  painEventId?: number;
151
126
  }
152
127
 
@@ -155,90 +130,7 @@ import { migrateToV2, isLegacyQueueItem, migrateQueueToV2, LegacyEvolutionQueueI
155
130
  export { migrateToV2, isLegacyQueueItem, migrateQueueToV2, LegacyEvolutionQueueItem, DEFAULT_TASK_KIND, DEFAULT_PRIORITY, DEFAULT_MAX_RETRIES };
156
131
  export type { RawQueueItem };
157
132
 
158
- function isSessionAtOrBeforeTriggerTime(
159
- session: { startedAt: string; updatedAt: string },
160
- triggerTimeMs: number,
161
- ): boolean {
162
- const startedAtMs = new Date(session.startedAt).getTime();
163
- const updatedAtMs = new Date(session.updatedAt).getTime();
164
- if (!Number.isFinite(triggerTimeMs)) {
165
- return true;
166
- }
167
- if (Number.isFinite(startedAtMs) && startedAtMs > triggerTimeMs) {
168
- return false;
169
- }
170
- if (Number.isFinite(updatedAtMs) && updatedAtMs > triggerTimeMs) {
171
- return false;
172
- }
173
- return true;
174
- }
175
-
176
-
177
- function buildFallbackNocturnalSnapshot(
178
- sleepTask: EvolutionQueueItem,
179
- extractor?: ReturnType<typeof createNocturnalTrajectoryExtractor> | null,
180
- logger?: { warn?: (message: string) => void }
181
- ): NocturnalSessionSnapshot | null {
182
- const painContext = sleepTask.recentPainContext;
183
- if (!painContext) {
184
- return null;
185
- }
186
-
187
- const fallbackPainEvents: NocturnalPainEvent[] = painContext.mostRecent ? [{
188
- source: painContext.mostRecent.source,
189
- score: painContext.mostRecent.score,
190
- severity: null,
191
- reason: painContext.mostRecent.reason,
192
- createdAt: painContext.mostRecent.timestamp,
193
- }] : [];
194
-
195
- // #246: Try to extract real session stats from trajectory DB for the pain session.
196
- // The main path tries getNocturnalSessionSnapshot which returns null when no session
197
- // exists. Here we attempt a lighter query via listRecentNocturnalCandidateSessions
198
- // to at least get summary counts for the pain-triggering session.
199
- let realStats: { totalAssistantTurns: number; totalToolCalls: number; failureCount: number; totalGateBlocks: number } | null = null;
200
- if (extractor && painContext.mostRecent?.sessionId) {
201
- try {
202
- // #246-fix: Use minToolCalls=0 to avoid filtering out sessions with 0 tool calls.
203
- // The pain-triggering session may have no tool calls but still be worth tracking.
204
- const summaries = extractor.listRecentNocturnalCandidateSessions({ limit: 300, minToolCalls: 0 });
205
- const match = summaries.find(s => s.sessionId === painContext.mostRecent?.sessionId);
206
- if (match) {
207
- realStats = {
208
- totalAssistantTurns: match.assistantTurnCount,
209
- totalToolCalls: match.toolCallCount,
210
- failureCount: match.failureCount,
211
- totalGateBlocks: match.gateBlockCount,
212
- };
213
- }
214
- } catch (err) {
215
- // #260: Log extraction failures — silent swallowing makes debugging impossible
216
- // and can mask systemic trajectory DB issues.
217
- logger?.warn?.(`[PD:EvolutionWorker] Failed to extract real stats for session ${painContext.mostRecent?.sessionId} (falling back to zeros): ${String(err)}`);
218
- }
219
- }
220
133
 
221
- return {
222
- sessionId: painContext.mostRecent?.sessionId || sleepTask.id,
223
- startedAt: sleepTask.timestamp,
224
- updatedAt: sleepTask.timestamp,
225
- assistantTurns: [],
226
- userTurns: [],
227
- toolCalls: [],
228
- painEvents: fallbackPainEvents,
229
- gateBlocks: [],
230
- // #268: Empty corrections in fallback path (no trajectory data available)
231
- userCorrections: [],
232
- stats: {
233
- totalAssistantTurns: realStats?.totalAssistantTurns ?? 0,
234
- totalToolCalls: realStats?.totalToolCalls ?? 0,
235
- failureCount: realStats?.failureCount ?? 0,
236
- totalPainEvents: painContext.recentPainCount,
237
- totalGateBlocks: realStats?.totalGateBlocks ?? 0,
238
- },
239
- _dataSource: 'pain_context_fallback',
240
- };
241
- }
242
134
 
243
135
  // Queue lock constants and requireQueueLock are imported from queue-io.ts
244
136
 
@@ -309,294 +201,6 @@ export function hasEquivalentPromotedRule(dictionary: { getAllRules(): Record<st
309
201
  });
310
202
  }
311
203
 
312
- interface ParsedPainValues {
313
- score: number; source: string; reason: string; preview: string;
314
- traceId: string; sessionId: string; agentId: string;
315
- painEventId?: number;
316
- }
317
-
318
-
319
-
320
- async function doEnqueuePainTask(
321
- wctx: WorkspaceContext, logger: PluginLogger, painFlagPath: string,
322
- result: WorkerStatusReport['pain_flag'], v: ParsedPainValues,
323
- ): Promise<WorkerStatusReport['pain_flag']> {
324
- result.exists = true;
325
- result.score = v.score;
326
- result.source = v.source;
327
-
328
- if (v.score < 30) {
329
- result.skipped_reason = `score_too_low (${v.score} < 30)`;
330
- if (logger) logger.info(`[PD:EvolutionWorker] Pain flag score too low: ${v.score} (source=${v.source})`);
331
- return result;
332
- }
333
-
334
- // Validate pain signal through TypeBox schema before enqueuing.
335
- // Malformed signals are logged and skipped — they never enter the queue.
336
- const signalInput = {
337
- source: v.source,
338
- score: v.score,
339
- timestamp: new Date().toISOString(),
340
- reason: v.reason,
341
- sessionId: v.sessionId ?? undefined,
342
- agentId: v.agentId ?? undefined,
343
- traceId: v.traceId ?? undefined,
344
- triggerTextPreview: v.preview,
345
- };
346
- const validation: PainSignalValidationResult = validatePainSignal(signalInput);
347
- if (!validation.valid) {
348
- result.skipped_reason = `invalid_pain_signal (${validation.errors.join('; ')})`;
349
- if (logger) logger.warn(`[PD:EvolutionWorker] Pain signal validation failed, skipping enqueue: ${validation.errors.join('; ')}`);
350
- SystemLogger.log(wctx.workspaceDir, 'PAIN_SIGNAL_INVALID', `Validation errors: ${validation.errors.join('; ')} | source=${v.source} score=${v.score}`);
351
- clearPainFlag(wctx.workspaceDir);
352
- return result;
353
- }
354
-
355
- const queuePath = wctx.resolve('EVOLUTION_QUEUE');
356
- const releaseLock = await requireQueueLock(queuePath, logger, 'checkPainFlag');
357
- try {
358
- let queue: EvolutionQueueItem[] = [];
359
- if (fs.existsSync(queuePath)) {
360
- try { queue = JSON.parse(fs.readFileSync(queuePath, 'utf8')); } catch { /* corrupted queue, treat as empty — safe fallback */ }
361
- }
362
- const now = Date.now();
363
- const dup = findRecentDuplicateTask(queue, v.source, v.preview, now, v.reason);
364
- if (dup) {
365
- fs.appendFileSync(painFlagPath, `\nstatus: queued\ntask_id: ${dup.id}\n`, 'utf8');
366
- result.enqueued = true;
367
- result.skipped_reason = 'duplicate';
368
- if (logger) logger.info(`[PD:EvolutionWorker] Duplicate pain task skipped for source=${v.source} preview=${v.preview || 'N/A'}`);
369
- clearPainFlag(wctx.workspaceDir);
370
- return result;
371
- }
372
-
373
- const taskId = createEvolutionTaskId(v.source, v.score, v.preview, v.reason, now);
374
- const nowIso = new Date(now).toISOString();
375
- const effectiveTraceId = v.traceId || taskId;
376
-
377
- queue.push({
378
- id: taskId, taskKind: 'pain_diagnosis',
379
- priority: v.score >= 70 ? 'high' : v.score >= 40 ? 'medium' : 'low',
380
- score: v.score, source: v.source, reason: v.reason,
381
- trigger_text_preview: v.preview, timestamp: nowIso, enqueued_at: nowIso,
382
- status: 'pending', session_id: v.sessionId || undefined,
383
- agent_id: v.agentId || undefined, traceId: effectiveTraceId,
384
- retryCount: 0, maxRetries: 3,
385
- painEventId: v.painEventId,
386
- });
387
-
388
- saveEvolutionQueue(queuePath, queue);
389
- fs.appendFileSync(painFlagPath, `\nstatus: queued\ntask_id: ${taskId}\n`, 'utf8');
390
- result.enqueued = true;
391
-
392
- if (logger) logger.info(`[PD:EvolutionWorker] Enqueued pain task ${taskId} (score=${v.score})`);
393
-
394
- const evoLogger = getEvolutionLogger(wctx.workspaceDir, wctx.trajectory);
395
- evoLogger.logQueued({
396
- traceId: effectiveTraceId,
397
- taskId,
398
- score: v.score,
399
- source: v.source,
400
- reason: v.reason,
401
- });
402
-
403
- wctx.trajectory?.recordEvolutionTask?.({
404
- taskId,
405
- traceId: effectiveTraceId,
406
- source: v.source,
407
- reason: v.reason,
408
- score: v.score,
409
- status: 'pending',
410
- enqueuedAt: nowIso,
411
- });
412
- } finally { releaseLock(); }
413
- clearPainFlag(wctx.workspaceDir);
414
- return result;
415
- }
416
-
417
- async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Promise<WorkerStatusReport['pain_flag']> {
418
- const result: WorkerStatusReport['pain_flag'] = { exists: false, score: null, source: null, enqueued: false, skipped_reason: null };
419
- try {
420
- const painFlagPath = wctx.resolve('PAIN_FLAG');
421
- if (!fs.existsSync(painFlagPath)) return result;
422
-
423
- const rawPain = fs.readFileSync(painFlagPath, 'utf8');
424
- const contract = readPainFlagContract(wctx.workspaceDir);
425
-
426
- if (contract.status === 'valid') {
427
- const score = parseInt(contract.data.score ?? '0', 10) || 0;
428
- const source = contract.data.source ?? 'unknown';
429
- const reason = contract.data.reason ?? 'Systemic pain detected';
430
- const preview = contract.data.trigger_text_preview ?? '';
431
- const isQueued = contract.data.status === 'queued';
432
- const traceId = contract.data.trace_id ?? '';
433
- const sessionId = contract.data.session_id ?? '';
434
- const agentId = contract.data.agent_id ?? '';
435
- const painEventIdRaw = contract.data.pain_event_id;
436
- const painEventId = painEventIdRaw ? parseInt(painEventIdRaw, 10) : undefined;
437
-
438
- result.exists = true;
439
- result.score = score;
440
- result.source = source;
441
- result.enqueued = isQueued;
442
-
443
- if (isQueued) {
444
- result.skipped_reason = 'already_queued';
445
- if (logger) logger.info(`[PD:EvolutionWorker] Pain flag already queued (score=${score}, source=${source})`);
446
- clearPainFlag(wctx.workspaceDir, painEventId);
447
- return result;
448
- }
449
-
450
- if (logger) logger.info(`[PD:EvolutionWorker] Detected pain flag (score: ${score}, source: ${source}). Enqueueing evolution task.`);
451
- return doEnqueuePainTask(wctx, logger, painFlagPath, result, {
452
- score, source, reason, preview, traceId, sessionId, agentId, painEventId,
453
- });
454
- }
455
-
456
- if (contract.status === 'invalid' && (contract.format === 'kv' || contract.format === 'json' || contract.format === 'invalid_json')) {
457
- result.exists = true;
458
- result.skipped_reason = `invalid_pain_flag (${contract.missingFields.join(', ') || contract.format})`;
459
- if (logger) logger.warn(`[PD:EvolutionWorker] Invalid pain flag skipped: ${result.skipped_reason}`);
460
- clearPainFlag(wctx.workspaceDir);
461
- return result;
462
- }
463
-
464
- // Try JSON format first (pain skill structured output)
465
- // The file may have 'status: queued' and 'task_id: xxx' appended after the JSON object.
466
- // Extract just the JSON portion by finding the last '}' and parsing up to that point.
467
- let parsedAsJson = false;
468
- try {
469
- const jsonEndIdx = rawPain.lastIndexOf('}');
470
- const jsonPortion = jsonEndIdx >= 0 ? rawPain.slice(0, jsonEndIdx + 1) : rawPain;
471
- const jsonPain = JSON.parse(jsonPortion);
472
-
473
- // Detect if this is a pain flag JSON object: has any of the known pain flag fields
474
- const isPainJson = typeof jsonPain === 'object' && jsonPain !== null && (
475
- jsonPain.pain_score !== undefined ||
476
- jsonPain.score !== undefined ||
477
- jsonPain.source !== undefined ||
478
- jsonPain.reason !== undefined ||
479
- jsonPain.session_id !== undefined ||
480
- jsonPain.agent_id !== undefined
481
- );
482
-
483
- if (isPainJson) {
484
- parsedAsJson = true;
485
- // Score resolution: pain_score > score > default 50
486
- const jsonScore = typeof jsonPain.pain_score === 'number' ? jsonPain.pain_score :
487
- typeof jsonPain.score === 'number' ? jsonPain.score : 50;
488
- const jsonSource = jsonPain.source || 'human';
489
- const jsonReason = jsonPain.reason || jsonPain.requested_action || 'Systemic pain detected';
490
- const jsonPreview = (jsonPain.symptoms || []).slice(0, 2).join('; ');
491
-
492
- // Check if already queued by looking for 'status: queued' in the full file
493
- const alreadyQueued = rawPain.includes('status: queued');
494
- if (alreadyQueued) {
495
- result.exists = true;
496
- result.score = jsonScore;
497
- result.source = jsonSource;
498
- result.enqueued = true;
499
- result.skipped_reason = 'already_queued';
500
- if (logger) logger.info(`[PD:EvolutionWorker] Pain flag already queued (score=${jsonScore}, source=${jsonSource})`);
501
- clearPainFlag(wctx.workspaceDir, jsonPain.pain_event_id ? parseInt(jsonPain.pain_event_id, 10) : undefined);
502
- return result;
503
- }
504
-
505
- return doEnqueuePainTask(wctx, logger, painFlagPath, result, {
506
- score: jsonScore, source: jsonSource, reason: jsonReason,
507
- preview: jsonPreview, traceId: '',
508
- sessionId: jsonPain.session_id || '',
509
- agentId: jsonPain.agent_id || '',
510
- painEventId: jsonPain.pain_event_id ? parseInt(jsonPain.pain_event_id, 10) : undefined,
511
- });
512
- }
513
- } catch { /* Not JSON — fall through to KV/Markdown parsing */ }
514
-
515
- // If we successfully parsed JSON but it didn't match pain flag fields,
516
- // don't fall through to KV parsing — it's not a valid pain flag
517
- if (parsedAsJson) {
518
- if (logger) logger.warn('[PD:EvolutionWorker] Pain flag parsed as JSON but missing all expected fields — ignoring');
519
- result.skipped_reason = 'invalid_json_format';
520
- return result;
521
- }
522
-
523
- const lines = rawPain.split('\n');
524
-
525
- let score = 0;
526
- let source = 'unknown';
527
- let reason = 'Systemic pain detected';
528
- let preview = '';
529
- let isQueued = false;
530
- let traceId = '';
531
- let sessionId = '';
532
- let agentId = '';
533
- let painEventId: number | undefined;
534
-
535
- for (const line of lines) {
536
- // KV format: "key: value"
537
- if (line.startsWith('score:')) score = parseInt(line.split(':', 2)[1].trim(), 10) || 0;
538
- if (line.startsWith('source:')) source = line.split(':', 2)[1].trim();
539
- if (line.startsWith('reason:')) reason = line.slice('reason:'.length).trim();
540
- if (line.startsWith('trigger_text_preview:')) preview = line.slice('trigger_text_preview:'.length).trim();
541
- if (line.startsWith('status: queued')) isQueued = true;
542
- if (line.startsWith('trace_id:')) traceId = line.split(':', 2)[1].trim();
543
- if (line.startsWith('session_id:')) sessionId = line.slice('session_id:'.length).trim();
544
- if (line.startsWith('agent_id:')) agentId = line.slice('agent_id:'.length).trim();
545
- if (line.startsWith('pain_event_id:')) {
546
- const raw = line.slice('pain_event_id:'.length).trim();
547
- painEventId = parseInt(raw, 10) || undefined;
548
- }
549
-
550
- // Key=Value fallback format: "key=value" (pain skill manual output)
551
- // Handles both uppercase (Source=X) and lowercase (source=x) variants
552
- if (line.startsWith('Source=') || line.startsWith('source=')) source = line.includes('Source=') ? line.slice('Source='.length).trim() : line.slice('source='.length).trim();
553
- if (line.startsWith('Reason=') || line.startsWith('reason=')) reason = line.includes('Reason=') ? line.slice('Reason='.length).trim() : line.slice('reason='.length).trim();
554
- if (line.startsWith('Score=') || line.startsWith('score=')) {
555
- const scoreStr = line.includes('Score=') ? line.slice('Score='.length).trim() : line.slice('score='.length).trim();
556
- score = parseInt(scoreStr, 10) || 0;
557
- }
558
- if (line.startsWith('Time=') || line.startsWith('time=')) {
559
- const timeStr = line.includes('Time=') ? line.slice('Time='.length).trim() : line.slice('time='.length).trim();
560
- preview = `Human intervention at ${timeStr}`;
561
- }
562
-
563
- // Markdown format support (pain skill writes **Source**: xxx format)
564
- const mdSource = /\*\*Source\*\*:\s*(.+)/.exec(line);
565
- if (mdSource) source = mdSource[1].trim();
566
- const mdReason = /\*\*Reason\*\*:\s*(.+)/.exec(line);
567
- if (mdReason) reason = mdReason[1].trim();
568
- const mdTime = /\*\*Time\*\*:\s*(.+)/.exec(line);
569
- if (mdTime) preview = `Human intervention at ${mdTime[1].trim()}`;
570
- }
571
-
572
- // Markdown format has no score — default to 50 for human intervention
573
- if (score === 0 && source !== 'unknown') score = 50;
574
-
575
- result.exists = true;
576
- result.score = score;
577
- result.source = source;
578
- result.enqueued = isQueued;
579
-
580
- if (isQueued) {
581
- result.skipped_reason = 'already_queued';
582
- if (logger) logger.info(`[PD:EvolutionWorker] Pain flag already queued (score=${score}, source=${source})`);
583
- return result;
584
- }
585
-
586
- if (logger) logger.info(`[PD:EvolutionWorker] Detected pain flag (score: ${score}, source: ${source}). Enqueueing evolution task.`);
587
-
588
- return doEnqueuePainTask(wctx, logger, painFlagPath, result, {
589
- score, source, reason, preview,
590
- traceId, sessionId, agentId, painEventId,
591
- });
592
-
593
- } catch (err) {
594
- if (logger) logger.warn(`[PD:EvolutionWorker] Error processing pain flag: ${String(err)}`);
595
- result.skipped_reason = `error: ${String(err)}`;
596
- }
597
- return result;
598
- }
599
-
600
204
  /**
601
205
  * Process compilation backfill and retry loop.
602
206
  * Phase 1 — Backfill: on first call, scan for old principles (compilationRetryCount === undefined)
@@ -757,7 +361,7 @@ function tryUpdatePrinciple(
757
361
  }
758
362
  }
759
363
 
760
- async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogger, eventLog: EventLog, api?: OpenClawPluginApi) {
364
+ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogger, _eventLog?: EventLog, _api?: OpenClawPluginApi) {
761
365
  const queuePath = wctx.resolve('EVOLUTION_QUEUE');
762
366
  if (!fs.existsSync(queuePath)) {
763
367
  logger?.debug?.('[PD:EvolutionWorker] No evolution queue file — nothing to process');
@@ -765,7 +369,6 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
765
369
  }
766
370
 
767
371
  const releaseLock = await requireQueueLock(queuePath, logger, 'processEvolutionQueue');
768
- const evoLogger = getEvolutionLogger(wctx.workspaceDir, wctx.trajectory);
769
372
  let lockReleased = false;
770
373
 
771
374
  try {
@@ -792,6 +395,14 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
792
395
  // V2: Migrate queue to current schema if needed
793
396
  let queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue) as unknown as EvolutionQueueItem[];
794
397
 
398
+ // Runtime v2 owns pain diagnosis. Drop legacy pain_diagnosis queue items so
399
+ // EvolutionWorker cannot revive the old .pain_flag -> prompt path.
400
+ const beforeLegacyPainDrop = queue.length;
401
+ queue = queue.filter((item) => item.taskKind !== 'pain_diagnosis');
402
+ if (queue.length < beforeLegacyPainDrop) {
403
+ logger?.info?.(`[PD:EvolutionWorker] Dropped ${beforeLegacyPainDrop - queue.length} legacy pain_diagnosis queue item(s); use PainSignalBridge/pd pain record`);
404
+ }
405
+
795
406
  // Validate queue items — filter out malformed entries before processing.
796
407
  // Malformed items are logged + skipped; they never crash the evolution cycle.
797
408
  const beforeValidation = queue.length;
@@ -803,7 +414,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
803
414
  if (!item.status || typeof item.status !== 'string') errors.push('missing/invalid status');
804
415
  if (!item.taskKind || typeof item.taskKind !== 'string') errors.push('missing/invalid taskKind');
805
416
  else {
806
- const validTaskKinds = ['pain_diagnosis', 'sleep_reflection', 'model_eval', 'keyword_optimization'];
417
+ const validTaskKinds = ['model_eval'];
807
418
  if (!validTaskKinds.includes(item.taskKind)) {
808
419
  errors.push(`invalid taskKind value '${item.taskKind}' (expected one of: ${validTaskKinds.join(', ')})`);
809
420
  }
@@ -821,1251 +432,12 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
821
432
  logger?.info?.(`[PD:EvolutionWorker] Filtered ${beforeValidation - queue.length} malformed queue item(s)`);
822
433
  }
823
434
 
824
- let queueChanged = rawQueue.some(isLegacyQueueItem) || queue.length < beforeValidation;
825
-
826
- // Guard: Skip keyword_optimization if one is already pending/in-progress (CORR-08)
827
- if (hasPendingTask(queue, 'keyword_optimization')) {
828
- logger?.debug?.('[PD:EvolutionWorker] keyword_optimization task already pending/in-progress, skipping enqueue');
829
- }
830
-
831
- const {config} = wctx;
832
- const timeout = config.get('intervals.task_timeout_ms') || (60 * 60 * 1000); // Default 1 hour
833
-
834
- // V2: Recover stuck in_progress sleep_reflection tasks.
835
- // If the worker crashes or the result write-back fails after Phase 1 claimed
836
- // the task, it stays in_progress indefinitely. Detect via timeout and mark
837
- // as failed so a fresh task can be enqueued on the next idle cycle.
838
- // #214: Also expire the underlying nocturnal workflow to prevent resource leaks.
839
- for (const task of queue.filter(t => t.status === 'in_progress' && t.taskKind === 'sleep_reflection')) {
840
- const startedAt = new Date(task.started_at || task.timestamp);
841
- const age = Date.now() - startedAt.getTime();
842
- if (age > timeout) {
843
- task.status = 'failed';
844
- task.completed_at = new Date().toISOString();
845
- task.resolution = 'failed_max_retries';
846
- task.retryCount = (task.retryCount ?? 0) + 1;
847
- queueChanged = true;
848
-
849
- // #219: Fetch real failure reason from workflow events for better diagnostics
850
- let detailedError = `sleep_reflection timed out after ${Math.round(timeout / 60000)} minutes`;
851
- if (task.resultRef && !task.resultRef.startsWith('trinity-draft')) {
852
- try {
853
- const wfStore = new WorkflowStore({ workspaceDir: wctx.workspaceDir });
854
- const events = wfStore.getEvents(task.resultRef);
855
- // Find the most recent failure event
856
- const failureEvent = events.filter(e =>
857
- e.event_type.includes('failed') || e.event_type.includes('error')
858
- ).pop();
859
- if (failureEvent) {
860
- const payload = validateQueueEventPayload(failureEvent.payload_json);
861
- detailedError = `sleep_reflection failed: ${failureEvent.reason}`;
862
- if (payload.skipReason) {
863
- detailedError += ` (skipReason: ${payload.skipReason})`;
864
- }
865
- if (payload.failures && Array.isArray(payload.failures) && payload.failures.length > 0) {
866
- detailedError += ` | failures: ${(payload.failures as string[]).slice(0, 3).join(', ')}`;
867
- }
868
- }
869
- } catch (fetchErr) {
870
- logger?.debug?.(`[PD:EvolutionWorker] Could not fetch workflow events for ${task.resultRef}: ${String(fetchErr)}`);
871
- }
872
- }
873
- task.lastError = detailedError;
874
-
875
- logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${task.id} timed out after ${Math.round(age / 60000)} minutes, marking as failed. Reason: ${detailedError}`);
876
- evoLogger.logCompleted({
877
- traceId: task.traceId || task.id,
878
- taskId: task.id,
879
- resolution: 'manual',
880
- durationMs: age,
881
- });
882
-
883
- // #214: Expire the underlying nocturnal workflow to prevent resource leak.
884
- // The task's resultRef holds the workflowId if one was started.
885
- if (task.resultRef && !task.resultRef.startsWith('trinity-draft')) {
886
- try {
887
- const nocturnalMgr = new NocturnalWorkflowManager({
888
- workspaceDir: wctx.workspaceDir,
889
- stateDir: wctx.stateDir,
890
- logger: api?.logger || logger,
891
-
892
- runtimeAdapter: new OpenClawTrinityRuntimeAdapter(api!),
893
- subagent: api?.runtime?.subagent,
894
- });
895
- try {
896
- // Force-expire this specific workflow regardless of TTL
897
- nocturnalMgr.expireWorkflow(
898
- task.resultRef,
899
- `Sleep reflection task ${task.id} timed out after ${Math.round(age / 60000)} min`,
900
- );
901
- logger?.info?.(`[PD:EvolutionWorker] Expired nocturnal workflow ${task.resultRef} for timed-out sleep task ${task.id}`);
902
- } finally {
903
- nocturnalMgr.dispose();
904
- }
905
- } catch (expireErr) {
906
- logger?.warn?.(`[PD:EvolutionWorker] Could not expire nocturnal workflow ${task.resultRef}: ${String(expireErr)}`);
907
- }
908
- }
909
- }
910
- }
911
-
912
- // Check in_progress tasks for completion (only pain_diagnosis gets HEARTBEAT treatment)
913
- // Diagnostician runs via HEARTBEAT (main session LLM), not as a subagent.
914
- // Marker file detection is the ONLY completion path for HEARTBEAT diagnostics.
915
- for (const task of queue.filter(t => t.status === 'in_progress' && t.taskKind === 'pain_diagnosis')) {
916
- const startedAt = new Date(task.started_at || task.timestamp);
917
-
918
- // Condition 1: Check for marker file (created by diagnostician on completion)
919
- const completeMarker = path.join(wctx.stateDir, `.evolution_complete_${task.id}`);
920
- if (fs.existsSync(completeMarker)) {
921
- if (logger) logger.info(`[PD:EvolutionWorker] Task ${task.id} completed - marker file detected`);
922
-
923
- let principlesGenerated = 0;
924
- // C: Track report success for event recording
925
- // FIX: Use reportParsed flag so reportSuccess=false when JSON is missing/garbled
926
- let reportSuccess = false;
927
- let reportParsed = false;
928
- // Create principle from the diagnostician's JSON report.
929
- const reportPath = path.join(wctx.stateDir, `.diagnostician_report_${task.id}.json`);
930
- if (fs.existsSync(reportPath)) {
931
- try {
932
- const raw = fs.readFileSync(reportPath, 'utf8');
933
- if (!raw || raw.trim().length === 0) {
934
- throw new Error('Report file is empty');
935
- }
936
- const reportData = JSON.parse(raw);
937
- if (!reportData) {
938
- throw new Error('JSON parsed but content is null/undefined');
939
- }
940
- // Report is valid JSON — mark as parsed
941
- reportParsed = true;
942
-
943
- // FIX: Validate phase completeness before accepting the report
944
- // A report missing critical phases is considered failed (not silently accepted).
945
- // The diagnostician must produce all 4 diagnostic phases.
946
- const phases = reportData?.phases || reportData?.diagnosis_report?.phases || {};
947
- const requiredPhases = [
948
- 'evidence_gathering',
949
- 'causal_chain',
950
- 'root_cause_classification',
951
- 'principle_extraction',
952
- ];
953
- const presentPhases = requiredPhases.filter(p =>
954
- phases && Object.keys(phases).length > 0 && phases[p]
955
- );
956
- if (presentPhases.length < requiredPhases.length) {
957
- const missing = requiredPhases.filter(p => !phases[p]);
958
- if (logger) logger.warn(`[PD:EvolutionWorker] Report for task ${task.id} incomplete — missing phases: ${missing.join(', ')} (present: ${presentPhases.length}/${requiredPhases.length})`);
959
- // PD-FUNNEL-1.1: Record incomplete_fields event BEFORE retry so funnel can see it.
960
- // The phase-completeness check requeues incomplete reports with continue; without
961
- // this record the funnel would have no signal for JSON-present-but-incomplete cases.
962
- if (eventLog) {
963
- eventLog.recordDiagnosticianReport({
964
- taskId: task.id,
965
- reportPath,
966
- category: 'incomplete_fields',
967
- });
968
- }
969
- // Treat as retryable failure: don't mark success, let retry logic kick in
970
- reportParsed = false;
971
- // Also delete the incomplete marker so next heartbeat re-runs the diagnostician
972
- try { fs.unlinkSync(completeMarker); } catch { /* ignore if already gone */ }
973
- task.status = 'pending';
974
- task.resolution = undefined;
975
- queueChanged = true;
976
- continue;
977
- }
978
-
979
- // ── Step 3: Noise Classification Filter ──
980
- // Skip principle creation for low-value noise categories that don't represent
981
- // systemic failures or behavioral issues worth encoding as principles.
982
- const classification = reportData?.classification;
983
- const noiseCategories: Record<string, boolean> = {
984
- 'development_transient': true, // CRLF drift, duplicate match, self-resolved dev issues
985
- 'user_error': true, // User mistakes, wrong file, bad input
986
- };
987
- if (classification?.category && noiseCategories[classification.category]) {
988
- if (logger) logger.info(`[PD:EvolutionWorker] Skipping principle for noise category "${classification.category}" — pain was ${classification.severity || 'low'} severity, not a systemic failure`);
989
- task.status = 'completed';
990
- task.completed_at = new Date().toISOString();
991
- task.resolution = 'noise_classified';
992
- } else {
993
- // Check ALL known nesting paths — matches subagent.ts parseDiagnosticianReport
994
- const principle = reportData?.principle
995
- || reportData?.phases?.principle_extraction?.principle
996
- || reportData?.diagnosis_report?.principle
997
- || reportData?.diagnosis_report?.phases?.principle_extraction?.principle;
998
- if (principle?.trigger_pattern && principle?.action) {
999
- // Check for duplicate principle (diagnostician may output existing principle)
1000
- if (principle.duplicate === true) {
1001
- logger.info(`[PD:EvolutionWorker] Diagnostician marked principle as duplicate: ${principle.duplicate_of || 'unknown'} — skipping creation for task ${task.id}`);
1002
- task.status = 'completed';
1003
- task.completed_at = new Date().toISOString();
1004
- task.resolution = 'marker_detected';
1005
- } else {
1006
- // ── Server-side dedup guard (defense against LLM ignoring duplicate check) ──
1007
- const existingPrinciples = wctx.evolutionReducer.getActivePrinciples();
1008
- let serverDuplicate: string | null = null;
1009
- if (existingPrinciples.length > 0) {
1010
- const newTrigger = (principle.trigger_pattern || '').toLowerCase();
1011
- const newAbstracted = (principle.abstracted_principle || '').toLowerCase();
1012
- for (const ep of existingPrinciples) {
1013
- const epTrigger = (ep.trigger || '').toLowerCase();
1014
- const epAbstracted = (ep.abstractedPrinciple || '').toLowerCase();
1015
- const epText = (ep.text || '').toLowerCase();
1016
-
1017
- // Check 1: abstracted principle overlap (>70% keyword match)
1018
- const newKeywords = newAbstracted.split(/\s+/).filter((w: string) => w.length > 3);
1019
- const epKeywords = epAbstracted.split(/\s+/).filter((w: string) => w.length > 3);
1020
- if (newKeywords.length > 0 && epKeywords.length > 0) {
1021
- const overlap = newKeywords.filter((k: string) => epKeywords.includes(k)).length;
1022
- const overlapRatio = overlap / Math.max(newKeywords.length, epKeywords.length);
1023
- if (overlapRatio > 0.7) {
1024
- serverDuplicate = ep.id;
1025
- break;
1026
- }
1027
- }
1028
-
1029
- // Check 2: trigger pattern contains same key terms
1030
- if (newTrigger.length > 10 && epTrigger.length > 10) {
1031
- const sharedTerms = newTrigger.split(/[\s|\\.+*?()[\]{}^$-]+/).filter((t: string) => t.length > 3);
1032
- if (sharedTerms.length > 0 && sharedTerms.every((t: string) => epTrigger.includes(t))) {
1033
- serverDuplicate = ep.id;
1034
- break;
1035
- }
1036
- }
1037
-
1038
- // Check 3: text overlap (LLM often reuses text from existing principle)
1039
- if (epText.length > 20 && newTrigger.length > 20) {
1040
- const sharedPhrases = epText.split(/\s+/).filter(w => w.length > 5);
1041
- const matchCount = sharedPhrases.filter(w => newTrigger.includes(w)).length;
1042
- if (matchCount >= 3) {
1043
- serverDuplicate = ep.id;
1044
- break;
1045
- }
1046
- }
1047
- }
1048
- }
1049
-
1050
- if (serverDuplicate) {
1051
- logger.info(`[PD:EvolutionWorker] Server-side dedup: new principle overlaps with existing ${serverDuplicate} — skipping creation for task ${task.id}`);
1052
- task.status = 'completed';
1053
- task.completed_at = new Date().toISOString();
1054
- task.resolution = 'marker_detected';
1055
- } else {
1056
- logger.info(`[PD:EvolutionWorker] Creating principle from report for task ${task.id}`);
1057
- const principleId = wctx.evolutionReducer.createPrincipleFromDiagnosis({
1058
- painId: task.painEventId !== undefined ? String(task.painEventId) : task.id,
1059
- painType: task.source === 'Human Intervention' ? 'user_frustration' : 'tool_failure',
1060
- triggerPattern: principle.trigger_pattern,
1061
- action: principle.action,
1062
- source: task.source || 'heartbeat_diagnostician',
1063
- // #212: Default to weak_heuristic so principles are auto-evaluable
1064
- // without requiring full detectorMetadata from the diagnostician.
1065
- evaluability: principle.evaluability || 'weak_heuristic',
1066
- // Review fix: Accept both snake_case and camelCase from LLM output
1067
- detectorMetadata: principle.detector_metadata || principle.detectorMetadata,
1068
- abstractedPrinciple: principle.abstracted_principle,
1069
- coreAxiomId: principle.core_axiom_id || principle.coreAxiomId,
1070
- });
1071
- if (principleId) {
1072
- logger.info(`[PD:EvolutionWorker] Created principle ${principleId} from marker fallback for task ${task.id}`);
1073
- principlesGenerated = 1;
1074
- // C: Record principle_candidate_created event for observability
1075
- if (eventLog) {
1076
- eventLog.recordPrincipleCandidate({
1077
- principleId,
1078
- taskId: task.id,
1079
- source: 'diagnostician',
1080
- });
1081
- }
1082
- } else {
1083
- logger.warn(`[PD:EvolutionWorker] createPrincipleFromDiagnosis returned null for task ${task.id} (may be duplicate or blacklisted)`);
1084
- }
1085
- task.status = 'completed';
1086
- task.completed_at = new Date().toISOString();
1087
- task.resolution = 'marker_detected';
1088
- }
1089
- }
1090
- } else {
1091
- logger.warn(`[PD:EvolutionWorker] Diagnostician report for task ${task.id} missing principle fields — diagnostician did not produce a principle`);
1092
- }
1093
- }
1094
- } catch (err) {
1095
- logger.warn(`[PD:EvolutionWorker] Failed to parse diagnostician report for task ${task.id}: ${String(err)}`);
1096
- }
1097
- // FIX: Only mark success if JSON was actually parsed and non-empty
1098
- // If JSON was missing, garbled, or empty — reportSuccess stays false
1099
- reportSuccess = reportParsed;
1100
- } else {
1101
- // ── #366: Marker exists but JSON report missing — retry logic ──
1102
- // Do NOT mark completed yet. Re-inject the task for the next heartbeat cycle.
1103
- // Read retry count from marker file content.
1104
- const MAX_REPORT_MISSING_RETRIES = 3;
1105
- let markerRetries = 0;
1106
- try {
1107
- const markerContent = fs.readFileSync(completeMarker, 'utf8');
1108
- const match = markerContent.match(/report_missing_retries:(\d+)/);
1109
- if (match) markerRetries = parseInt(match[1], 10);
1110
- } catch { /* marker may not be readable, use 0 */ }
1111
-
1112
- if (markerRetries < MAX_REPORT_MISSING_RETRIES) {
1113
- // Re-inject: keep task in queue (don't mark completed), update marker with incremented count
1114
- const newRetries = markerRetries + 1;
1115
- if (logger) logger.info(`[PD:EvolutionWorker] Task ${task.id}: marker found but report missing — re-queuing (retry ${newRetries}/${MAX_REPORT_MISSING_RETRIES})`);
1116
- // FIX: Update store's reportMissingRetries BEFORE deleting the marker.
1117
- // This ensures the store's retry count is persisted even if the
1118
- // diagnostician session crashes before re-adding the task.
1119
- await requeueDiagnosticianTask(wctx.stateDir, task.id, MAX_REPORT_MISSING_RETRIES);
1120
- // Also update the task in the main queue to keep it alive
1121
- task.status = 'pending';
1122
- task.resolution = undefined;
1123
- queueChanged = true;
1124
- // Delete the marker so the next heartbeat sees no marker
1125
- // and re-processes the task as a fresh diagnostician run.
1126
- try {
1127
- fs.unlinkSync(completeMarker);
1128
- } catch { /* ignore if already deleted */ }
1129
- // Skip the completion/unlink block below — task is still pending
1130
- continue;
1131
- } else {
1132
- // Max retries reached — accept that no report was produced
1133
- if (logger) logger.warn(`[PD:EvolutionWorker] Task ${task.id}: max retries (${MAX_REPORT_MISSING_RETRIES}) reached — marking as failed_max_retries`);
1134
- task.status = 'completed';
1135
- task.completed_at = new Date().toISOString();
1136
- task.resolution = 'failed_max_retries';
1137
- }
1138
- }
1139
-
1140
- // Only reached if JSON existed or max retries reached:
1141
- task.status = task.status || 'completed';
1142
- task.completed_at = task.completed_at || new Date().toISOString();
1143
- if (!task.resolution) task.resolution = 'marker_detected';
1144
- try {
1145
- fs.unlinkSync(completeMarker);
1146
- } catch { /* marker may have been deleted already, not critical */ }
1147
-
1148
- // #190: Clean up diagnostician report file after processing
1149
- try {
1150
- const cleanupReportPath = path.join(wctx.stateDir, `.diagnostician_report_${task.id}.json`);
1151
- if (fs.existsSync(cleanupReportPath)) fs.unlinkSync(cleanupReportPath);
1152
- } catch { /* report may not exist, not critical */ }
1153
-
1154
- // FIX (#187): Remove the task from the diagnostician task store
1155
- await completeDiagnosticianTask(wctx.stateDir, task.id);
1156
-
1157
- // C: Record diagnostician_report event for observability
1158
- if (eventLog) {
1159
- // Map to three-state category:
1160
- // - reportSuccess=true → 'success' (JSON exists, parsed, principle found)
1161
- // - reportSuccess=false, reportParsed=true → 'incomplete_fields' (JSON existed but principle missing)
1162
- // - reportSuccess=false, reportParsed=false → 'missing_json' (JSON never existed)
1163
- const reportCategory: 'success' | 'missing_json' | 'incomplete_fields' =
1164
- reportSuccess ? 'success' : reportParsed ? 'incomplete_fields' : 'missing_json';
1165
- eventLog.recordDiagnosticianReport({
1166
- taskId: task.id,
1167
- reportPath,
1168
- category: reportCategory,
1169
- });
1170
- }
1171
-
1172
- // Log to EvolutionLogger
1173
- const durationMs = task.started_at
1174
- ? Date.now() - new Date(task.started_at).getTime()
1175
- : undefined;
1176
- evoLogger.logCompleted({
1177
- traceId: task.traceId || task.id,
1178
- taskId: task.id,
1179
- resolution: task.resolution as 'marker_detected' | 'auto_completed_timeout' | 'manual' | 'late_marker_principle_created' | 'late_marker_no_principle' | 'diagnostician_timeout',
1180
- durationMs,
1181
- principlesGenerated,
1182
- });
1183
-
1184
- // Record task completion in event stats
1185
- eventLog.recordEvolutionTaskCompleted({
1186
- taskId: task.id,
1187
- taskType: task.source || 'unknown',
1188
- reason: task.reason || '',
1189
- });
1190
-
1191
- // Update evolution_tasks table
1192
- wctx.trajectory?.updateEvolutionTask?.(task.id, {
1193
- status: 'completed',
1194
- completedAt: task.completed_at,
1195
- resolution: task.resolution as 'marker_detected' | 'auto_completed_timeout' | 'manual' | 'late_marker_principle_created' | 'late_marker_no_principle' | 'diagnostician_timeout',
1196
- });
1197
-
1198
- wctx.trajectory?.recordTaskOutcome({
1199
- sessionId: task.assigned_session_key || 'heartbeat:diagnostician',
1200
- taskId: task.id,
1201
- outcome: 'ok',
1202
- summary: `Task ${task.id} completed — ${principlesGenerated} principle(s) generated (${task.resolution}).`
1203
- });
1204
- queueChanged = true;
1205
- continue;
1206
- }
1207
-
1208
- const age = Date.now() - startedAt.getTime();
1209
- if (age > timeout) {
1210
- const timeoutMinutes = Math.round(timeout / 60000);
1211
-
1212
- const timeoutCompleteMarker = path.join(wctx.stateDir, `.evolution_complete_${task.id}`);
1213
- const timeoutReportPath = path.join(wctx.stateDir, `.diagnostician_report_${task.id}.json`);
1214
-
1215
- let principlesGenerated = 0;
1216
-
1217
-
1218
- if (fs.existsSync(timeoutCompleteMarker) && fs.existsSync(timeoutReportPath)) {
1219
- if (logger) logger.info(`[PD:EvolutionWorker] Task ${task.id} timed out but marker found — creating principle anyway`);
1220
- try {
1221
- const reportData = JSON.parse(fs.readFileSync(timeoutReportPath, 'utf8'));
1222
- const principle = reportData?.principle
1223
- || reportData?.phases?.principle_extraction?.principle
1224
- || reportData?.diagnosis_report?.principle
1225
- || reportData?.diagnosis_report?.phases?.principle_extraction?.principle;
1226
- if (principle?.trigger_pattern && principle?.action) {
1227
- if (principle.duplicate === true) {
1228
- logger.info(`[PD:EvolutionWorker] Diagnostician marked principle as duplicate: ${principle.duplicate_of || 'unknown'} — skipping for task ${task.id}`);
1229
- } else {
1230
- const principleId = wctx.evolutionReducer.createPrincipleFromDiagnosis({
1231
- painId: task.painEventId !== undefined ? String(task.painEventId) : task.id,
1232
- painType: task.source === 'Human Intervention' ? 'user_frustration' : 'tool_failure',
1233
- triggerPattern: principle.trigger_pattern,
1234
- action: principle.action,
1235
- source: task.source || 'heartbeat_diagnostician',
1236
- // #212: Default to weak_heuristic so principles are auto-evaluable.
1237
- evaluability: principle.evaluability || 'weak_heuristic',
1238
- // Review fix: Accept both snake_case and camelCase from LLM output
1239
- detectorMetadata: principle.detector_metadata || principle.detectorMetadata,
1240
- abstractedPrinciple: principle.abstracted_principle,
1241
- coreAxiomId: principle.core_axiom_id || principle.coreAxiomId,
1242
- });
1243
- if (principleId) {
1244
- logger.info(`[PD:EvolutionWorker] Created principle ${principleId} from late marker for task ${task.id}`);
1245
- principlesGenerated = 1;
1246
- }
1247
- }
1248
- }
1249
- } catch (err) {
1250
- logger.warn(`[PD:EvolutionWorker] Failed to parse late diagnostician report for task ${task.id}: ${String(err)}`);
1251
- }
1252
- try { fs.unlinkSync(completeMarker); } catch { /* marker may not exist, not critical */ }
1253
- // #190: Clean up diagnostician report file
1254
- try {
1255
- const lateReportPath = path.join(wctx.stateDir, `.diagnostician_report_${task.id}.json`);
1256
- if (fs.existsSync(lateReportPath)) fs.unlinkSync(lateReportPath);
1257
- } catch { /* report may not exist, not critical */ }
1258
- task.resolution = principlesGenerated > 0 ? 'late_marker_principle_created' : 'late_marker_no_principle';
1259
- } else {
1260
- if (logger) logger.info(`[PD:EvolutionWorker] Task ${task.id} auto-completed after ${timeoutMinutes} minute timeout`);
1261
- // #190: Clean up diagnostician report file even on timeout (may have been written late)
1262
- try {
1263
- const autoTimeoutReportPath = path.join(wctx.stateDir, `.diagnostician_report_${task.id}.json`);
1264
- if (fs.existsSync(autoTimeoutReportPath)) fs.unlinkSync(autoTimeoutReportPath);
1265
- } catch { /* report may not exist, not critical */ }
1266
- task.resolution = 'auto_completed_timeout';
1267
- }
1268
-
1269
- // Critical: mark task as completed so it doesn't get re-processed
1270
- task.status = 'completed';
1271
- task.completed_at = new Date().toISOString();
1272
-
1273
- // Log to EvolutionLogger - use task.resolution, not hardcoded value
1274
- evoLogger.logCompleted({
1275
- traceId: task.traceId || task.id,
1276
- taskId: task.id,
1277
- resolution: task.resolution,
1278
- durationMs: age,
1279
- principlesGenerated,
1280
- });
1281
-
1282
- // Record task completion in event stats (for timeout path too)
1283
- eventLog.recordEvolutionTaskCompleted({
1284
- taskId: task.id,
1285
- taskType: task.source || 'unknown',
1286
- reason: task.reason || '',
1287
- });
1288
-
1289
- // Update evolution_tasks table - use task.resolution, not hardcoded value
1290
- wctx.trajectory?.updateEvolutionTask?.(task.id, {
1291
- status: 'completed',
1292
- completedAt: task.completed_at,
1293
- resolution: task.resolution,
1294
- });
1295
-
1296
- wctx.trajectory?.recordTaskOutcome({
1297
- sessionId: task.assigned_session_key || 'heartbeat:diagnostician',
1298
- taskId: task.id,
1299
- outcome: 'timeout',
1300
- summary: `Task ${task.id} completed — ${principlesGenerated} principle(s) generated (${task.resolution}).`
1301
- });
1302
- queueChanged = true;
1303
- }
1304
- }
1305
-
1306
- // V2: Process pain_diagnosis tasks FIRST (quick, inside lock),
1307
- // then sleep_reflection tasks (slow, lock released during execution).
1308
- // This order ensures pain tasks are never starved by long-running
1309
- // nocturnal reflection — sleep_reflection can safely return early
1310
- // because pain_diagnosis has already been handled.
1311
- const pendingTasks = queue.filter(t => t.status === 'pending' && t.taskKind === 'pain_diagnosis');
1312
-
1313
- if (pendingTasks.length > 0) {
1314
- // V2: Also sort by priority within same score
1315
- const priorityWeight = { high: 3, medium: 2, low: 1 };
1316
- const [highestScoreTask] = pendingTasks.sort((a, b) => {
1317
- const scoreDiff = b.score - a.score;
1318
- if (scoreDiff !== 0) return scoreDiff;
1319
- return (priorityWeight[b.priority] || 2) - (priorityWeight[a.priority] || 2);
1320
- });
1321
- const nowIso = new Date().toISOString();
1322
-
1323
- const taskDescription = `Diagnose systemic pain [ID: ${highestScoreTask.id}]. Source: ${highestScoreTask.source}. Reason: ${highestScoreTask.reason}. ` +
1324
- `Trigger text: "${highestScoreTask.trigger_text_preview || 'N/A'}"`;
1325
-
1326
- // Prepare diagnostician task content
1327
- // FIX (#187): Write diagnostician tasks to .state/diagnostician_tasks.json
1328
- // instead of HEARTBEAT.md. HEARTBEAT.md is a shared file that gets overwritten
1329
- // by the main session heartbeat, causing a race condition where the diagnostician
1330
- // task prompt is lost. The task store is in .state/ which is not modified by
1331
- // the main session.
1332
- const markerFilePath = path.join(wctx.stateDir, `.evolution_complete_${highestScoreTask.id}`);
1333
- const reportFilePath = path.join(wctx.stateDir, `.diagnostician_report_${highestScoreTask.id}.json`);
1334
-
1335
- let existingPrinciplesRef = '';
1336
- try {
1337
- const activePrinciples = wctx.evolutionReducer.getActivePrinciples();
1338
- if (activePrinciples.length > 0) {
1339
- // Include all principles up to 20 — enough for duplicate detection
1340
- // without overwhelming the context window
1341
- const maxPrinciples = 20;
1342
- const included = activePrinciples.length > maxPrinciples
1343
- ? activePrinciples.slice(-maxPrinciples)
1344
- : activePrinciples;
1345
- const lines = included.map((p) => {
1346
- let line = `### ${p.id}: ${p.text}`;
1347
- if (p.priority && p.priority !== 'P1') line += ` [${p.priority}]`;
1348
- if (p.scope === 'domain' && p.domain) line += ` (domain: ${p.domain})`;
1349
- return line;
1350
- });
1351
- existingPrinciplesRef = `\n**Existing Principles for Duplicate Detection** (showing ${included.length}/${activePrinciples.length}):\n${lines.join('\n')}`;
1352
-
1353
- // Also inject suggested rules from existing principles (if any)
1354
- const rulesByPrinciple = included.filter((p) => p.suggestedRules?.length);
1355
- if (rulesByPrinciple.length > 0) {
1356
- const ruleLines = rulesByPrinciple.flatMap((p) =>
1357
- (p.suggestedRules ?? []).map((r) => `- [${p.id}] **${r.name}**: ${r.action} (type: ${r.type}, enforce: ${r.enforcement})`),
1358
- );
1359
- existingPrinciplesRef += `\n\n**Suggested Rules from Existing Principles**:\n${ruleLines.join('\n')}`;
1360
- }
1361
- }
1362
- } catch (err) {
1363
- // #184: Log warning instead of silently swallowing — diagnostician needs
1364
- // existing principles context for duplicate detection.
1365
- logger?.warn?.(`[PD:EvolutionWorker] Failed to load active principles for duplicate detection: ${String(err)}`);
1366
- }
1367
-
1368
- // ── Context Enrichment (CTX-01): Dual-path strategy ──
1369
- // P1: OpenClaw built-in tools (sessions_history) - safe, visibility-limited
1370
- // P2: JSONL direct read - fallback when tools fail or session not visible
1371
- // The diagnostician skill implements both paths (Phase 0 protocol).
1372
- //
1373
- // Here we pre-extract JSONL context as backup and inject tool instructions.
1374
-
1375
- let contextSection = '';
1376
- if (highestScoreTask.session_id && highestScoreTask.agent_id) {
1377
- try {
1378
- const { extractRecentConversation, extractFailedToolContext } = await import('../core/pain-context-extractor.js');
1379
- const conversation = await extractRecentConversation(highestScoreTask.session_id, highestScoreTask.agent_id, 5);
1380
-
1381
- if (conversation) {
1382
- contextSection = `\n## Recent Conversation Context (pre-extracted JSONL fallback)\n\n${conversation}\n`;
1383
-
1384
- // Also try to extract failed tool context if this is a tool failure
1385
- if (highestScoreTask.source === 'tool_failure') {
1386
- const toolMatch = /Tool ([\w-]+) failed/.exec(highestScoreTask.reason);
1387
- const fileMatch = /on (.+?)(?=\s*Error:|$)/i.exec(highestScoreTask.reason);
1388
- if (toolMatch) {
1389
- const toolContext = await extractFailedToolContext(
1390
- highestScoreTask.session_id,
1391
- highestScoreTask.agent_id,
1392
- toolMatch[1],
1393
- fileMatch?.[1]?.trim(),
1394
- );
1395
- if (toolContext) {
1396
- contextSection += `\n## Failed Tool Call Context\n\n${toolContext}\n`;
1397
- }
1398
- }
1399
- }
1400
- }
1401
- if (logger) {
1402
- const turns = contextSection ? contextSection.split('\n').filter(l => l.startsWith('[User]') || l.startsWith('[Assistant]')).length : 0;
1403
- logger?.debug?.(`[PD:EvolutionWorker] Pre-extracted ${turns} conversation turns for task ${highestScoreTask.id}`);
1404
- }
1405
- } catch (e) {
1406
- if (logger) logger.warn(`[PD:EvolutionWorker] Failed to extract conversation context for task ${highestScoreTask.id}: ${String(e)}. Diagnostician will use P1 tools or fallback.`);
1407
- }
1408
- }
1409
-
1410
- const heartbeatContent = [
1411
- `## Evolution Task [ID: ${highestScoreTask.id}]`,
1412
- ``,
1413
- `**Pain Score**: ${highestScoreTask.score}`,
1414
- `**Source**: ${highestScoreTask.source}`,
1415
- `**Reason**: ${highestScoreTask.reason}`,
1416
- `**Trigger**: "${highestScoreTask.trigger_text_preview || 'N/A'}"`,
1417
- `**Queued At**: ${highestScoreTask.enqueued_at || nowIso}`,
1418
- `**Session ID**: ${highestScoreTask.session_id || 'N/A'}`,
1419
- `**Agent ID**: ${highestScoreTask.agent_id || 'main'}`,
1420
- ``,
1421
- `## Available Tools for Context Search (P1 - Preferred)`,
1422
- ``,
1423
- `1. **sessions_history** — Get full message history (requires sessionKey)`,
1424
- `2. **sessions_list** — List sessions (searches metadata only, NOT message content)`,
1425
- `3. **read_file / search_file_content** — Search codebase`,
1426
- ``,
1427
- `**P1 SOP**: sessions_history(sessionKey="agent:${highestScoreTask.agent_id || 'main'}:run:${highestScoreTask.session_id || 'N/A'}", limit=30)`,
1428
- 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.` : '',
1429
- ``,
1430
- `## Pre-extracted Context (P2 - JSONL Fallback)`,
1431
- `If OpenClaw tools cannot access the session (visibility limits),`,
1432
- `use this pre-extracted context below:`,
1433
- contextSection || `*(No JSONL context available — use P1 tools first)*`,
1434
- ``,
1435
- `---`,
1436
- ``,
1437
- `## Diagnostician Protocol`,
1438
- ``,
1439
- `You MUST use the **pd-diagnostician** skill for this task.`,
1440
- `Read the full skill definition and follow the protocol EXACTLY as specified: Phase 0 (context extraction, optional) → Phase 1 (Evidence) → Phase 2 (Causal Chain) → Phase 3 (Classification) → Phase 4 (Principle Extraction).`,
1441
- `The skill defines the complete output contract — your JSON report MUST match the format specified in the skill.`,
1442
- ``,
1443
- `---`,
1444
- ``,
1445
- `After completing the analysis:`,
1446
- `1. Write your JSON diagnosis report to: ${reportFilePath}`,
1447
- ` The JSON structure MUST match the output format defined in the pd-diagnostician skill.`,
1448
- `2. Mark the task complete by creating a marker file: ${markerFilePath}`,
1449
- ` The marker file should contain: "diagnostic_completed: <timestamp>\\noutcome: <summary>"`,
1450
- `3. After writing both files, reply with "DIAGNOSTICIAN_DONE: ${highestScoreTask.id}"`,
1451
- existingPrinciplesRef,
1452
- ].join('\n');
1453
-
1454
- // FIX (#187): Write to diagnostician_tasks.json instead of HEARTBEAT.md
1455
- // HEARTBEAT.md is a shared file that gets overwritten by the main session
1456
- // heartbeat, causing a race condition. The task store is in .state/ and is
1457
- // not modified by the main session.
1458
- try {
1459
- await addDiagnosticianTask(wctx.stateDir, highestScoreTask.id, heartbeatContent);
1460
- if (logger) logger.info(`[PD:EvolutionWorker] Wrote diagnostician task to diagnostician_tasks.json for task ${highestScoreTask.id}`);
1461
-
1462
- // C: Record diagnosis_task_written event for observability
1463
- if (eventLog) {
1464
- eventLog.recordDiagnosisTask({
1465
- taskId: highestScoreTask.id,
1466
- painEventId: highestScoreTask.painEventId !== undefined ? String(highestScoreTask.painEventId) : undefined,
1467
- sessionId: highestScoreTask.session_id,
1468
- });
1469
- }
1470
-
1471
- // Task store write succeeded, now mark task as in_progress
1472
- highestScoreTask.task = taskDescription;
1473
- highestScoreTask.status = 'in_progress';
1474
- highestScoreTask.started_at = nowIso;
1475
- delete highestScoreTask.completed_at;
1476
- // Use placeholder so marker path can correlate task (no subagent spawned for HEARTBEAT)
1477
- // This fixes task_outcomes being empty for HEARTBEAT-triggered diagnostician runs
1478
- highestScoreTask.assigned_session_key = `heartbeat:diagnostician:${highestScoreTask.id}`;
1479
- queueChanged = true;
1480
-
1481
- // Log to EvolutionLogger
1482
- evoLogger.logStarted({
1483
- traceId: highestScoreTask.traceId || highestScoreTask.id,
1484
- taskId: highestScoreTask.id,
1485
- });
1486
-
1487
- // Update evolution_tasks table
1488
- wctx.trajectory?.updateEvolutionTask?.(highestScoreTask.id, {
1489
- status: 'in_progress',
1490
- startedAt: nowIso,
1491
- });
1492
-
1493
- if (eventLog) {
1494
- eventLog.recordEvolutionTask({
1495
- taskId: highestScoreTask.id,
1496
- taskType: highestScoreTask.source,
1497
- reason: highestScoreTask.reason
1498
- });
1499
- }
1500
- } catch (heartbeatErr) {
1501
- // Diagnostician task store write failed - keep task as pending for next cycle retry
1502
- if (logger) logger.error(`[PD:EvolutionWorker] Failed to write diagnostician task for task ${highestScoreTask.id}: ${String(heartbeatErr)}. Task will remain pending for next cycle.`);
1503
- SystemLogger.log(wctx.workspaceDir, 'DIAGNOSTICIAN_TASK_WRITE_FAILED', `Task ${highestScoreTask.id} diagnostician task write failed: ${String(heartbeatErr)}`);
1504
- }
1505
- }
1506
-
1507
- // Phase 2.4: Process sleep_reflection tasks AFTER pain_diagnosis.
1508
- // Claim tasks inside the lock, execute reflection outside the lock,
1509
- // then re-acquire the lock to write results. This prevents the long-running
1510
- // nocturnal reflection from blocking all other queue consumers.
1511
- // Safe to return early here because pain_diagnosis was already handled above.
1512
-
1513
- // FIX: Also poll in_progress tasks that were started in a previous cycle.
1514
- // Previously only 'pending' tasks were filtered, so an in_progress task from
1515
- // a previous heartbeat cycle would never be re-polled until the 1-hour
1516
- // stuck task recovery kicked in.
1517
- const pendingSleepTasks = queue.filter(t => t.status === 'pending' && t.taskKind === 'sleep_reflection');
1518
- const pollingSleepTasks = queue.filter(t =>
1519
- t.status === 'in_progress' && t.taskKind === 'sleep_reflection' && t.resultRef && !t.resultRef.startsWith('trinity-draft')
1520
- );
1521
- let sleepReflectionTasks = [...pendingSleepTasks, ...pollingSleepTasks];
1522
- // Phase 40: Check if sleep_reflection is in cooldown due to persistent failures
1523
- const sleepCooldown = isTaskKindInCooldown(wctx.stateDir, 'sleep_reflection');
1524
- if (sleepCooldown.inCooldown) {
1525
- logger?.info?.(`[PD:EvolutionWorker] sleep_reflection in cooldown (remaining ${Math.round(sleepCooldown.remainingMs / 60000)}min), skipping task processing`);
1526
- sleepReflectionTasks = [];
1527
- }
1528
- if (sleepReflectionTasks.length > 0) {
1529
- // --- Phase 1: Claim only pending tasks (inside lock) ---
1530
- // in_progress tasks from previous cycles are already claimed, don't re-claim them
1531
- for (const sleepTask of pendingSleepTasks) {
1532
- sleepTask.status = 'in_progress';
1533
- sleepTask.started_at = new Date().toISOString();
1534
- }
1535
- queueChanged = queueChanged || pendingSleepTasks.length > 0;
1536
-
1537
- // Write claimed state (includes any pain changes from above) and release lock
1538
- if (queueChanged) {
1539
- saveEvolutionQueue(queuePath, queue);
1540
- }
1541
- releaseLock();
1542
- // Phase 40: Track outcomes for failure classification after queue write
1543
- const sleepOutcomes: Array<{ taskKind: ClassifiableTaskKind; succeeded: boolean }> = [];
1544
- for (const sleepTask of sleepReflectionTasks) {
1545
- try {
1546
- // FIX: For in_progress tasks from a previous cycle, just poll the workflow.
1547
- // Don't start a new workflow — that was already done when the task was first claimed.
1548
- const isPollingTask = !!sleepTask.resultRef && !sleepTask.resultRef.startsWith('trinity-draft');
1549
-
1550
- if (isPollingTask) {
1551
- logger?.debug?.(`[PD:EvolutionWorker] Polling existing sleep_reflection task ${sleepTask.id} (workflowId: ${sleepTask.resultRef})`);
1552
- } else {
1553
- logger?.info?.(`[PD:EvolutionWorker] Processing sleep_reflection task ${sleepTask.id}`);
1554
- }
1555
-
1556
-
1557
- let workflowId: string | undefined;
1558
-
1559
-
1560
- let nocturnalManager: NocturnalWorkflowManager;
1561
-
1562
-
1563
- let snapshotData: NocturnalSessionSnapshot | undefined;
1564
-
1565
- if (isPollingTask) {
1566
-
1567
- workflowId = sleepTask.resultRef!;
1568
- } else {
1569
- // Phase 1: Build trajectory snapshot for Nocturnal pipeline
1570
- // Priority: Pain signal sessionId → Task ID → Recent session with violations
1571
- let extractor: ReturnType<typeof createNocturnalTrajectoryExtractor> | null = null;
1572
- try {
1573
- extractor = createNocturnalTrajectoryExtractor(wctx.workspaceDir);
1574
-
1575
- // 1. Try exact session ID from pain signal (most accurate)
1576
- const painSessionId = sleepTask.recentPainContext?.mostRecent?.sessionId;
1577
- let fullSnapshot = painSessionId ? extractor.getNocturnalSessionSnapshot(painSessionId) : undefined;
1578
- if (fullSnapshot) {
1579
- logger?.info?.(`[PD:EvolutionWorker] Task ${sleepTask.id} using exact session from pain signal: ${painSessionId}`);
1580
- }
1581
-
1582
- // 2. Try task ID (legacy compatibility, rarely matches)
1583
- if (!fullSnapshot) {
1584
- fullSnapshot = extractor.getNocturnalSessionSnapshot(sleepTask.id);
1585
- }
1586
-
1587
- // 3. If no match, find most recent session WITH violation signals
1588
- if (!fullSnapshot) {
1589
- const taskTimeMs = new Date(sleepTask.enqueued_at || sleepTask.timestamp).getTime();
1590
- const recentSessions = extractor.listRecentNocturnalCandidateSessions({
1591
- limit: 20,
1592
- minToolCalls: 1,
1593
- dateTo: sleepTask.enqueued_at || sleepTask.timestamp,
1594
- }).filter((session) => isSessionAtOrBeforeTriggerTime(session, taskTimeMs));
1595
- // Filter to sessions with actual violations (pain, failures, or gate blocks)
1596
- const sessionsWithViolations = recentSessions.filter(
1597
- s => s.failureCount > 0 || s.painEventCount > 0 || s.gateBlockCount > 0
1598
- );
1599
- if (sessionsWithViolations.length > 0) {
1600
-
1601
- const targetSession = sessionsWithViolations[0];
1602
- logger?.info?.(`[PD:EvolutionWorker] Task ${sleepTask.id} using session with violations: ${targetSession.sessionId} (failed=${targetSession.failureCount}, pain=${targetSession.painEventCount}, gates=${targetSession.gateBlockCount})`);
1603
- fullSnapshot = extractor.getNocturnalSessionSnapshot(targetSession.sessionId);
1604
- } else if (recentSessions.length > 0) {
1605
- // No sessions with violations, use most recent as last resort
1606
-
1607
- const latestSession = recentSessions[0];
1608
- 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})`);
1609
- fullSnapshot = extractor.getNocturnalSessionSnapshot(latestSession.sessionId);
1610
- } else {
1611
- logger?.warn?.(`[PD:EvolutionWorker] Task ${sleepTask.id} no sessions with tool calls in trajectory DB`);
1612
- }
1613
- }
1614
-
1615
- if (fullSnapshot) {
1616
- snapshotData = fullSnapshot;
1617
- }
1618
- } catch (snapErr) {
1619
- logger?.warn?.(`[PD:EvolutionWorker] Failed to build trajectory snapshot for ${sleepTask.id}: ${String(snapErr)}`);
1620
- }
1621
-
1622
- // Phase 2: If no trajectory data, try pain-context fallback
1623
- if (!snapshotData && sleepTask.recentPainContext) {
1624
- logger?.warn?.(`[PD:EvolutionWorker] Using pain-context fallback for ${sleepTask.id}: trajectory snapshot unavailable, will try session summary from extractor`);
1625
- snapshotData = buildFallbackNocturnalSnapshot(sleepTask, extractor, logger) ?? undefined;
1626
- }
1627
-
1628
- const snapshotValidation = validateNocturnalSnapshotIngress(snapshotData);
1629
- if (snapshotValidation.status !== 'valid') {
1630
- sleepTask.status = 'failed';
1631
- sleepTask.completed_at = new Date().toISOString();
1632
- sleepTask.resolution = 'failed_max_retries';
1633
- sleepOutcomes.push({ taskKind: 'sleep_reflection', succeeded: false });
1634
- sleepTask.lastError = `sleep_reflection failed: invalid_snapshot_ingress (${snapshotValidation.reasons.join('; ') || 'missing snapshot'})`;
1635
- sleepTask.retryCount = (sleepTask.retryCount ?? 0) + 1;
1636
- logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} rejected: ${sleepTask.lastError}`);
1637
- continue;
1638
- }
1639
-
1640
- snapshotData = snapshotValidation.snapshot;
1641
- }
1642
-
1643
- if (!api) {
1644
- sleepTask.status = 'failed';
1645
- sleepTask.completed_at = new Date().toISOString();
1646
- sleepTask.resolution = 'failed_max_retries';
1647
- sleepTask.lastError = 'No API available to create NocturnalWorkflowManager';
1648
- sleepTask.retryCount = (sleepTask.retryCount ?? 0) + 1;
1649
- sleepOutcomes.push({ taskKind: 'sleep_reflection', succeeded: false });
1650
- logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} skipped: no API`);
1651
- continue;
1652
- }
1653
-
1654
- nocturnalManager = new NocturnalWorkflowManager({
1655
- workspaceDir: wctx.workspaceDir,
1656
- stateDir: wctx.stateDir,
1657
- logger: api.logger,
1658
- runtimeAdapter: new OpenClawTrinityRuntimeAdapter(api),
1659
- subagent: api.runtime.subagent,
1660
- });
1661
-
1662
- if (!isPollingTask) {
1663
- const workflowHandle = await nocturnalManager.startWorkflow(nocturnalWorkflowSpec, {
1664
- parentSessionId: `sleep_reflection:${sleepTask.id}`,
1665
- workspaceDir: wctx.workspaceDir,
1666
- taskInput: {},
1667
- metadata: {
1668
- snapshot: snapshotData,
1669
- taskId: sleepTask.id,
1670
- painContext: sleepTask.recentPainContext,
1671
- triggerSource: sleepTask.source,
1672
- // #297: Configure which preflight gates to skip.
1673
- // sleep_reflection uses periodic trigger which bypasses idle by design.
1674
- skipPreflightGates: ['idle'],
1675
- },
1676
- });
1677
- sleepTask.resultRef = workflowHandle.workflowId;
1678
-
1679
- workflowId = workflowHandle.workflowId;
1680
- }
1681
-
1682
- if (!workflowId) {
1683
- sleepTask.status = 'failed';
1684
- sleepTask.completed_at = new Date().toISOString();
1685
- sleepTask.resolution = 'failed_max_retries';
1686
- sleepTask.lastError = 'sleep_reflection failed: missing_workflow_id';
1687
- sleepTask.retryCount = (sleepTask.retryCount ?? 0) + 1;
1688
- sleepOutcomes.push({ taskKind: 'sleep_reflection', succeeded: false });
1689
- logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} missing workflow id after startup`);
1690
- continue;
1691
- }
1692
-
1693
- // Workflow is running asynchronously. Check if it completed in this cycle
1694
- // by polling getWorkflowDebugSummary.
1695
- const summary = await nocturnalManager.getWorkflowDebugSummary(workflowId);
1696
- if (summary) {
1697
- if (summary.state === 'completed') {
1698
- sleepTask.status = 'completed';
1699
- sleepTask.completed_at = new Date().toISOString();
1700
- sleepTask.resolution = 'marker_detected';
1701
- sleepTask.resultRef = summary.metadata?.nocturnalResult ? 'trinity-draft' : workflowId;
1702
- sleepOutcomes.push({ taskKind: 'sleep_reflection', succeeded: true });
1703
- logger?.info?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} workflow completed`);
1704
- } else if (summary.state === 'terminal_error') {
1705
- // #208/#209: Classify terminal_error reason before hardcoding to failed.
1706
- // The async executeNocturnalReflectionAsync catches subagent errors and
1707
- // records them as terminal_error. Without this check, expected errors
1708
- // (daemon mode, process isolation) would always become failed_max_retries.
1709
- const lastEvent = summary.recentEvents[summary.recentEvents.length - 1];
1710
- const errorReason = lastEvent?.reason ?? 'unknown';
1711
- // #219: Include payload details for better diagnostics
1712
- let detailedError = `Workflow terminal_error: ${errorReason}`;
1713
-
1714
- let payload: unknown = {};
1715
-
1716
- try {
1717
- payload = lastEvent?.payload ?? {};
1718
-
1719
- if ((payload as any).skipReason) {
1720
-
1721
- detailedError += ` (skipReason: ${(payload as any).skipReason})`;
1722
-
1723
- }
1724
-
1725
- if ((payload as any).failures && Array.isArray((payload as any).failures) && (payload as any).failures.length > 0) {
1726
-
1727
- detailedError += ` | failures: ${((payload as any).failures as string[]).slice(0, 3).join(', ')}`;
1728
- }
1729
- } catch { /* ignore parse errors */ }
1730
- sleepTask.lastError = detailedError;
1731
- sleepTask.retryCount = (sleepTask.retryCount ?? 0) + 1;
1732
-
1733
- if (isExpectedSubagentError(errorReason)) {
1734
- // #237: Expected unavailability → stub fallback, not hard failure
1735
- sleepTask.status = 'completed';
1736
-
1737
- sleepTask.completed_at = new Date().toISOString();
1738
- sleepTask.resolution = 'stub_fallback';
1739
- sleepOutcomes.push({ taskKind: 'sleep_reflection', succeeded: true });
1740
-
1741
- logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} background runtime unavailable, using stub fallback: ${errorReason}`);
1742
-
1743
- } else if ((payload as any).skipReason === 'no_violating_sessions') {
1744
- // #244: No meaningful violations found (thin filter) → skip without failure
1745
- sleepTask.status = 'completed';
1746
- sleepTask.completed_at = new Date().toISOString();
1747
- sleepTask.resolution = 'skipped_thin_violation';
1748
- sleepOutcomes.push({ taskKind: 'sleep_reflection', succeeded: true });
1749
- logger?.info?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} completed: no sessions with meaningful violations found`);
1750
- } else {
1751
- sleepTask.status = 'failed';
1752
- sleepTask.completed_at = new Date().toISOString();
1753
- sleepTask.resolution = 'failed_max_retries';
1754
- sleepOutcomes.push({ taskKind: 'sleep_reflection', succeeded: false });
1755
- logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} workflow failed: ${sleepTask.lastError}`);
1756
- }
1757
- } else {
1758
- // Workflow still active, keep task in_progress for next cycle
1759
- logger?.info?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} workflow ${summary.state}, will poll again next cycle`);
1760
- }
1761
- }
1762
- } catch (taskErr) {
1763
- // #202: Handle expected subagent unavailability (e.g., process isolation in daemon mode)
1764
- // When subagent is unavailable due to gateway running in separate process,
1765
- // use stub fallback instead of failing the task.
1766
- sleepTask.completed_at = new Date().toISOString();
1767
- sleepTask.lastError = String(taskErr);
1768
- sleepTask.retryCount = (sleepTask.retryCount ?? 0) + 1;
1769
-
1770
- if (isExpectedSubagentError(taskErr)) {
1771
- // #237: Expected unavailability → stub fallback, not hard failure
1772
- sleepTask.status = 'completed';
1773
- sleepTask.completed_at = new Date().toISOString();
1774
- sleepTask.resolution = 'stub_fallback';
1775
- sleepOutcomes.push({ taskKind: 'sleep_reflection', succeeded: true });
1776
- logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} background runtime unavailable, using stub fallback: ${String(taskErr)}`);
1777
- } else {
1778
- sleepTask.status = 'failed';
1779
- sleepTask.completed_at = new Date().toISOString();
1780
- sleepTask.resolution = 'failed_max_retries';
1781
- sleepOutcomes.push({ taskKind: 'sleep_reflection', succeeded: false });
1782
- logger?.error?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} threw: ${taskErr}`);
1783
- }
1784
- }
1785
- }
1786
-
1787
- // --- Phase 3: Write results back (re-acquire lock) ---
1788
- try {
1789
- const resultLock = await requireQueueLock(queuePath, logger, 'sleepReflectionResult');
1790
- try {
1791
- // Re-read queue to merge with any changes made while lock was released
1792
- let freshQueue: (RawQueueItem | EvolutionQueueItem)[] = [];
1793
- try {
1794
- freshQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
1795
- } catch { /* empty queue if corrupted */ }
1796
-
1797
- // Merge: update tasks by ID
1798
- for (const sleepTask of sleepReflectionTasks) {
1799
- const idx = freshQueue.findIndex((t) => (t as { id?: string }).id === sleepTask.id);
1800
- if (idx >= 0) {
1801
- freshQueue[idx] = sleepTask;
1802
- }
1803
- }
1804
- atomicWriteFileSync(queuePath, JSON.stringify(freshQueue, null, 2));
1805
-
1806
- // Log completions to EvolutionLogger
1807
- for (const sleepTask of sleepReflectionTasks) {
1808
- if (sleepTask.status === 'completed' || sleepTask.status === 'failed') {
1809
- evoLogger.logCompleted({
1810
- traceId: sleepTask.traceId || sleepTask.id,
1811
- taskId: sleepTask.id,
1812
- resolution: sleepTask.status === 'completed'
1813
- ? (sleepTask.resolution === 'marker_detected' ? 'marker_detected' : 'manual')
1814
- : 'manual',
1815
- durationMs: sleepTask.started_at
1816
- ? Date.now() - new Date(sleepTask.started_at).getTime()
1817
- : undefined,
1818
- });
1819
- }
1820
- }
1821
- } finally {
1822
- resultLock();
1823
- }
1824
- } catch (resultLockErr) {
1825
- // If we can't re-acquire lock, results are in memory but not persisted.
1826
- // Tasks will appear stuck as in_progress and will be retried on next cycle.
1827
- logger?.warn?.(`[PD:EvolutionWorker] Failed to write sleep_reflection results back: ${String(resultLockErr)}`);
1828
- }
1829
-
1830
- // Phase 40: Process failure classification — evaluate once per taskKind,
1831
- // not per-outcome, to prevent tier escalation from firing N times for N failures.
1832
- try {
1833
- const hadAnySuccess = sleepOutcomes.some(o => o.succeeded);
1834
- const hadAnyFailure = sleepOutcomes.some(o => !o.succeeded);
1835
- if (hadAnySuccess) {
1836
- await resetFailureState(wctx.stateDir, 'sleep_reflection');
1837
- }
1838
- if (hadAnyFailure) {
1839
- const config = loadCooldownEscalationConfig(wctx.stateDir);
1840
- const result = classifyFailure(queue, 'sleep_reflection', config.consecutive_threshold);
1841
- if (result.classification === 'persistent') {
1842
- await recordPersistentFailure(wctx.stateDir, 'sleep_reflection', config, result.consecutiveFailures);
1843
- logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection persistent failure (${result.consecutiveFailures} consecutive), escalating cooldown`);
1844
- }
1845
- }
1846
- } catch { /* classification errors are non-blocking */ }
1847
-
1848
- // Safe to return — pain_diagnosis was already processed above.
1849
- // keyword_optimization tasks are deferred to the next heartbeat cycle.
1850
- // Running both in the same cycle causes stale queue overwrite and
1851
- // double lock release (lock was released at line ~1703).
1852
- lockReleased = true;
1853
- return;
1854
- }
1855
-
1856
- // ── keyword_optimization task processing ──────────────────────────────
1857
- // Process keyword_optimization tasks independently of sleep_reflection.
1858
- // Uses CorrectionObserverWorkflowManager to dispatch LLM subagent and
1859
- // KeywordOptimizationService to apply mutations to keyword store (CORR-09).
1860
- const pendingKeywordOptTasks = queue.filter(t => t.status === 'pending' && t.taskKind === 'keyword_optimization');
1861
- const inProgressKeywordOptTasks = queue.filter(t =>
1862
- t.status === 'in_progress' &&
1863
- t.taskKind === 'keyword_optimization' &&
1864
- t.resultRef &&
1865
- !t.resultRef.startsWith('trinity-draft')
1866
- );
1867
- const keywordOptTasks = [...pendingKeywordOptTasks, ...inProgressKeywordOptTasks];
1868
- // Phase 40: Check if keyword_optimization is in cooldown due to persistent failures
1869
- const kwOptCooldown = isTaskKindInCooldown(wctx.stateDir, 'keyword_optimization');
1870
- if (kwOptCooldown.inCooldown) {
1871
- logger?.info?.(`[PD:EvolutionWorker] keyword_optimization in cooldown (remaining ${Math.round(kwOptCooldown.remainingMs / 60000)}min), skipping task processing`);
1872
- if (keywordOptTasks.length > 0) {
1873
- // Skip all keyword_optimization tasks this cycle; release lock and return
1874
- if (queueChanged) {
1875
- saveEvolutionQueue(queuePath, queue);
1876
- }
1877
- releaseLock();
1878
- lockReleased = true;
1879
- return;
1880
- }
1881
- }
1882
- if (keywordOptTasks.length > 0) {
1883
- // Claim pending tasks inside lock
1884
- for (const koTask of pendingKeywordOptTasks) {
1885
- koTask.status = 'in_progress';
1886
- koTask.started_at = new Date().toISOString();
1887
- }
1888
- queueChanged = queueChanged || pendingKeywordOptTasks.length > 0;
1889
-
1890
- // Release lock during LLM dispatch (long-running)
1891
- saveEvolutionQueue(queuePath, queue);
1892
- releaseLock();
1893
- lockReleased = true;
1894
-
1895
- // Phase 40: Track outcomes for failure classification after queue write
1896
- const kwOptOutcomes: Array<{ taskKind: ClassifiableTaskKind; succeeded: boolean }> = [];
1897
- for (const koTask of keywordOptTasks) {
1898
- const isPolling = !!koTask.resultRef && !koTask.resultRef.startsWith('trinity-draft');
1899
-
1900
- if (isPolling) {
1901
- logger?.debug?.(`[PD:EvolutionWorker] Polling existing keyword_optimization task ${koTask.id}`);
1902
- } else {
1903
- logger?.info?.(`[PD:EvolutionWorker] Processing keyword_optimization task ${koTask.id}`);
1904
- }
1905
-
1906
- try {
1907
- // Build trajectoryHistory via KeywordOptimizationService
1908
- const koService = KeywordOptimizationService.get(wctx.stateDir, wctx.workspaceDir, logger);
1909
- const db = TrajectoryRegistry.get(wctx.workspaceDir);
1910
- const recentSessionIds = db.listRecentSessions({ limit: 10 }).map(s => s.sessionId);
1911
- const trajectoryHistory = await koService.buildTrajectoryHistory(recentSessionIds);
1912
-
1913
- // Build full payload (CORR-09, D-40-07, D-40-08)
1914
- const learner = CorrectionCueLearner.get(wctx.stateDir);
1915
- const store = learner.getStore();
1916
- const payload: CorrectionObserverPayload = {
1917
- workspaceDir: wctx.workspaceDir,
1918
- parentSessionId: `keyword_optimization:${koTask.id}`,
1919
- keywordStoreSummary: {
1920
- totalKeywords: store.keywords.length,
1921
- terms: store.keywords.map(k => ({
1922
- term: k.term,
1923
- weight: k.weight,
1924
- hitCount: k.hitCount ?? 0,
1925
- truePositiveCount: k.truePositiveCount ?? 0,
1926
- falsePositiveCount: k.falsePositiveCount ?? 0,
1927
- })),
1928
- },
1929
- recentMessages: [],
1930
- trajectoryHistory,
1931
- };
1932
-
1933
- // Dispatch LLM subagent via CorrectionObserverWorkflowManager
1934
- const manager = new CorrectionObserverWorkflowManager({
1935
- workspaceDir: wctx.workspaceDir,
1936
- logger,
1937
- subagent: api?.runtime?.subagent!,
1938
- agentSession: api?.runtime?.agent?.session,
1939
- });
1940
-
1941
- let workflowId: string | undefined;
1942
- if (!isPolling) {
1943
- const handle = await manager.startWorkflow(correctionObserverWorkflowSpec, {
1944
- parentSessionId: `keyword_optimization:${koTask.id}`,
1945
- workspaceDir: wctx.workspaceDir,
1946
- taskInput: payload,
1947
- });
1948
- workflowId = handle.workflowId;
1949
- koTask.resultRef = workflowId;
1950
- } else {
1951
- workflowId = koTask.resultRef!;
1952
- }
1953
-
1954
- // Poll workflow state
1955
- const summary = await manager.getWorkflowDebugSummary(workflowId);
1956
- if (summary) {
1957
- if (summary.state === 'completed') {
1958
- // Get parsed LLM result and apply mutations to keyword store (CORR-09)
1959
- const parsedResult = await manager.getWorkflowResult(workflowId);
1960
-
1961
- if (parsedResult?.updated) {
1962
- koService.applyResult(parsedResult);
1963
- await learner.recordOptimizationPerformed();
1964
- logger?.info?.(`[PD:EvolutionWorker] keyword_optimization applied mutations: ${parsedResult.summary}`);
1965
- } else {
1966
- logger?.info?.(`[PD:EvolutionWorker] keyword_optimization completed with no updates`);
1967
- }
1968
-
1969
- koTask.status = 'completed';
1970
- koTask.completed_at = new Date().toISOString();
1971
- koTask.resolution = 'marker_detected';
1972
- kwOptOutcomes.push({ taskKind: 'keyword_optimization', succeeded: true });
1973
- // CORR-08: Record throttle quota (max 4/day)
1974
- recordCooldown(wctx.stateDir).catch(err =>
1975
- logger?.warn?.(`[PD:EvolutionWorker] recordCooldown failed (non-blocking): ${String(err)}`)
1976
- );
1977
- logger?.info?.(`[PD:EvolutionWorker] keyword_optimization task ${koTask.id} workflow completed`);
1978
- } else if (summary.state === 'terminal_error') {
1979
- koTask.status = 'failed';
1980
- koTask.completed_at = new Date().toISOString();
1981
- koTask.resolution = 'failed_max_retries';
1982
- kwOptOutcomes.push({ taskKind: 'keyword_optimization', succeeded: false });
1983
- koTask.retryCount = (koTask.retryCount ?? 0) + 1;
1984
- const lastEvent = summary.recentEvents[summary.recentEvents.length - 1];
1985
- koTask.lastError = `keyword_optimization failed: ${lastEvent?.reason ?? 'unknown'}`;
1986
- logger?.warn?.(`[PD:EvolutionWorker] keyword_optimization task ${koTask.id} workflow terminal_error: ${koTask.lastError}`);
1987
- } else {
1988
- logger?.info?.(`[PD:EvolutionWorker] keyword_optimization task ${koTask.id} workflow ${summary.state}, will poll again next cycle`);
1989
- }
1990
- }
1991
- } catch (koErr) {
1992
- koTask.status = 'failed';
1993
- koTask.completed_at = new Date().toISOString();
1994
- koTask.resolution = 'failed_max_retries';
1995
- kwOptOutcomes.push({ taskKind: 'keyword_optimization', succeeded: false });
1996
- koTask.lastError = String(koErr);
1997
- koTask.retryCount = (koTask.retryCount ?? 0) + 1;
1998
- logger?.error?.(`[PD:EvolutionWorker] keyword_optimization task ${koTask.id} threw: ${koErr}`);
1999
- }
2000
- }
2001
-
2002
- // Re-acquire lock to write results
2003
- const koResultLock = await requireQueueLock(queuePath, logger, 'keywordOptResult');
2004
- try {
2005
- let freshQueue: (RawQueueItem | EvolutionQueueItem)[] = [];
2006
- try {
2007
- freshQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
2008
- } catch (readErr) {
2009
- // Queue file corrupted — log warning but preserve in-memory task state
2010
- logger?.warn?.(`[PD:EvolutionWorker] Queue file corrupted (${String(readErr)}), preserving in-memory state`);
2011
- freshQueue = [];
2012
- }
2013
-
2014
- // Append or replace keyword_optimization tasks
2015
- for (const koTask of keywordOptTasks) {
2016
- const idx = freshQueue.findIndex((t) => (t as { id?: string }).id === koTask.id);
2017
- if (idx >= 0) {
2018
- freshQueue[idx] = koTask;
2019
- } else {
2020
- freshQueue.push(koTask);
2021
- }
2022
- }
2023
- atomicWriteFileSync(queuePath, JSON.stringify(freshQueue, null, 2));
2024
- } catch (koResultErr) {
2025
- logger?.warn?.(`[PD:EvolutionWorker] Failed to write keyword_optimization results: ${String(koResultErr)}`);
2026
- } finally {
2027
- koResultLock();
2028
- }
2029
-
2030
- // Phase 40: Process failure classification — evaluate once per taskKind
2031
- try {
2032
- const hadAnySuccess = kwOptOutcomes.some(o => o.succeeded);
2033
- const hadAnyFailure = kwOptOutcomes.some(o => !o.succeeded);
2034
- if (hadAnySuccess) {
2035
- await resetFailureState(wctx.stateDir, 'keyword_optimization');
2036
- }
2037
- if (hadAnyFailure) {
2038
- const config = loadCooldownEscalationConfig(wctx.stateDir);
2039
- const freshQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8')) as EvolutionQueueItem[];
2040
- const result = classifyFailure(freshQueue, 'keyword_optimization', config.consecutive_threshold);
2041
- if (result.classification === 'persistent') {
2042
- await recordPersistentFailure(wctx.stateDir, 'keyword_optimization', config, result.consecutiveFailures);
2043
- logger?.warn?.(`[PD:EvolutionWorker] keyword_optimization persistent failure (${result.consecutiveFailures} consecutive), escalating cooldown`);
2044
- }
2045
- }
2046
- } catch { /* classification errors are non-blocking */ }
2047
-
2048
- return;
2049
- }
435
+ let queueChanged = rawQueue.some(isLegacyQueueItem) || queue.length < beforeLegacyPainDrop || queue.length < beforeValidation;
2050
436
 
2051
437
  if (queueChanged) {
2052
438
  saveEvolutionQueue(queuePath, queue);
2053
439
  }
2054
440
 
2055
- // Pipeline observability: log stage-level summary at end of cycle
2056
- const pendingPain = queue.filter((t) => t.status === 'pending' && t.taskKind === 'pain_diagnosis').length;
2057
- const inProgressPain = queue.filter((t) => t.status === 'in_progress' && t.taskKind === 'pain_diagnosis').length;
2058
- if (inProgressPain > 0) {
2059
- const stuck = queue
2060
- .filter((t) => t.status === 'in_progress' && t.taskKind === 'pain_diagnosis')
2061
- .map((t) => `${t.id} (since ${t.started_at || 'unknown'})`);
2062
- logger?.info?.(`[PD:EvolutionWorker] Pipeline: ${inProgressPain} pain_diagnosis task(s) in_progress — awaiting agent response: ${stuck.join(', ')}`);
2063
- }
2064
- if (pendingPain > 0) {
2065
- logger?.info?.(`[PD:EvolutionWorker] Pipeline: ${pendingPain} pain_diagnosis task(s) pending — HEARTBEAT.md will trigger next cycle`);
2066
- }
2067
- const painCompleted = queue.filter((t) => t.status === 'completed' && t.taskKind === 'pain_diagnosis').length;
2068
- logger?.info?.(`[PD:EvolutionWorker] Pipeline summary: pain_completed=${painCompleted} pain_pending=${pendingPain} pain_in_progress=${inProgressPain}`);
2069
441
  } catch (err) {
2070
442
  if (logger) logger.warn(`[PD:EvolutionWorker] Error processing evolution queue: ${String(err)}`);
2071
443
  } finally {
@@ -2260,6 +632,49 @@ async function processEvolutionQueueWithResult(
2260
632
  return { queue: queueResult, errors };
2261
633
  }
2262
634
 
635
+ function resolveCorrectionObserver(wctx: WorkspaceContext, logger?: Pick<PluginLogger, 'info' | 'warn' | 'error' | 'debug'>): CorrectionObserver | null {
636
+ try {
637
+ const loader = new WorkflowFunnelLoader(wctx.stateDir);
638
+ const funnel = loader.getFunnel('pd-correction-observer');
639
+ const policy = funnel?.policy;
640
+ if (!policy || policy.runtimeKind !== 'pi-ai') {
641
+ logger?.debug?.('[PD:Correction] workflows.yaml pd-correction-observer policy not found. Falling back to environment variables.');
642
+ const provider = process.env.PD_CORRECTION_PROVIDER || 'anthropic';
643
+ const model = process.env.PD_CORRECTION_MODEL || 'anthropic/claude-3-5-sonnet';
644
+ const apiKeyEnv = process.env.PD_CORRECTION_API_KEY_ENV || 'ANTHROPIC_API_KEY';
645
+ const baseUrl = process.env.PD_CORRECTION_BASE_URL;
646
+
647
+ if (!process.env[apiKeyEnv]) {
648
+ logger?.debug?.(`[PD:Correction] Correction observer API key env ${apiKeyEnv} is not set. Periodic optimization disabled.`);
649
+ return null;
650
+ }
651
+
652
+ const adapter = new PiAiRuntimeAdapter({
653
+ provider,
654
+ model,
655
+ apiKeyEnv,
656
+ baseUrl,
657
+ workspace: wctx.workspaceDir,
658
+ });
659
+ return new CorrectionObserver({ runtimeAdapter: adapter });
660
+ }
661
+
662
+ const adapter = new PiAiRuntimeAdapter({
663
+ provider: String(policy.provider),
664
+ model: String(policy.model),
665
+ apiKeyEnv: String(policy.apiKeyEnv),
666
+ maxRetries: policy.maxRetries,
667
+ timeoutMs: policy.timeoutMs ?? 30_000,
668
+ baseUrl: policy.baseUrl,
669
+ workspace: wctx.workspaceDir,
670
+ });
671
+ return new CorrectionObserver({ runtimeAdapter: adapter }, { timeoutMs: policy.timeoutMs });
672
+ } catch (err) {
673
+ logger?.warn?.(`[PD:Correction] Failed to resolve CorrectionObserver: ${String(err)}`);
674
+ return null;
675
+ }
676
+ }
677
+
2263
678
  export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
2264
679
  id: 'principles-evolution-worker',
2265
680
  api: null,
@@ -2328,149 +743,125 @@ export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
2328
743
  };
2329
744
 
2330
745
  try {
2331
- // Load config on each cycle (supports runtime updates) — single file read
2332
- const mergedConfig = loadNocturnalConfigMerged(wctx.stateDir);
2333
- const { sleepReflection: sleepConfig, keywordOptimization: kwOptConfig } = mergedConfig;
2334
-
2335
746
  // Compilation backfill: runs on every heartbeat to retry failed compilations.
2336
747
  // Fire-and-forget — errors are logged within the function.
2337
748
  processCompilationBackfill(wctx, logger).catch((err) => {
2338
749
  logger?.error?.(`[PD:EvolutionWorker] CompilationBackfill threw: ${String(err)}`);
2339
750
  });
2340
751
 
2341
- const idleResult = checkWorkspaceIdle(wctx.workspaceDir, {});
2342
- logger?.info?.(`[PD:EvolutionWorker] HEARTBEAT cycle=${new Date().toISOString()} idle=${idleResult.isIdle} idleForMs=${idleResult.idleForMs} userActiveSessions=${idleResult.userActiveSessions} abandonedSessions=${idleResult.abandonedSessionIds.length} lastActivityEpoch=${idleResult.mostRecentActivityAt} triggerMode=${sleepConfig.trigger_mode}`);
2343
-
2344
- let shouldTrySleepReflection = false;
2345
-
2346
- // Path 1: Idle-based trigger (default mode)
2347
- if (idleResult.isIdle && sleepConfig.trigger_mode === 'idle') {
2348
- logger?.info?.(`[PD:EvolutionWorker] Workspace idle (${idleResult.idleForMs}ms since last activity)`);
2349
- shouldTrySleepReflection = true;
2350
- }
2351
-
2352
- // keyword_optimization: Independent periodic trigger (CORR-07).
2353
- // Fires every kwOptConfig.period_heartbeats regardless of trigger_mode.
2354
- // Has its own dedicated config (default 24 heartbeats = 6 hours).
2355
- if (kwOptConfig.enabled && heartbeatCounter > 0 && heartbeatCounter % kwOptConfig.period_heartbeats === 0) {
2356
- logger?.info?.(`[PD:EvolutionWorker] keyword_optimization trigger at heartbeat ${heartbeatCounter} (trigger_mode=${sleepConfig.trigger_mode})`);
2357
- enqueueKeywordOptimizationTask(wctx, logger).catch((err) => {
2358
- logger?.error?.(`[PD:EvolutionWorker] Failed to enqueue keyword_optimization task: ${String(err)}`);
2359
- });
2360
- }
2361
-
2362
- // Path 2: Periodic trigger for sleep_reflection (fires regardless of idle state)
2363
- if (sleepConfig.trigger_mode === 'periodic') {
2364
- if (heartbeatCounter >= sleepConfig.period_heartbeats) {
2365
- logger?.info?.(`[PD:EvolutionWorker] Periodic trigger: heartbeatCounter=${heartbeatCounter} >= period_heartbeats=${sleepConfig.period_heartbeats}`);
2366
- shouldTrySleepReflection = true;
2367
- heartbeatCounter = 0; // Reset counter
2368
- } else {
2369
- logger?.info?.(`[PD:EvolutionWorker] Periodic: ${heartbeatCounter}/${sleepConfig.period_heartbeats} heartbeats — waiting`);
2370
- }
2371
- }
2372
-
2373
- if (shouldTrySleepReflection) {
2374
- const cooldown = checkCooldown(wctx.stateDir, undefined, {
2375
- globalCooldownMs: sleepConfig.cooldown_ms,
2376
- maxRunsPerWindow: sleepConfig.max_runs_per_day,
2377
- quotaWindowMs: 24 * 60 * 60 * 1000,
2378
- });
2379
- logger?.info?.(`[PD:EvolutionWorker] Cooldown check: globalCooldownActive=${cooldown.globalCooldownActive} quotaExhausted=${cooldown.quotaExhausted} runsRemaining=${cooldown.runsRemaining}`);
2380
- if (!cooldown.globalCooldownActive && !cooldown.quotaExhausted) {
2381
- logger?.info?.('[PD:EvolutionWorker] Attempting to enqueue sleep_reflection task...');
2382
- enqueueSleepReflectionTask(wctx, logger).catch((err) => {
2383
- logger?.error?.(`[PD:EvolutionWorker] Failed to enqueue sleep_reflection task: ${String(err)}`);
2384
- });
2385
- } else {
2386
- logger?.info?.(`[PD:EvolutionWorker] Skipping sleep_reflection: globalCooldown=${cooldown.globalCooldownActive} quotaExhausted=${cooldown.quotaExhausted}`);
2387
- }
2388
- }
2389
-
2390
- const painCheckResult = await checkPainFlag(wctx, logger);
2391
- cycleResult.pain_flag = painCheckResult;
752
+ logger?.info?.(`[PD:EvolutionWorker] HEARTBEAT cycle=${new Date().toISOString()}`);
2392
753
 
2393
754
  const queueResult = await processEvolutionQueueWithResult(wctx, logger, eventLog, api ?? undefined);
2394
755
  cycleResult.queue = queueResult.queue;
2395
756
  if (queueResult.errors) cycleResult.errors.push(...queueResult.errors);
2396
757
 
2397
- // If pain flag was enqueued AND processEvolutionQueue wrote HEARTBEAT.md
2398
- // with a diagnostician task, immediately trigger a heartbeat to start
2399
- // the diagnostician without waiting for the next 15-minute interval.
2400
- // Must run AFTER processEvolutionQueue — HEARTBEAT.md must be written first.
2401
- if (painCheckResult.enqueued) {
2402
- const canTrigger = !!api?.runtime?.system?.runHeartbeatOnce;
2403
- logger.info(`[PD:EvolutionWorker] Pain flag enqueued — runHeartbeatOnce available: ${canTrigger} (api=${!!api}, runtime=${!!api?.runtime}, system=${!!api?.runtime?.system})`);
2404
- if (canTrigger) {
2405
- try {
2406
- const hbResult = await api.runtime.system.runHeartbeatOnce({
2407
- reason: `pd-pain-diagnosis: pain flag detected, starting diagnostician`,
2408
- });
2409
- 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}` : ''}`);
2410
- if (hbResult.status === 'skipped' || hbResult.status === 'failed') {
2411
- logger.warn(`[PD:EvolutionWorker] Immediate heartbeat was ${hbResult.status} (${hbResult.reason}). Diagnostician will start on next regular heartbeat cycle.`);
2412
- }
2413
- } catch (hbErr) {
2414
- logger.warn(`[PD:EvolutionWorker] Failed to trigger immediate heartbeat: ${String(hbErr)}. Diagnostician will start on next regular heartbeat cycle.`);
2415
- }
2416
- } else {
2417
- logger.warn(`[PD:EvolutionWorker] runHeartbeatOnce not available. Diagnostician will start on next regular heartbeat cycle.`);
2418
- }
2419
- }
2420
-
2421
758
  if (api) {
2422
759
  await processDetectionQueue(wctx, api, eventLog);
2423
760
  }
2424
761
  // processPromotion removed (D-06) — promotion via PAIN_CANDIDATES no longer needed
2425
762
 
763
+ // ── Correction Observer: periodic keyword optimization (D-40-08 / H-1) ──
2426
764
  try {
2427
- // Delegate to workflow managers' sweepExpiredWorkflows so that
2428
- // session/transcript cleanup runs via driver.deleteSession().
2429
- const subagentRuntime = api?.runtime?.subagent;
2430
- const agentSession = api?.runtime?.agent?.session;
2431
- if (subagentRuntime) {
2432
- const empathyMgr = new EmpathyObserverWorkflowManager({
2433
- workspaceDir: wctx.workspaceDir,
2434
- logger: api.logger,
2435
- subagent: subagentRuntime,
2436
- agentSession,
2437
- });
2438
- let swept = 0;
2439
- try {
2440
- swept += await empathyMgr.sweepExpiredWorkflows(WORKFLOW_TTL_MS);
2441
- } finally {
2442
- empathyMgr.dispose();
2443
- }
765
+ const observer = resolveCorrectionObserver(wctx, logger);
766
+ if (observer) {
767
+ logger?.info?.('[PD:EvolutionWorker] Correction Observer resolved. Initiating periodic optimization...');
768
+ const db = TrajectoryRegistry.get(wctx.workspaceDir);
769
+ const recentSessions = db.listRecentSessions({ limit: 20 });
770
+ const recentSessionIds = recentSessions.map(s => s.sessionId);
771
+
772
+ if (recentSessionIds.length > 0) {
773
+ const recentMessages: string[] = [];
774
+ for (const sId of recentSessionIds.slice(0, 5)) {
775
+ try {
776
+ const turns = db.listUserTurnsForSession(sId);
777
+ for (const t of turns) {
778
+ if (t.rawExcerpt) {
779
+ recentMessages.push(t.rawExcerpt);
780
+ }
781
+ }
782
+ } catch (turnErr) {
783
+ logger?.warn?.(`[PD:EvolutionWorker] Failed to load user turns for session ${sId}: ${String(turnErr)}`);
784
+ }
785
+ }
2444
786
 
2445
- // #183 + #188: Sweep Nocturnal workflows too (with gateway-safe fallback)
2446
- try {
2447
- const nocturnalMgr = new NocturnalWorkflowManager({
787
+ const learner = CorrectionCueLearner.get(wctx.stateDir);
788
+ const keywords = learner.getStore().keywords;
789
+ const keywordStoreSummary = {
790
+ totalKeywords: keywords.length,
791
+ terms: keywords.map(k => ({
792
+ term: k.term,
793
+ weight: k.weight,
794
+ hitCount: k.hitCount ?? 0,
795
+ truePositiveCount: k.truePositiveCount ?? 0,
796
+ falsePositiveCount: k.falsePositiveCount ?? 0,
797
+ })),
798
+ };
799
+
800
+ const optimizationService = KeywordOptimizationService.get(wctx.stateDir, wctx.workspaceDir, logger);
801
+ const trajectoryHistory = await optimizationService.buildTrajectoryHistory(recentSessionIds);
802
+
803
+ const payload = {
804
+ parentSessionId: 'evolution-worker',
2448
805
  workspaceDir: wctx.workspaceDir,
2449
- stateDir: wctx.stateDir,
2450
- logger: api.logger,
2451
- runtimeAdapter: new OpenClawTrinityRuntimeAdapter(api),
2452
- subagent: api.runtime.subagent,
806
+ keywordStoreSummary,
807
+ recentMessages,
808
+ trajectoryHistory,
809
+ };
810
+
811
+ const scheduler = new AgentScheduler();
812
+ scheduler.register({
813
+ agentId: 'correction-observer',
814
+ mode: 'realtime',
815
+ runner: observer,
2453
816
  });
2454
- swept += await nocturnalMgr.sweepExpiredWorkflows(WORKFLOW_TTL_MS, subagentRuntime, agentSession);
2455
- nocturnalMgr.dispose();
2456
- } catch (noctSweepErr) {
2457
- logger?.warn?.(`[PD:EvolutionWorker] Nocturnal sweep failed: ${String(noctSweepErr)}`);
2458
- }
2459
817
 
2460
- if (swept > 0) {
2461
- logger?.info?.(`[PD:EvolutionWorker] Swept ${swept} expired workflows (with session cleanup)`);
818
+ logger?.info?.(`[PD:EvolutionWorker] Dispatching correction-observer with ${trajectoryHistory.length} trajectory events, ${recentMessages.length} recent messages.`);
819
+ const result = await scheduler.dispatch('correction-observer', payload);
820
+ logger?.info?.(`[PD:EvolutionWorker] Correction-observer completed: updated=${result.updated}, summary="${result.summary}"`);
821
+
822
+ if (result.updated) {
823
+ optimizationService.applyResult(result);
824
+ }
825
+ } else {
826
+ logger?.info?.('[PD:EvolutionWorker] No recent sessions found. Skipping correction optimization.');
2462
827
  }
2463
- } else {
2464
- // Fallback: if subagent runtime unavailable, mark as expired
2465
- // but log that session cleanup was skipped.
2466
- const workflowStore = new WorkflowStore({ workspaceDir: wctx.workspaceDir });
828
+ }
829
+ } catch (corrErr) {
830
+ const corrErrMsg = `Correction observer execution failed: ${String(corrErr)}`;
831
+ cycleResult.errors.push(corrErrMsg);
832
+ logger?.warn?.(`[PD:EvolutionWorker] ${corrErrMsg}`);
833
+ }
834
+
835
+ try {
836
+ const subagentRuntime = api?.runtime?.subagent;
837
+ const workflowStore = new WorkflowStore({ workspaceDir: wctx.workspaceDir });
838
+ try {
2467
839
  const expiredWorkflows = workflowStore.getExpiredWorkflows(WORKFLOW_TTL_MS);
2468
840
  for (const wf of expiredWorkflows) {
841
+ // Attempt session cleanup when runtime is available
842
+ if (subagentRuntime && wf.child_session_key) {
843
+ try {
844
+ await subagentRuntime.deleteSession({ sessionKey: wf.child_session_key, deleteTranscript: true });
845
+ workflowStore.updateCleanupState(wf.workflow_id, 'completed');
846
+ logger?.info?.(`[PD:EvolutionWorker] Cleaned up session ${wf.child_session_key} for expired workflow ${wf.workflow_id}`);
847
+ } catch (cleanupErr) {
848
+ const errMsg = `Session cleanup failed for workflow ${wf.workflow_id} (child_session=${wf.child_session_key}): ${String(cleanupErr)}`;
849
+ workflowStore.updateCleanupState(wf.workflow_id, 'failed');
850
+ cycleResult.errors.push(errMsg);
851
+ logger?.warn?.(`[PD:EvolutionWorker] ${errMsg}`);
852
+ }
853
+ } else if (wf.child_session_key) {
854
+ // Runtime unavailable but session exists — structured failure, not silent
855
+ const errMsg = `Session cleanup unavailable for workflow ${wf.workflow_id} (child_session=${wf.child_session_key}): subagentRuntime not in gateway context`;
856
+ workflowStore.updateCleanupState(wf.workflow_id, 'failed');
857
+ cycleResult.errors.push(errMsg);
858
+ logger?.warn?.(`[PD:EvolutionWorker] ${errMsg}`);
859
+ }
2469
860
  workflowStore.updateWorkflowState(wf.workflow_id, 'expired');
2470
- workflowStore.updateCleanupState(wf.workflow_id, 'failed');
2471
- workflowStore.recordEvent(wf.workflow_id, 'swept', wf.state, 'expired', 'TTL expired (no runtime for session cleanup)', {});
2472
- logger?.warn?.(`[PD:EvolutionWorker] Marked workflow ${wf.workflow_id} as expired but could not cleanup session (subagent runtime unavailable)`);
861
+ workflowStore.recordEvent(wf.workflow_id, 'swept', wf.state, 'expired', 'TTL expired', {});
862
+ logger?.warn?.(`[PD:EvolutionWorker] Marked workflow ${wf.workflow_id} as expired`);
2473
863
  }
864
+ } finally {
2474
865
  workflowStore.dispose();
2475
866
  }
2476
867
  } catch (sweepErr) {
@@ -2516,20 +907,6 @@ export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
2516
907
 
2517
908
  timeoutId = setTimeout(() => {
2518
909
  void (async () => {
2519
- // Phase 41: Startup reconciliation — validate state, clear stale cooldowns, clean orphans
2520
- try {
2521
- const reconResult = await reconcileStartup(wctx.stateDir);
2522
- if (reconResult.cooldownsCleared > 0 || reconResult.orphansRemoved.length > 0 || reconResult.stateReset) {
2523
- logger?.info?.(`[PD:EvolutionWorker] Startup reconciliation: ${reconResult.cooldownsCleared} stale cooldowns cleared, ${reconResult.orphansRemoved.length} orphan files removed, stateReset=${reconResult.stateReset}`);
2524
- } else {
2525
- logger?.debug?.('[PD:EvolutionWorker] Startup reconciliation: clean state, no action needed');
2526
- }
2527
- } catch (reconErr) {
2528
- logger?.warn?.(`[PD:EvolutionWorker] Startup reconciliation failed (non-blocking): ${String(reconErr)}`);
2529
- }
2530
-
2531
- await checkPainFlag(wctx, logger);
2532
- // Use the same pipeline as regular cycles (includes purge + observability)
2533
910
  const queueResult = await processEvolutionQueueWithResult(wctx, logger, eventLog, api ?? undefined);
2534
911
  if (queueResult.errors.length > 0) {
2535
912
  queueResult.errors.forEach((e) => logger?.error?.(`[PD:EvolutionWorker] Startup cycle error: ${e}`));