principles-disciple 1.8.0 → 1.8.2
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.
- package/ADVANCED_CONFIG_ZH.md +97 -0
- package/AGENT_INSTALL.md +173 -0
- package/AGENT_INSTALL_EN.md +173 -0
- package/INSTALL.md +256 -0
- package/SKILL.md +63 -0
- package/docs/COMMAND_REFERENCE.md +76 -0
- package/docs/COMMAND_REFERENCE_EN.md +79 -0
- package/esbuild.config.js +75 -0
- package/openclaw.plugin.json +6 -1
- package/package.json +13 -15
- package/scripts/build-web.mjs +46 -0
- package/scripts/install-dependencies.cjs +47 -0
- package/scripts/sync-plugin.mjs +802 -0
- package/scripts/verify-build.mjs +109 -0
- package/src/agents/nocturnal-dreamer.md +152 -0
- package/src/agents/nocturnal-philosopher.md +138 -0
- package/src/agents/nocturnal-reflector.md +126 -0
- package/src/agents/nocturnal-scribe.md +164 -0
- package/src/commands/capabilities.ts +85 -0
- package/{dist/commands/context.js → src/commands/context.ts} +78 -38
- package/src/commands/evolution-status.ts +146 -0
- package/src/commands/export.ts +111 -0
- package/src/commands/focus.ts +533 -0
- package/src/commands/nocturnal-review.ts +311 -0
- package/src/commands/nocturnal-rollout.ts +763 -0
- package/src/commands/nocturnal-train.ts +1002 -0
- package/{dist/commands/pain.js → src/commands/pain.ts} +68 -49
- package/src/commands/principle-rollback.ts +27 -0
- package/{dist/commands/rollback.js → src/commands/rollback.ts} +44 -12
- package/src/commands/samples.ts +60 -0
- package/src/commands/strategy.ts +38 -0
- package/{dist/commands/thinking-os.js → src/commands/thinking-os.ts} +59 -36
- package/src/commands/workflow-debug.ts +128 -0
- package/{dist/config/defaults/runtime.js → src/config/defaults/runtime.ts} +12 -5
- package/src/config/errors.ts +163 -0
- package/{dist/config/index.d.ts → src/config/index.ts} +2 -1
- package/src/constants/diagnostician.ts +66 -0
- package/src/constants/tools.ts +62 -0
- package/src/core/adaptive-thresholds.ts +476 -0
- package/{dist/core/config-service.js → src/core/config-service.ts} +7 -4
- package/{dist/core/config.js → src/core/config.ts} +158 -46
- package/src/core/control-ui-db.ts +435 -0
- package/{dist/core/detection-funnel.js → src/core/detection-funnel.ts} +36 -21
- package/{dist/core/detection-service.js → src/core/detection-service.ts} +7 -4
- package/{dist/core/dictionary-service.js → src/core/dictionary-service.ts} +7 -4
- package/{dist/core/dictionary.js → src/core/dictionary.ts} +57 -34
- package/src/core/empathy-keyword-matcher.ts +327 -0
- package/src/core/empathy-types.ts +218 -0
- package/src/core/event-log.ts +544 -0
- package/src/core/evolution-engine.ts +612 -0
- package/src/core/evolution-logger.ts +353 -0
- package/src/core/evolution-migration.ts +77 -0
- package/src/core/evolution-reducer.ts +731 -0
- package/src/core/evolution-types.ts +456 -0
- package/src/core/external-training-contract.ts +527 -0
- package/src/core/focus-history.ts +1458 -0
- package/src/core/hygiene/tracker.ts +117 -0
- package/{dist/core/init.js → src/core/init.ts} +39 -26
- package/src/core/local-worker-routing.ts +617 -0
- package/{dist/core/migration.js → src/core/migration.ts} +18 -11
- package/src/core/model-deployment-registry.ts +722 -0
- package/src/core/model-training-registry.ts +813 -0
- package/src/core/nocturnal-arbiter.ts +706 -0
- package/src/core/nocturnal-candidate-scoring.ts +392 -0
- package/src/core/nocturnal-compliance.ts +1075 -0
- package/src/core/nocturnal-dataset.ts +668 -0
- package/src/core/nocturnal-executability.ts +428 -0
- package/src/core/nocturnal-export.ts +390 -0
- package/{dist/core/nocturnal-paths.js → src/core/nocturnal-paths.ts} +49 -23
- package/src/core/nocturnal-trajectory-extractor.ts +484 -0
- package/src/core/nocturnal-trinity.ts +1384 -0
- package/src/core/pain.ts +122 -0
- package/{dist/core/path-resolver.js → src/core/path-resolver.ts} +157 -36
- package/{dist/core/paths.js → src/core/paths.ts} +13 -4
- package/src/core/principle-training-state.ts +450 -0
- package/src/core/profile.ts +226 -0
- package/src/core/promotion-gate.ts +822 -0
- package/{dist/core/risk-calculator.js → src/core/risk-calculator.ts} +42 -16
- package/{dist/core/session-tracker.js → src/core/session-tracker.ts} +185 -63
- package/src/core/shadow-observation-registry.ts +534 -0
- package/{dist/core/system-logger.js → src/core/system-logger.ts} +9 -5
- package/src/core/thinking-models.ts +217 -0
- package/src/core/training-program.ts +630 -0
- package/src/core/trajectory-types.ts +243 -0
- package/src/core/trajectory.ts +1673 -0
- package/{dist/core/workspace-context.js → src/core/workspace-context.ts} +57 -32
- package/src/hooks/bash-risk.ts +171 -0
- package/src/hooks/edit-verification.ts +295 -0
- package/src/hooks/gate-block-helper.ts +160 -0
- package/src/hooks/gate.ts +210 -0
- package/src/hooks/gfi-gate.ts +177 -0
- package/src/hooks/lifecycle.ts +326 -0
- package/{dist/hooks/llm.js → src/hooks/llm.ts} +166 -139
- package/src/hooks/message-sanitize.ts +45 -0
- package/src/hooks/pain.ts +384 -0
- package/src/hooks/progressive-trust-gate.ts +174 -0
- package/src/hooks/prompt.ts +920 -0
- package/src/hooks/subagent.ts +207 -0
- package/src/hooks/thinking-checkpoint.ts +73 -0
- package/src/hooks/trajectory-collector.ts +290 -0
- package/src/http/principles-console-route.ts +716 -0
- package/src/i18n/commands.ts +117 -0
- package/src/index.ts +694 -0
- package/src/service/central-database.ts +831 -0
- package/src/service/control-ui-query-service.ts +888 -0
- package/src/service/evolution-query-service.ts +405 -0
- package/src/service/evolution-worker.ts +1646 -0
- package/src/service/health-query-service.ts +836 -0
- package/{dist/service/nocturnal-runtime.js → src/service/nocturnal-runtime.ts} +263 -36
- package/src/service/nocturnal-service.ts +1015 -0
- package/src/service/nocturnal-target-selector.ts +532 -0
- package/src/service/phase3-input-filter.ts +237 -0
- package/src/service/runtime-summary-service.ts +757 -0
- package/src/service/subagent-workflow/deep-reflect-workflow-manager.ts +513 -0
- package/src/service/subagent-workflow/empathy-observer-workflow-manager.ts +603 -0
- package/src/service/subagent-workflow/index.ts +51 -0
- package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +856 -0
- package/src/service/subagent-workflow/runtime-direct-driver.ts +166 -0
- package/src/service/subagent-workflow/types.ts +378 -0
- package/src/service/subagent-workflow/workflow-store.ts +328 -0
- package/src/service/trajectory-service.ts +15 -0
- package/{dist/tools/critique-prompt.js → src/tools/critique-prompt.ts} +25 -8
- package/src/tools/deep-reflect.ts +349 -0
- package/{dist/tools/model-index.js → src/tools/model-index.ts} +33 -17
- package/src/types/event-types.ts +453 -0
- package/src/types/hygiene-types.ts +31 -0
- package/src/types/principle-tree-schema.ts +244 -0
- package/src/types/runtime-summary.ts +49 -0
- package/src/types.ts +74 -0
- package/src/utils/file-lock.ts +391 -0
- package/{dist/utils/glob-match.js → src/utils/glob-match.ts} +21 -20
- package/{dist/utils/hashing.js → src/utils/hashing.ts} +6 -4
- package/src/utils/io.ts +110 -0
- package/{dist/utils/nlp.js → src/utils/nlp.ts} +19 -12
- package/{dist/utils/plugin-logger.js → src/utils/plugin-logger.ts} +33 -8
- package/src/utils/subagent-probe.ts +94 -0
- package/templates/langs/zh/skills/pd-diagnostician/SKILL.md +70 -1
- package/templates/pain_settings.json +2 -1
- package/tests/README.md +120 -0
- package/tests/build-artifacts.test.ts +111 -0
- package/tests/commands/evolution-status.test.ts +222 -0
- package/tests/commands/evolver.test.ts +22 -0
- package/tests/commands/export.test.ts +78 -0
- package/tests/commands/nocturnal-review.test.ts +448 -0
- package/tests/commands/nocturnal-train.test.ts +97 -0
- package/tests/commands/pain.test.ts +108 -0
- package/tests/commands/samples.test.ts +65 -0
- package/tests/commands/strategy.test.ts +34 -0
- package/tests/commands/thinking-os.test.ts +88 -0
- package/tests/core/adaptive-thresholds.test.ts +261 -0
- package/tests/core/config-service.test.ts +89 -0
- package/tests/core/config.test.ts +90 -0
- package/tests/core/control-ui-db.test.ts +75 -0
- package/tests/core/core-template-guidance.test.ts +21 -0
- package/tests/core/detection-funnel.test.ts +63 -0
- package/tests/core/detection-service.test.ts +50 -0
- package/tests/core/dictionary-service.test.ts +116 -0
- package/tests/core/dictionary.test.ts +168 -0
- package/tests/core/empathy-keyword-matcher.test.ts +209 -0
- package/tests/core/event-log.test.ts +181 -0
- package/tests/core/evolution-e2e.test.ts +58 -0
- package/tests/core/evolution-engine-gate-integration.test.ts +543 -0
- package/tests/core/evolution-engine.test.ts +562 -0
- package/tests/core/evolution-logger.test.ts +148 -0
- package/tests/core/evolution-migration.test.ts +50 -0
- package/tests/core/evolution-paths.test.ts +21 -0
- package/tests/core/evolution-reducer.detector-metadata.test.ts +602 -0
- package/tests/core/evolution-reducer.test.ts +180 -0
- package/tests/core/evolution-types-loop.test.ts +48 -0
- package/tests/core/evolution-user-stories.e2e.test.ts +249 -0
- package/tests/core/external-training-contract.test.ts +463 -0
- package/tests/core/focus-history.test.ts +682 -0
- package/tests/core/init-flatten.test.ts +69 -0
- package/tests/core/init-refactor.test.ts +87 -0
- package/tests/core/init-v1.3.test.ts +46 -0
- package/tests/core/init.test.ts +190 -0
- package/tests/core/local-worker-routing.test.ts +757 -0
- package/tests/core/migration.test.ts +84 -0
- package/tests/core/model-deployment-registry.test.ts +845 -0
- package/tests/core/model-training-registry.test.ts +889 -0
- package/tests/core/nocturnal-arbiter.test.ts +494 -0
- package/tests/core/nocturnal-candidate-scoring.test.ts +400 -0
- package/tests/core/nocturnal-compliance.test.ts +646 -0
- package/tests/core/nocturnal-dataset.test.ts +892 -0
- package/tests/core/nocturnal-executability.test.ts +357 -0
- package/tests/core/nocturnal-export.test.ts +462 -0
- package/tests/core/nocturnal-reviewed-subset-comparison.test.ts +428 -0
- package/tests/core/nocturnal-trajectory-extractor.test.ts +634 -0
- package/tests/core/nocturnal-trinity.test.ts +953 -0
- package/tests/core/pain.test.ts +33 -0
- package/tests/core/path-resolver.test.ts +57 -0
- package/tests/core/paths-refactor.test.ts +42 -0
- package/tests/core/phase7-rollout-integration.test.ts +477 -0
- package/tests/core/principle-training-state.test.ts +712 -0
- package/tests/core/profile.test.ts +56 -0
- package/tests/core/promotion-gate.test.ts +556 -0
- package/tests/core/risk-calculator.test.ts +168 -0
- package/tests/core/session-tracker.test.ts +191 -0
- package/tests/core/training-program.test.ts +472 -0
- package/tests/core/trajectory.test.ts +265 -0
- package/tests/core/workspace-context-factory.test.ts +18 -0
- package/tests/core/workspace-context.test.ts +134 -0
- package/tests/fixtures/nocturnal-reviewed-subset.json +183 -0
- package/tests/fixtures/production-compatibility.test.ts +147 -0
- package/tests/fixtures/production-mock-generator.ts +282 -0
- package/tests/hooks/bash-risk-integration.test.ts +137 -0
- package/tests/hooks/bash-risk.test.ts +81 -0
- package/tests/hooks/edit-verification.test.ts +678 -0
- package/tests/hooks/gate-edit-verification-p1.test.ts +632 -0
- package/tests/hooks/gate-edit-verification.test.ts +435 -0
- package/tests/hooks/gate-pipeline-integration.test.ts +404 -0
- package/tests/hooks/gate.test.ts +271 -0
- package/tests/hooks/gfi-gate-unit.test.ts +422 -0
- package/tests/hooks/gfi-gate.test.ts +669 -0
- package/tests/hooks/lifecycle.test.ts +248 -0
- package/tests/hooks/llm.test.ts +308 -0
- package/tests/hooks/message-sanitize.test.ts +36 -0
- package/tests/hooks/pain.test.ts +141 -0
- package/tests/hooks/progressive-trust-gate.test.ts +277 -0
- package/tests/hooks/prompt.test.ts +1411 -0
- package/tests/hooks/subagent.test.ts +467 -0
- package/tests/hooks/thinking-gate.test.ts +313 -0
- package/tests/http/principles-console-route.test.ts +140 -0
- package/tests/hygiene-tracker.test.ts +77 -0
- package/tests/index.integration.test.ts +179 -0
- package/tests/index.shadow-routing.integration.test.ts +140 -0
- package/tests/index.test.ts +9 -0
- package/tests/integration/empathy-workflow-integration.test.ts +627 -0
- package/tests/service/control-ui-query-service.test.ts +121 -0
- package/tests/service/empathy-observer-workflow-manager.test.ts +176 -0
- package/tests/service/evolution-worker.test.ts +585 -0
- package/tests/service/nocturnal-runtime.test.ts +470 -0
- package/tests/service/nocturnal-service.test.ts +577 -0
- package/tests/service/nocturnal-target-selector.test.ts +615 -0
- package/tests/service/nocturnal-workflow-manager.test.ts +439 -0
- package/tests/service/phase3-input-filter.test.ts +289 -0
- package/tests/service/runtime-summary-service.test.ts +919 -0
- package/tests/task-compliance.test.ts +166 -0
- package/tests/test-utils.ts +48 -0
- package/tests/tools/critique-prompt.test.ts +260 -0
- package/tests/tools/deep-reflect.test.ts +232 -0
- package/tests/tools/model-index.test.ts +246 -0
- package/tests/ui/app.test.tsx +114 -0
- package/tests/utils/file-lock.test.ts +407 -0
- package/tests/utils/hashing.test.ts +32 -0
- package/tests/utils/io.test.ts +39 -0
- package/tests/utils/nlp.test.ts +53 -0
- package/tests/utils/plugin-logger.test.ts +156 -0
- package/tsconfig.json +16 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/ui/src/App.tsx +45 -0
- package/ui/src/api.ts +216 -0
- package/ui/src/charts.tsx +586 -0
- package/ui/src/components/ErrorState.tsx +6 -0
- package/ui/src/components/Loading.tsx +13 -0
- package/ui/src/components/ProtectedRoute.tsx +12 -0
- package/ui/src/components/Shell.tsx +91 -0
- package/ui/src/components/WorkspaceConfig.tsx +146 -0
- package/ui/src/components/index.ts +5 -0
- package/ui/src/context/auth.tsx +80 -0
- package/ui/src/context/theme.tsx +66 -0
- package/ui/src/hooks/useAutoRefresh.ts +39 -0
- package/ui/src/i18n/ui.ts +363 -0
- package/ui/src/main.tsx +16 -0
- package/ui/src/pages/EvolutionPage.tsx +352 -0
- package/ui/src/pages/FeedbackPage.tsx +140 -0
- package/ui/src/pages/GateMonitorPage.tsx +136 -0
- package/ui/src/pages/LoginPage.tsx +88 -0
- package/ui/src/pages/OverviewPage.tsx +238 -0
- package/ui/src/pages/SamplesPage.tsx +174 -0
- package/ui/src/pages/ThinkingModelsPage.tsx +127 -0
- package/ui/src/styles.css +1661 -0
- package/ui/src/types.ts +368 -0
- package/ui/src/utils/format.ts +15 -0
- package/vitest.config.ts +23 -0
- package/dist/commands/capabilities.d.ts +0 -3
- package/dist/commands/capabilities.js +0 -73
- package/dist/commands/context.d.ts +0 -5
- package/dist/commands/evolution-status.d.ts +0 -4
- package/dist/commands/evolution-status.js +0 -117
- package/dist/commands/evolver.d.ts +0 -9
- package/dist/commands/evolver.js +0 -26
- package/dist/commands/export.d.ts +0 -2
- package/dist/commands/export.js +0 -98
- package/dist/commands/focus.d.ts +0 -14
- package/dist/commands/focus.js +0 -457
- package/dist/commands/nocturnal-review.d.ts +0 -24
- package/dist/commands/nocturnal-review.js +0 -265
- package/dist/commands/nocturnal-rollout.d.ts +0 -27
- package/dist/commands/nocturnal-rollout.js +0 -671
- package/dist/commands/nocturnal-train.d.ts +0 -25
- package/dist/commands/nocturnal-train.js +0 -919
- package/dist/commands/pain.d.ts +0 -5
- package/dist/commands/principle-rollback.d.ts +0 -4
- package/dist/commands/principle-rollback.js +0 -22
- package/dist/commands/rollback.d.ts +0 -19
- package/dist/commands/samples.d.ts +0 -2
- package/dist/commands/samples.js +0 -55
- package/dist/commands/strategy.d.ts +0 -3
- package/dist/commands/strategy.js +0 -29
- package/dist/commands/thinking-os.d.ts +0 -2
- package/dist/config/defaults/runtime.d.ts +0 -40
- package/dist/config/errors.d.ts +0 -84
- package/dist/config/errors.js +0 -94
- package/dist/config/index.js +0 -7
- package/dist/constants/diagnostician.d.ts +0 -12
- package/dist/constants/diagnostician.js +0 -56
- package/dist/constants/tools.d.ts +0 -17
- package/dist/constants/tools.js +0 -54
- package/dist/core/adaptive-thresholds.d.ts +0 -186
- package/dist/core/adaptive-thresholds.js +0 -300
- package/dist/core/config-service.d.ts +0 -15
- package/dist/core/config.d.ts +0 -127
- package/dist/core/control-ui-db.d.ts +0 -95
- package/dist/core/control-ui-db.js +0 -292
- package/dist/core/detection-funnel.d.ts +0 -33
- package/dist/core/detection-service.d.ts +0 -15
- package/dist/core/dictionary-service.d.ts +0 -15
- package/dist/core/dictionary.d.ts +0 -38
- package/dist/core/event-log.d.ts +0 -82
- package/dist/core/event-log.js +0 -463
- package/dist/core/evolution-engine.d.ts +0 -118
- package/dist/core/evolution-engine.js +0 -464
- package/dist/core/evolution-logger.d.ts +0 -137
- package/dist/core/evolution-logger.js +0 -256
- package/dist/core/evolution-migration.d.ts +0 -5
- package/dist/core/evolution-migration.js +0 -65
- package/dist/core/evolution-reducer.d.ts +0 -98
- package/dist/core/evolution-reducer.js +0 -465
- package/dist/core/evolution-types.d.ts +0 -287
- package/dist/core/evolution-types.js +0 -78
- package/dist/core/external-training-contract.d.ts +0 -276
- package/dist/core/external-training-contract.js +0 -269
- package/dist/core/focus-history.d.ts +0 -210
- package/dist/core/focus-history.js +0 -1185
- package/dist/core/hygiene/tracker.d.ts +0 -22
- package/dist/core/hygiene/tracker.js +0 -106
- package/dist/core/init.d.ts +0 -12
- package/dist/core/local-worker-routing.d.ts +0 -175
- package/dist/core/local-worker-routing.js +0 -525
- package/dist/core/migration.d.ts +0 -6
- package/dist/core/model-deployment-registry.d.ts +0 -218
- package/dist/core/model-deployment-registry.js +0 -503
- package/dist/core/model-training-registry.d.ts +0 -295
- package/dist/core/model-training-registry.js +0 -475
- package/dist/core/nocturnal-arbiter.d.ts +0 -159
- package/dist/core/nocturnal-arbiter.js +0 -534
- package/dist/core/nocturnal-candidate-scoring.d.ts +0 -137
- package/dist/core/nocturnal-candidate-scoring.js +0 -266
- package/dist/core/nocturnal-compliance.d.ts +0 -175
- package/dist/core/nocturnal-compliance.js +0 -824
- package/dist/core/nocturnal-dataset.d.ts +0 -224
- package/dist/core/nocturnal-dataset.js +0 -443
- package/dist/core/nocturnal-executability.d.ts +0 -85
- package/dist/core/nocturnal-executability.js +0 -331
- package/dist/core/nocturnal-export.d.ts +0 -124
- package/dist/core/nocturnal-export.js +0 -275
- package/dist/core/nocturnal-paths.d.ts +0 -124
- package/dist/core/nocturnal-trajectory-extractor.d.ts +0 -242
- package/dist/core/nocturnal-trajectory-extractor.js +0 -307
- package/dist/core/nocturnal-trinity.d.ts +0 -311
- package/dist/core/nocturnal-trinity.js +0 -880
- package/dist/core/pain.d.ts +0 -4
- package/dist/core/pain.js +0 -70
- package/dist/core/path-resolver.d.ts +0 -46
- package/dist/core/paths.d.ts +0 -65
- package/dist/core/principle-training-state.d.ts +0 -121
- package/dist/core/principle-training-state.js +0 -321
- package/dist/core/profile.d.ts +0 -62
- package/dist/core/profile.js +0 -210
- package/dist/core/promotion-gate.d.ts +0 -238
- package/dist/core/promotion-gate.js +0 -529
- package/dist/core/risk-calculator.d.ts +0 -22
- package/dist/core/session-tracker.d.ts +0 -99
- package/dist/core/shadow-observation-registry.d.ts +0 -217
- package/dist/core/shadow-observation-registry.js +0 -308
- package/dist/core/system-logger.d.ts +0 -8
- package/dist/core/thinking-models.d.ts +0 -38
- package/dist/core/thinking-models.js +0 -170
- package/dist/core/training-program.d.ts +0 -233
- package/dist/core/training-program.js +0 -433
- package/dist/core/trajectory.d.ts +0 -411
- package/dist/core/trajectory.js +0 -1307
- package/dist/core/workspace-context.d.ts +0 -71
- package/dist/hooks/bash-risk.d.ts +0 -57
- package/dist/hooks/bash-risk.js +0 -137
- package/dist/hooks/edit-verification.d.ts +0 -62
- package/dist/hooks/edit-verification.js +0 -256
- package/dist/hooks/gate-block-helper.d.ts +0 -44
- package/dist/hooks/gate-block-helper.js +0 -119
- package/dist/hooks/gate.d.ts +0 -24
- package/dist/hooks/gate.js +0 -173
- package/dist/hooks/gfi-gate.d.ts +0 -40
- package/dist/hooks/gfi-gate.js +0 -113
- package/dist/hooks/lifecycle.d.ts +0 -5
- package/dist/hooks/lifecycle.js +0 -284
- package/dist/hooks/llm.d.ts +0 -12
- package/dist/hooks/message-sanitize.d.ts +0 -3
- package/dist/hooks/message-sanitize.js +0 -37
- package/dist/hooks/pain.d.ts +0 -5
- package/dist/hooks/pain.js +0 -301
- package/dist/hooks/progressive-trust-gate.d.ts +0 -51
- package/dist/hooks/progressive-trust-gate.js +0 -89
- package/dist/hooks/prompt.d.ts +0 -47
- package/dist/hooks/prompt.js +0 -884
- package/dist/hooks/subagent.d.ts +0 -10
- package/dist/hooks/subagent.js +0 -387
- package/dist/hooks/thinking-checkpoint.d.ts +0 -37
- package/dist/hooks/thinking-checkpoint.js +0 -51
- package/dist/hooks/trajectory-collector.d.ts +0 -32
- package/dist/hooks/trajectory-collector.js +0 -256
- package/dist/http/principles-console-route.d.ts +0 -9
- package/dist/http/principles-console-route.js +0 -567
- package/dist/i18n/commands.d.ts +0 -26
- package/dist/i18n/commands.js +0 -116
- package/dist/index.d.ts +0 -7
- package/dist/index.js +0 -581
- package/dist/service/central-database.d.ts +0 -104
- package/dist/service/central-database.js +0 -649
- package/dist/service/control-ui-query-service.d.ts +0 -221
- package/dist/service/control-ui-query-service.js +0 -543
- package/dist/service/empathy-observer-manager.d.ts +0 -52
- package/dist/service/empathy-observer-manager.js +0 -229
- package/dist/service/evolution-query-service.d.ts +0 -155
- package/dist/service/evolution-query-service.js +0 -258
- package/dist/service/evolution-worker.d.ts +0 -101
- package/dist/service/evolution-worker.js +0 -974
- package/dist/service/nocturnal-runtime.d.ts +0 -183
- package/dist/service/nocturnal-service.d.ts +0 -163
- package/dist/service/nocturnal-service.js +0 -787
- package/dist/service/nocturnal-target-selector.d.ts +0 -145
- package/dist/service/nocturnal-target-selector.js +0 -315
- package/dist/service/phase3-input-filter.d.ts +0 -73
- package/dist/service/phase3-input-filter.js +0 -172
- package/dist/service/runtime-summary-service.d.ts +0 -122
- package/dist/service/runtime-summary-service.js +0 -485
- package/dist/service/trajectory-service.d.ts +0 -2
- package/dist/service/trajectory-service.js +0 -15
- package/dist/tools/critique-prompt.d.ts +0 -14
- package/dist/tools/deep-reflect.d.ts +0 -39
- package/dist/tools/deep-reflect.js +0 -350
- package/dist/tools/model-index.d.ts +0 -9
- package/dist/types/event-types.d.ts +0 -306
- package/dist/types/event-types.js +0 -106
- package/dist/types/hygiene-types.d.ts +0 -20
- package/dist/types/hygiene-types.js +0 -12
- package/dist/types/runtime-summary.d.ts +0 -47
- package/dist/types/runtime-summary.js +0 -1
- package/dist/types.d.ts +0 -50
- package/dist/types.js +0 -22
- package/dist/utils/file-lock.d.ts +0 -71
- package/dist/utils/file-lock.js +0 -309
- package/dist/utils/glob-match.d.ts +0 -28
- package/dist/utils/hashing.d.ts +0 -9
- package/dist/utils/io.d.ts +0 -6
- package/dist/utils/io.js +0 -106
- package/dist/utils/nlp.d.ts +0 -9
- package/dist/utils/plugin-logger.d.ts +0 -39
- package/dist/utils/subagent-probe.d.ts +0 -34
- package/dist/utils/subagent-probe.js +0 -81
|
@@ -0,0 +1,1646 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
import type { OpenClawPluginServiceContext, OpenClawPluginApi, PluginLogger } from '../openclaw-sdk.js';
|
|
5
|
+
import { DictionaryService } from '../core/dictionary-service.js';
|
|
6
|
+
import { DetectionService } from '../core/detection-service.js';
|
|
7
|
+
import { ensureStateTemplates } from '../core/init.js';
|
|
8
|
+
import { extractCommonSubstring } from '../utils/nlp.js';
|
|
9
|
+
import { SystemLogger } from '../core/system-logger.js';
|
|
10
|
+
import { WorkspaceContext } from '../core/workspace-context.js';
|
|
11
|
+
import { EventLog } from '../core/event-log.js';
|
|
12
|
+
import { initPersistence, flushAllSessions } from '../core/session-tracker.js';
|
|
13
|
+
import { acquireLockAsync, releaseLock, type LockContext } from '../utils/file-lock.js';
|
|
14
|
+
import { getEvolutionLogger, type EvolutionStage } from '../core/evolution-logger.js';
|
|
15
|
+
import type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
|
|
16
|
+
export type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
|
|
17
|
+
import { LockUnavailableError } from '../config/index.js';
|
|
18
|
+
import { checkWorkspaceIdle, checkCooldown } from './nocturnal-runtime.js';
|
|
19
|
+
import { WorkflowStore } from './subagent-workflow/workflow-store.js';
|
|
20
|
+
import { EmpathyObserverWorkflowManager } from './subagent-workflow/empathy-observer-workflow-manager.js';
|
|
21
|
+
import { DeepReflectWorkflowManager } from './subagent-workflow/deep-reflect-workflow-manager.js';
|
|
22
|
+
import { NocturnalWorkflowManager, nocturnalWorkflowSpec } from './subagent-workflow/nocturnal-workflow-manager.js';
|
|
23
|
+
|
|
24
|
+
const WORKFLOW_TTL_MS = 5 * 60 * 1000; // 5 minutes default TTL for helper workflows
|
|
25
|
+
import { OpenClawTrinityRuntimeAdapter } from '../core/nocturnal-trinity.js';
|
|
26
|
+
|
|
27
|
+
let timeoutId: NodeJS.Timeout | null = null;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Queue V2 Schema - Supports multiple task kinds while preserving pain_diagnosis semantics.
|
|
31
|
+
*
|
|
32
|
+
* taskKind semantics:
|
|
33
|
+
* - pain_diagnosis: User-adjacent, triggers HEARTBEAT, injects into user prompts
|
|
34
|
+
* - sleep_reflection: Background-only, never injects into user prompts, no HEARTBEAT
|
|
35
|
+
*
|
|
36
|
+
* Old queue items (without taskKind) are migrated to pain_diagnosis for compatibility.
|
|
37
|
+
*/
|
|
38
|
+
export type QueueStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'canceled';
|
|
39
|
+
export type TaskResolution = 'marker_detected' | 'auto_completed_timeout' | 'failed_max_retries' | 'canceled' | 'late_marker_principle_created' | 'late_marker_no_principle';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Recent pain context attached to sleep_reflection tasks.
|
|
43
|
+
* Carries explicit recent pain signal metadata without being a separate task kind.
|
|
44
|
+
* Used by NocturnalTargetSelector for ranking bias and context enrichment.
|
|
45
|
+
*/
|
|
46
|
+
export interface RecentPainContext {
|
|
47
|
+
/** Most recent unresolved pain event */
|
|
48
|
+
mostRecent: {
|
|
49
|
+
score: number;
|
|
50
|
+
source: string;
|
|
51
|
+
reason: string;
|
|
52
|
+
timestamp: string;
|
|
53
|
+
} | null;
|
|
54
|
+
/** Count of pain events in the recent window (for signal strength) */
|
|
55
|
+
recentPainCount: number;
|
|
56
|
+
/** Highest pain score in the recent window */
|
|
57
|
+
recentMaxPainScore: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface EvolutionQueueItem {
|
|
61
|
+
// Core identity
|
|
62
|
+
id: string;
|
|
63
|
+
taskKind: TaskKind; // V2: distinguishes task types
|
|
64
|
+
priority: TaskPriority; // V2: scheduling priority
|
|
65
|
+
source: string;
|
|
66
|
+
traceId?: string; // Trace ID for linking events across the evolution lifecycle
|
|
67
|
+
|
|
68
|
+
// Legacy fields (still used for pain_diagnosis)
|
|
69
|
+
task?: string;
|
|
70
|
+
score: number;
|
|
71
|
+
reason: string;
|
|
72
|
+
timestamp: string;
|
|
73
|
+
enqueued_at?: string;
|
|
74
|
+
started_at?: string;
|
|
75
|
+
completed_at?: string;
|
|
76
|
+
assigned_session_key?: string;
|
|
77
|
+
trigger_text_preview?: string;
|
|
78
|
+
status: QueueStatus; // V2: includes 'failed' and 'canceled'
|
|
79
|
+
resolution?: TaskResolution;
|
|
80
|
+
session_id?: string;
|
|
81
|
+
agent_id?: string;
|
|
82
|
+
|
|
83
|
+
// V2 retry support
|
|
84
|
+
retryCount: number; // V2: number of retry attempts
|
|
85
|
+
maxRetries: number; // V2: maximum retry attempts allowed
|
|
86
|
+
lastError?: string; // V2: last error message if failed
|
|
87
|
+
|
|
88
|
+
// V2 result reference
|
|
89
|
+
resultRef?: string; // V2: reference to result artifact
|
|
90
|
+
|
|
91
|
+
// V2: Recent pain context for sleep_reflection tasks
|
|
92
|
+
// Attaches explicit recent pain signal without merging task kinds.
|
|
93
|
+
// Used by target selector for ranking bias and context enrichment.
|
|
94
|
+
recentPainContext?: RecentPainContext;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Legacy queue item shape (pre-V2) for migration compatibility.
|
|
99
|
+
* These items lack taskKind, priority, retryCount, maxRetries, lastError fields.
|
|
100
|
+
*/
|
|
101
|
+
interface LegacyEvolutionQueueItem {
|
|
102
|
+
id: string;
|
|
103
|
+
task?: string;
|
|
104
|
+
score: number;
|
|
105
|
+
source: string;
|
|
106
|
+
reason: string;
|
|
107
|
+
timestamp: string;
|
|
108
|
+
enqueued_at?: string;
|
|
109
|
+
started_at?: string;
|
|
110
|
+
completed_at?: string;
|
|
111
|
+
assigned_session_key?: string;
|
|
112
|
+
trigger_text_preview?: string;
|
|
113
|
+
status?: string;
|
|
114
|
+
resolution?: string;
|
|
115
|
+
session_id?: string;
|
|
116
|
+
agent_id?: string;
|
|
117
|
+
traceId?: string;
|
|
118
|
+
taskKind?: string;
|
|
119
|
+
priority?: string;
|
|
120
|
+
retryCount?: number;
|
|
121
|
+
maxRetries?: number;
|
|
122
|
+
lastError?: string;
|
|
123
|
+
resultRef?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface PainCandidateEntry {
|
|
127
|
+
count: number;
|
|
128
|
+
status: string;
|
|
129
|
+
firstSeen: string;
|
|
130
|
+
lastSeen: string;
|
|
131
|
+
samples: string[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Default values for new V2 fields when migrating legacy items.
|
|
136
|
+
*/
|
|
137
|
+
const DEFAULT_TASK_KIND: TaskKind = 'pain_diagnosis';
|
|
138
|
+
const DEFAULT_PRIORITY: TaskPriority = 'medium';
|
|
139
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Migrate a legacy queue item to V2 schema.
|
|
143
|
+
* Old items without taskKind are assumed to be pain_diagnosis for backward compatibility.
|
|
144
|
+
*/
|
|
145
|
+
function migrateToV2(item: LegacyEvolutionQueueItem): EvolutionQueueItem {
|
|
146
|
+
return {
|
|
147
|
+
id: item.id,
|
|
148
|
+
taskKind: (item.taskKind as TaskKind) || DEFAULT_TASK_KIND,
|
|
149
|
+
priority: (item.priority as TaskPriority) || DEFAULT_PRIORITY,
|
|
150
|
+
source: item.source,
|
|
151
|
+
traceId: item.traceId,
|
|
152
|
+
task: item.task,
|
|
153
|
+
score: item.score,
|
|
154
|
+
reason: item.reason,
|
|
155
|
+
timestamp: item.timestamp,
|
|
156
|
+
enqueued_at: item.enqueued_at,
|
|
157
|
+
started_at: item.started_at,
|
|
158
|
+
completed_at: item.completed_at,
|
|
159
|
+
assigned_session_key: item.assigned_session_key,
|
|
160
|
+
trigger_text_preview: item.trigger_text_preview,
|
|
161
|
+
status: (item.status as QueueStatus) || 'pending',
|
|
162
|
+
resolution: item.resolution as TaskResolution | undefined,
|
|
163
|
+
session_id: item.session_id,
|
|
164
|
+
agent_id: item.agent_id,
|
|
165
|
+
retryCount: item.retryCount || 0,
|
|
166
|
+
maxRetries: item.maxRetries || DEFAULT_MAX_RETRIES,
|
|
167
|
+
lastError: item.lastError,
|
|
168
|
+
resultRef: item.resultRef,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
type RawQueueItem = Record<string, unknown>;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Check if an item is a legacy (pre-V2) queue item.
|
|
176
|
+
*/
|
|
177
|
+
function isLegacyQueueItem(item: RawQueueItem): boolean {
|
|
178
|
+
return item && typeof item === 'object' && !('taskKind' in item);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Migrate entire queue to V2 schema if needed.
|
|
183
|
+
* Returns a new array with all items migrated to V2 format.
|
|
184
|
+
*/
|
|
185
|
+
function migrateQueueToV2(queue: RawQueueItem[]): EvolutionQueueItem[] {
|
|
186
|
+
return queue.map(item => isLegacyQueueItem(item) ? migrateToV2(item as unknown as LegacyEvolutionQueueItem) : item as unknown as EvolutionQueueItem);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const PAIN_QUEUE_DEDUP_WINDOW_MS = 30 * 60 * 1000;
|
|
190
|
+
|
|
191
|
+
// P0 fix: File lock constants and helper for queue operations (prevents TOCTOU race)
|
|
192
|
+
export const EVOLUTION_QUEUE_LOCK_SUFFIX = '.lock';
|
|
193
|
+
export const PAIN_CANDIDATES_LOCK_SUFFIX = '.candidates.lock';
|
|
194
|
+
export const LOCK_MAX_RETRIES = 50;
|
|
195
|
+
export const LOCK_RETRY_DELAY_MS = 50;
|
|
196
|
+
export const LOCK_STALE_MS = 30_000;
|
|
197
|
+
const PAIN_CANDIDATE_MAX_SAMPLES = 5;
|
|
198
|
+
const PAIN_CANDIDATE_SAMPLE_LEN = 1000;
|
|
199
|
+
const PAIN_CANDIDATE_FINGERPRINT_HEAD_LEN = 160;
|
|
200
|
+
const PAIN_CANDIDATE_FINGERPRINT_TAIL_LEN = 80;
|
|
201
|
+
|
|
202
|
+
export function createEvolutionTaskId(
|
|
203
|
+
source: string,
|
|
204
|
+
score: number,
|
|
205
|
+
preview: string,
|
|
206
|
+
reason: string,
|
|
207
|
+
now: number
|
|
208
|
+
): string {
|
|
209
|
+
// Keep ids short for prompt injection, but include enough entropy to avoid
|
|
210
|
+
// collisions between different pain events that share the same source/score/preview.
|
|
211
|
+
return createHash('md5')
|
|
212
|
+
.update(`${source}:${score}:${preview}:${reason}:${now}`)
|
|
213
|
+
.digest('hex')
|
|
214
|
+
.substring(0, 8);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function normalizePainCandidateText(text: string): string {
|
|
218
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function shouldTrackPainCandidate(text: string): boolean {
|
|
222
|
+
const normalized = normalizePainCandidateText(text);
|
|
223
|
+
if (!normalized) return false;
|
|
224
|
+
if (normalized === 'NO_REPLY') return false;
|
|
225
|
+
|
|
226
|
+
// Skip empathy observer payloads: they are classifier telemetry, not user/system pain patterns.
|
|
227
|
+
if (
|
|
228
|
+
normalized.startsWith('{')
|
|
229
|
+
&& normalized.endsWith('}')
|
|
230
|
+
&& normalized.includes('"damageDetected"')
|
|
231
|
+
&& normalized.includes('"severity"')
|
|
232
|
+
&& normalized.includes('"confidence"')
|
|
233
|
+
) {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function createPainCandidateFingerprint(text: string): string {
|
|
241
|
+
const normalized = normalizePainCandidateText(text);
|
|
242
|
+
const head = normalized.substring(0, PAIN_CANDIDATE_FINGERPRINT_HEAD_LEN);
|
|
243
|
+
const tail = normalized.slice(-PAIN_CANDIDATE_FINGERPRINT_TAIL_LEN);
|
|
244
|
+
|
|
245
|
+
return createHash('md5')
|
|
246
|
+
.update(`${normalized.length}:${head}:${tail}`)
|
|
247
|
+
.digest('hex')
|
|
248
|
+
.substring(0, 8);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function summarizePainCandidateSample(text: string): string {
|
|
252
|
+
return normalizePainCandidateText(text).substring(0, PAIN_CANDIDATE_SAMPLE_LEN);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function isPendingPainCandidate(status: string | undefined): boolean {
|
|
256
|
+
return status === undefined || status === 'pending';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function acquireQueueLock(resourcePath: string, logger: PluginLogger | { warn?: (message: string) => void; info?: (message: string) => void } | undefined, lockSuffix: string = EVOLUTION_QUEUE_LOCK_SUFFIX): Promise<() => void> {
|
|
260
|
+
try {
|
|
261
|
+
const ctx: LockContext = await acquireLockAsync(resourcePath, {
|
|
262
|
+
lockSuffix,
|
|
263
|
+
maxRetries: LOCK_MAX_RETRIES,
|
|
264
|
+
baseRetryDelayMs: LOCK_RETRY_DELAY_MS,
|
|
265
|
+
lockStaleMs: LOCK_STALE_MS,
|
|
266
|
+
});
|
|
267
|
+
return () => releaseLock(ctx);
|
|
268
|
+
} catch (error: unknown) {
|
|
269
|
+
const warn = logger?.warn;
|
|
270
|
+
warn?.(`[PD:EvolutionWorker] Failed to acquire lock for ${resourcePath}: ${String(error)}`);
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function requireQueueLock(resourcePath: string, logger: PluginLogger | { warn?: (message: string) => void; info?: (message: string) => void } | undefined, scope: string, lockSuffix: string = EVOLUTION_QUEUE_LOCK_SUFFIX): Promise<() => void> {
|
|
276
|
+
try {
|
|
277
|
+
return await acquireQueueLock(resourcePath, logger, lockSuffix);
|
|
278
|
+
} catch (err) {
|
|
279
|
+
throw new LockUnavailableError(resourcePath, scope, { cause: err });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function extractEvolutionTaskId(task: string): string | null {
|
|
284
|
+
if (!task) return null;
|
|
285
|
+
const match = task.match(/\[ID:\s*([A-Za-z0-9_-]+)\]/);
|
|
286
|
+
return match?.[1] || null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function findRecentDuplicateTask(
|
|
290
|
+
queue: EvolutionQueueItem[],
|
|
291
|
+
source: string,
|
|
292
|
+
preview: string,
|
|
293
|
+
now: number,
|
|
294
|
+
reason?: string
|
|
295
|
+
): EvolutionQueueItem | undefined {
|
|
296
|
+
const key = normalizePainDedupKey(source, preview, reason);
|
|
297
|
+
return queue.find((task) => {
|
|
298
|
+
if (task.status === 'completed') return false;
|
|
299
|
+
const taskTime = new Date(task.enqueued_at || task.timestamp).getTime();
|
|
300
|
+
if (!Number.isFinite(taskTime) || (now - taskTime) > PAIN_QUEUE_DEDUP_WINDOW_MS) return false;
|
|
301
|
+
return normalizePainDedupKey(task.source, task.trigger_text_preview || '', task.reason) === key;
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Purge stale failed tasks from the queue.
|
|
307
|
+
* Failed tasks older than the threshold are noise — they won't auto-recover
|
|
308
|
+
* and they bloat the queue, slowing every cycle.
|
|
309
|
+
*
|
|
310
|
+
* Called at the start of each cycle to keep the queue lean.
|
|
311
|
+
*/
|
|
312
|
+
const STALE_FAILED_TASK_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
313
|
+
|
|
314
|
+
export function purgeStaleFailedTasks(
|
|
315
|
+
queue: EvolutionQueueItem[],
|
|
316
|
+
logger: PluginLogger,
|
|
317
|
+
): { purged: number; remaining: number; byReason: Record<string, number> } {
|
|
318
|
+
const beforeCount = queue.length;
|
|
319
|
+
const cutoff = Date.now() - STALE_FAILED_TASK_MAX_AGE_MS;
|
|
320
|
+
const byReason: Record<string, number> = {};
|
|
321
|
+
|
|
322
|
+
const purged = queue.filter((t) => {
|
|
323
|
+
if (t.status !== 'failed') return false;
|
|
324
|
+
const taskTime = new Date(t.timestamp || t.enqueued_at || 0).getTime();
|
|
325
|
+
if (!Number.isFinite(taskTime) || taskTime > cutoff) return false;
|
|
326
|
+
const reason = t.lastError || t.resolution || 'unknown';
|
|
327
|
+
byReason[reason] = (byReason[reason] || 0) + 1;
|
|
328
|
+
return true;
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
if (purged.length === 0) return { purged: 0, remaining: queue.length, byReason };
|
|
332
|
+
|
|
333
|
+
// Remove purged items from the queue (mutates in place)
|
|
334
|
+
const purgedIds = new Set(purged.map((t) => t.id));
|
|
335
|
+
for (let i = queue.length - 1; i >= 0; i--) {
|
|
336
|
+
if (purgedIds.has(queue[i].id)) queue.splice(i, 1);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const summary = Object.entries(byReason)
|
|
340
|
+
.map(([r, c]) => `${c}x ${r}`)
|
|
341
|
+
.join('; ');
|
|
342
|
+
logger?.info?.(`[PD:EvolutionWorker] Purged ${purged.length} stale failed tasks (>24h): ${summary}`);
|
|
343
|
+
|
|
344
|
+
return { purged: purged.length, remaining: queue.length, byReason };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function normalizePainDedupKey(source: string, preview: string, reason?: string): string {
|
|
348
|
+
// Include reason in dedup key to match createEvolutionTaskId() behavior
|
|
349
|
+
// Different reasons for the same source/preview should create different tasks
|
|
350
|
+
const normalizedReason = (reason || '').trim().toLowerCase();
|
|
351
|
+
return `${source.trim().toLowerCase()}::${preview.trim().toLowerCase()}::${normalizedReason}`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function hasRecentDuplicateTask(queue: EvolutionQueueItem[], source: string, preview: string, now: number, reason?: string): boolean {
|
|
355
|
+
return !!findRecentDuplicateTask(queue, source, preview, now, reason);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function hasEquivalentPromotedRule(dictionary: { getAllRules(): Record<string, { type: string; phrases?: string[]; pattern?: string; status: string; }> }, phrase: string): boolean {
|
|
359
|
+
const normalizedPhrase = phrase.trim().toLowerCase();
|
|
360
|
+
return Object.values(dictionary.getAllRules()).some((rule) => {
|
|
361
|
+
if (rule.status !== 'active') return false;
|
|
362
|
+
if (rule.type === 'exact_match' && Array.isArray(rule.phrases)) {
|
|
363
|
+
return rule.phrases.some((candidate) => candidate.trim().toLowerCase() === normalizedPhrase);
|
|
364
|
+
}
|
|
365
|
+
if (rule.type === 'regex' && typeof rule.pattern === 'string') {
|
|
366
|
+
return rule.pattern.trim().toLowerCase() === normalizedPhrase;
|
|
367
|
+
}
|
|
368
|
+
return false;
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Read recent pain context from PAIN_FLAG file.
|
|
374
|
+
* Returns structured pain metadata for attaching to sleep_reflection tasks.
|
|
375
|
+
* Returns null if no pain flag exists.
|
|
376
|
+
*/
|
|
377
|
+
function readRecentPainContext(wctx: WorkspaceContext): RecentPainContext {
|
|
378
|
+
const painFlagPath = wctx.resolve('PAIN_FLAG');
|
|
379
|
+
if (!fs.existsSync(painFlagPath)) {
|
|
380
|
+
return { mostRecent: null, recentPainCount: 0, recentMaxPainScore: 0 };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
const rawPain = fs.readFileSync(painFlagPath, 'utf8');
|
|
385
|
+
const lines = rawPain.split('\n');
|
|
386
|
+
|
|
387
|
+
let score = 0;
|
|
388
|
+
let source = '';
|
|
389
|
+
let reason = '';
|
|
390
|
+
let timestamp = '';
|
|
391
|
+
|
|
392
|
+
for (const line of lines) {
|
|
393
|
+
if (line.startsWith('score:')) score = parseInt(line.split(':', 2)[1].trim(), 10) || 0;
|
|
394
|
+
if (line.startsWith('source:')) source = line.split(':', 2)[1].trim();
|
|
395
|
+
if (line.startsWith('reason:')) reason = line.slice('reason:'.length).trim();
|
|
396
|
+
if (line.startsWith('timestamp:')) timestamp = line.slice('timestamp:'.length).trim();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (score > 0) {
|
|
400
|
+
return {
|
|
401
|
+
mostRecent: { score, source, reason, timestamp },
|
|
402
|
+
recentPainCount: 1,
|
|
403
|
+
recentMaxPainScore: score,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
} catch {
|
|
407
|
+
// Best effort — non-fatal
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return { mostRecent: null, recentPainCount: 0, recentMaxPainScore: 0 };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Enqueue a sleep_reflection task if one is not already pending.
|
|
415
|
+
* Phase 2.4: Called when workspace is idle to trigger nocturnal reflection.
|
|
416
|
+
*/
|
|
417
|
+
async function enqueueSleepReflectionTask(
|
|
418
|
+
wctx: WorkspaceContext,
|
|
419
|
+
logger: PluginLogger
|
|
420
|
+
): Promise<void> {
|
|
421
|
+
const queuePath = wctx.resolve('EVOLUTION_QUEUE');
|
|
422
|
+
const releaseLock = await requireQueueLock(queuePath, logger, 'enqueueSleepReflection', EVOLUTION_QUEUE_LOCK_SUFFIX);
|
|
423
|
+
|
|
424
|
+
try {
|
|
425
|
+
let rawQueue: RawQueueItem[] = [];
|
|
426
|
+
try {
|
|
427
|
+
rawQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
|
|
428
|
+
} catch {
|
|
429
|
+
// Queue doesn't exist yet - create empty array
|
|
430
|
+
rawQueue = [];
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue);
|
|
434
|
+
|
|
435
|
+
// Check if a sleep_reflection task is already pending
|
|
436
|
+
const hasPendingSleepReflection = queue.some(
|
|
437
|
+
t => t.taskKind === 'sleep_reflection' && (t.status === 'pending' || t.status === 'in_progress')
|
|
438
|
+
);
|
|
439
|
+
if (hasPendingSleepReflection) {
|
|
440
|
+
logger?.debug?.('[PD:EvolutionWorker] sleep_reflection task already pending/in-progress, skipping');
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const now = Date.now();
|
|
445
|
+
const taskId = createEvolutionTaskId('nocturnal', 50, 'idle workspace', 'Sleep-mode reflection', now);
|
|
446
|
+
const nowIso = new Date(now).toISOString();
|
|
447
|
+
|
|
448
|
+
// Attach recent pain context if available
|
|
449
|
+
const recentPainContext = readRecentPainContext(wctx);
|
|
450
|
+
|
|
451
|
+
queue.push({
|
|
452
|
+
id: taskId,
|
|
453
|
+
taskKind: 'sleep_reflection',
|
|
454
|
+
priority: 'medium',
|
|
455
|
+
score: 50,
|
|
456
|
+
source: 'nocturnal',
|
|
457
|
+
reason: 'Sleep-mode reflection triggered by idle workspace',
|
|
458
|
+
trigger_text_preview: 'Idle workspace detected',
|
|
459
|
+
timestamp: nowIso,
|
|
460
|
+
enqueued_at: nowIso,
|
|
461
|
+
status: 'pending',
|
|
462
|
+
traceId: taskId,
|
|
463
|
+
retryCount: 0,
|
|
464
|
+
maxRetries: 1, // sleep_reflection doesn't retry
|
|
465
|
+
recentPainContext,
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
|
|
469
|
+
logger?.info?.(`[PD:EvolutionWorker] Enqueued sleep_reflection task ${taskId}`);
|
|
470
|
+
} finally {
|
|
471
|
+
releaseLock();
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
interface ParsedPainValues {
|
|
476
|
+
score: number; source: string; reason: string; preview: string;
|
|
477
|
+
traceId: string; sessionId: string; agentId: string;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function doEnqueuePainTask(
|
|
481
|
+
wctx: WorkspaceContext, logger: PluginLogger, painFlagPath: string,
|
|
482
|
+
result: WorkerStatusReport['pain_flag'], v: ParsedPainValues,
|
|
483
|
+
): Promise<WorkerStatusReport['pain_flag']> {
|
|
484
|
+
result.exists = true;
|
|
485
|
+
result.score = v.score;
|
|
486
|
+
result.source = v.source;
|
|
487
|
+
|
|
488
|
+
if (v.score < 30) {
|
|
489
|
+
result.skipped_reason = `score_too_low (${v.score} < 30)`;
|
|
490
|
+
if (logger) logger.info(`[PD:EvolutionWorker] Pain flag score too low: ${v.score} (source=${v.source})`);
|
|
491
|
+
return result;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const queuePath = wctx.resolve('EVOLUTION_QUEUE');
|
|
495
|
+
const releaseLock = await requireQueueLock(queuePath, logger, 'checkPainFlag');
|
|
496
|
+
try {
|
|
497
|
+
let queue: EvolutionQueueItem[] = [];
|
|
498
|
+
if (fs.existsSync(queuePath)) {
|
|
499
|
+
try { queue = JSON.parse(fs.readFileSync(queuePath, 'utf8')); } catch {}
|
|
500
|
+
}
|
|
501
|
+
const now = Date.now();
|
|
502
|
+
const dup = findRecentDuplicateTask(queue, v.source, v.preview, now, v.reason);
|
|
503
|
+
if (dup) {
|
|
504
|
+
fs.appendFileSync(painFlagPath, `\nstatus: queued\ntask_id: ${dup.id}\n`, 'utf8');
|
|
505
|
+
result.enqueued = true;
|
|
506
|
+
result.skipped_reason = 'duplicate';
|
|
507
|
+
if (logger) logger.info(`[PD:EvolutionWorker] Duplicate pain task skipped for source=${v.source} preview=${v.preview || 'N/A'}`);
|
|
508
|
+
return result;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const taskId = createEvolutionTaskId(v.source, v.score, v.preview, v.reason, now);
|
|
512
|
+
const nowIso = new Date(now).toISOString();
|
|
513
|
+
const effectiveTraceId = v.traceId || taskId;
|
|
514
|
+
|
|
515
|
+
queue.push({
|
|
516
|
+
id: taskId, taskKind: 'pain_diagnosis',
|
|
517
|
+
priority: v.score >= 70 ? 'high' : v.score >= 40 ? 'medium' : 'low',
|
|
518
|
+
score: v.score, source: v.source, reason: v.reason,
|
|
519
|
+
trigger_text_preview: v.preview, timestamp: nowIso, enqueued_at: nowIso,
|
|
520
|
+
status: 'pending', session_id: v.sessionId || undefined,
|
|
521
|
+
agent_id: v.agentId || undefined, traceId: effectiveTraceId,
|
|
522
|
+
retryCount: 0, maxRetries: 3,
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
|
|
526
|
+
fs.appendFileSync(painFlagPath, `\nstatus: queued\ntask_id: ${taskId}\n`, 'utf8');
|
|
527
|
+
result.enqueued = true;
|
|
528
|
+
|
|
529
|
+
if (logger) logger.info(`[PD:EvolutionWorker] Enqueued pain task ${taskId} (score=${v.score})`);
|
|
530
|
+
|
|
531
|
+
const evoLogger = getEvolutionLogger(wctx.workspaceDir, wctx.trajectory);
|
|
532
|
+
evoLogger.logQueued({
|
|
533
|
+
traceId: effectiveTraceId,
|
|
534
|
+
taskId,
|
|
535
|
+
score: v.score,
|
|
536
|
+
source: v.source,
|
|
537
|
+
reason: v.reason,
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
wctx.trajectory?.recordEvolutionTask?.({
|
|
541
|
+
taskId,
|
|
542
|
+
traceId: effectiveTraceId,
|
|
543
|
+
source: v.source,
|
|
544
|
+
reason: v.reason,
|
|
545
|
+
score: v.score,
|
|
546
|
+
status: 'pending',
|
|
547
|
+
enqueuedAt: nowIso,
|
|
548
|
+
});
|
|
549
|
+
} finally { releaseLock(); }
|
|
550
|
+
return result;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Promise<WorkerStatusReport['pain_flag']> {
|
|
554
|
+
const result: WorkerStatusReport['pain_flag'] = { exists: false, score: null, source: null, enqueued: false, skipped_reason: null };
|
|
555
|
+
try {
|
|
556
|
+
const painFlagPath = wctx.resolve('PAIN_FLAG');
|
|
557
|
+
if (!fs.existsSync(painFlagPath)) return result;
|
|
558
|
+
|
|
559
|
+
const rawPain = fs.readFileSync(painFlagPath, 'utf8');
|
|
560
|
+
|
|
561
|
+
// Try JSON format first (pain skill structured output)
|
|
562
|
+
// The file may have 'status: queued' and 'task_id: xxx' appended after the JSON object.
|
|
563
|
+
// Extract just the JSON portion by finding the last '}' and parsing up to that point.
|
|
564
|
+
try {
|
|
565
|
+
const jsonEndIdx = rawPain.lastIndexOf('}');
|
|
566
|
+
const jsonPortion = jsonEndIdx >= 0 ? rawPain.slice(0, jsonEndIdx + 1) : rawPain;
|
|
567
|
+
const jsonPain = JSON.parse(jsonPortion);
|
|
568
|
+
if (typeof jsonPain === 'object' && jsonPain !== null && jsonPain.pain_score !== undefined) {
|
|
569
|
+
const jsonScore = typeof jsonPain.pain_score === 'number' ? jsonPain.pain_score :
|
|
570
|
+
typeof jsonPain.score === 'number' ? jsonPain.score : 50;
|
|
571
|
+
const jsonSource = jsonPain.source || 'human';
|
|
572
|
+
const jsonReason = jsonPain.reason || jsonPain.requested_action || 'Systemic pain detected';
|
|
573
|
+
const jsonPreview = (jsonPain.symptoms || []).slice(0, 2).join('; ');
|
|
574
|
+
|
|
575
|
+
// Check if already queued by looking for 'status: queued' in the full file
|
|
576
|
+
const alreadyQueued = rawPain.includes('status: queued');
|
|
577
|
+
if (alreadyQueued) {
|
|
578
|
+
result.exists = true;
|
|
579
|
+
result.score = jsonScore;
|
|
580
|
+
result.source = jsonSource;
|
|
581
|
+
result.enqueued = true;
|
|
582
|
+
result.skipped_reason = 'already_queued';
|
|
583
|
+
if (logger) logger.info(`[PD:EvolutionWorker] Pain flag already queued (score=${jsonScore}, source=${jsonSource})`);
|
|
584
|
+
return result;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return doEnqueuePainTask(wctx, logger, painFlagPath, result, {
|
|
588
|
+
score: jsonScore, source: jsonSource, reason: jsonReason,
|
|
589
|
+
preview: jsonPreview, traceId: '', sessionId: '', agentId: '',
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
} catch { /* Not JSON — fall through to KV/Markdown parsing */ }
|
|
593
|
+
|
|
594
|
+
const lines = rawPain.split('\n');
|
|
595
|
+
|
|
596
|
+
let score = 0;
|
|
597
|
+
let source = 'unknown';
|
|
598
|
+
let reason = 'Systemic pain detected';
|
|
599
|
+
let preview = '';
|
|
600
|
+
let isQueued = false;
|
|
601
|
+
let traceId = '';
|
|
602
|
+
let sessionId = '';
|
|
603
|
+
let agentId = '';
|
|
604
|
+
|
|
605
|
+
for (const line of lines) {
|
|
606
|
+
if (line.startsWith('score:')) score = parseInt(line.split(':', 2)[1].trim(), 10) || 0;
|
|
607
|
+
if (line.startsWith('source:')) source = line.split(':', 2)[1].trim();
|
|
608
|
+
if (line.startsWith('reason:')) reason = line.slice('reason:'.length).trim();
|
|
609
|
+
if (line.startsWith('trigger_text_preview:')) preview = line.slice('trigger_text_preview:'.length).trim();
|
|
610
|
+
if (line.startsWith('status: queued')) isQueued = true;
|
|
611
|
+
if (line.startsWith('trace_id:')) traceId = line.split(':', 2)[1].trim();
|
|
612
|
+
if (line.startsWith('session_id:')) sessionId = line.slice('session_id:'.length).trim();
|
|
613
|
+
if (line.startsWith('agent_id:')) agentId = line.slice('agent_id:'.length).trim();
|
|
614
|
+
|
|
615
|
+
// Markdown format support (pain skill writes **Source**: xxx format)
|
|
616
|
+
const mdSource = line.match(/\*\*Source\*\*:\s*(.+)/);
|
|
617
|
+
if (mdSource) source = mdSource[1].trim();
|
|
618
|
+
const mdReason = line.match(/\*\*Reason\*\*:\s*(.+)/);
|
|
619
|
+
if (mdReason) reason = mdReason[1].trim();
|
|
620
|
+
const mdTime = line.match(/\*\*Time\*\*:\s*(.+)/);
|
|
621
|
+
if (mdTime) preview = `Human intervention at ${mdTime[1].trim()}`;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Markdown format has no score — default to 50 for human intervention
|
|
625
|
+
if (score === 0 && source !== 'unknown') score = 50;
|
|
626
|
+
|
|
627
|
+
result.exists = true;
|
|
628
|
+
result.score = score;
|
|
629
|
+
result.source = source;
|
|
630
|
+
result.enqueued = isQueued;
|
|
631
|
+
|
|
632
|
+
if (isQueued) {
|
|
633
|
+
result.skipped_reason = 'already_queued';
|
|
634
|
+
if (logger) logger.info(`[PD:EvolutionWorker] Pain flag already queued (score=${score}, source=${source})`);
|
|
635
|
+
return result;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (logger) logger.info(`[PD:EvolutionWorker] Detected pain flag (score: ${score}, source: ${source}). Enqueueing evolution task.`);
|
|
639
|
+
|
|
640
|
+
return doEnqueuePainTask(wctx, logger, painFlagPath, result, {
|
|
641
|
+
score, source, reason, preview,
|
|
642
|
+
traceId, sessionId, agentId,
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
} catch (err) {
|
|
646
|
+
if (logger) logger.warn(`[PD:EvolutionWorker] Error processing pain flag: ${String(err)}`);
|
|
647
|
+
result.skipped_reason = `error: ${String(err)}`;
|
|
648
|
+
}
|
|
649
|
+
return result;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogger, eventLog: EventLog, api?: OpenClawPluginApi) {
|
|
653
|
+
const queuePath = wctx.resolve('EVOLUTION_QUEUE');
|
|
654
|
+
if (!fs.existsSync(queuePath)) {
|
|
655
|
+
logger?.debug?.('[PD:EvolutionWorker] No evolution queue file — nothing to process');
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const releaseLock = await requireQueueLock(queuePath, logger, 'processEvolutionQueue');
|
|
660
|
+
const evoLogger = getEvolutionLogger(wctx.workspaceDir, wctx.trajectory);
|
|
661
|
+
let lockReleased = false;
|
|
662
|
+
|
|
663
|
+
try {
|
|
664
|
+
let rawQueue: RawQueueItem[] = [];
|
|
665
|
+
try {
|
|
666
|
+
rawQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
|
|
667
|
+
} catch (e) {
|
|
668
|
+
// Backup corrupted file instead of silently discarding
|
|
669
|
+
const backupPath = `${queuePath}.corrupted.${Date.now()}`;
|
|
670
|
+
try {
|
|
671
|
+
fs.renameSync(queuePath, backupPath);
|
|
672
|
+
if (logger) {
|
|
673
|
+
logger.error(`[PD:EvolutionWorker] Evolution queue corrupted and backed up to ${backupPath}. All pending tasks have been preserved in the backup file. Parse error: ${String(e)}`);
|
|
674
|
+
}
|
|
675
|
+
SystemLogger.log(wctx.workspaceDir, 'QUEUE_CORRUPTED', `Queue file backed up to ${backupPath}. Error: ${String(e)}`);
|
|
676
|
+
} catch (backupErr) {
|
|
677
|
+
if (logger) {
|
|
678
|
+
logger.error(`[PD:EvolutionWorker] Failed to backup corrupted queue: ${String(backupErr)}`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// V2: Migrate queue to current schema if needed
|
|
685
|
+
const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue);
|
|
686
|
+
|
|
687
|
+
let queueChanged = rawQueue.some(isLegacyQueueItem);
|
|
688
|
+
|
|
689
|
+
const config = wctx.config;
|
|
690
|
+
const timeout = config.get('intervals.task_timeout_ms') || (60 * 60 * 1000); // Default 1 hour
|
|
691
|
+
|
|
692
|
+
// V2: Recover stuck in_progress sleep_reflection tasks.
|
|
693
|
+
// If the worker crashes or the result write-back fails after Phase 1 claimed
|
|
694
|
+
// the task, it stays in_progress indefinitely. Detect via timeout and mark
|
|
695
|
+
// as failed so a fresh task can be enqueued on the next idle cycle.
|
|
696
|
+
for (const task of queue.filter(t => t.status === 'in_progress' && t.taskKind === 'sleep_reflection')) {
|
|
697
|
+
const startedAt = new Date(task.started_at || task.timestamp);
|
|
698
|
+
const age = Date.now() - startedAt.getTime();
|
|
699
|
+
if (age > timeout) {
|
|
700
|
+
task.status = 'failed';
|
|
701
|
+
task.completed_at = new Date().toISOString();
|
|
702
|
+
task.resolution = 'failed_max_retries';
|
|
703
|
+
task.lastError = `sleep_reflection timed out after ${Math.round(timeout / 60000)} minutes`;
|
|
704
|
+
task.retryCount = (task.retryCount ?? 0) + 1;
|
|
705
|
+
queueChanged = true;
|
|
706
|
+
logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${task.id} timed out after ${Math.round(age / 60000)} minutes, marking as failed`);
|
|
707
|
+
evoLogger.logCompleted({
|
|
708
|
+
traceId: task.traceId || task.id,
|
|
709
|
+
taskId: task.id,
|
|
710
|
+
resolution: 'manual',
|
|
711
|
+
durationMs: age,
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Check in_progress tasks for completion (only pain_diagnosis gets HEARTBEAT treatment)
|
|
717
|
+
// Diagnostician runs via HEARTBEAT (main session LLM), not as a subagent.
|
|
718
|
+
// Marker file detection is the ONLY completion path for HEARTBEAT diagnostics.
|
|
719
|
+
for (const task of queue.filter(t => t.status === 'in_progress' && t.taskKind === 'pain_diagnosis')) {
|
|
720
|
+
const startedAt = new Date(task.started_at || task.timestamp);
|
|
721
|
+
|
|
722
|
+
// Condition 1: Check for marker file (created by diagnostician on completion)
|
|
723
|
+
const completeMarker = path.join(wctx.stateDir, `.evolution_complete_${task.id}`);
|
|
724
|
+
if (fs.existsSync(completeMarker)) {
|
|
725
|
+
if (logger) logger.info(`[PD:EvolutionWorker] Task ${task.id} completed - marker file detected`);
|
|
726
|
+
|
|
727
|
+
// Create principle from the diagnostician's JSON report.
|
|
728
|
+
const reportPath = path.join(wctx.stateDir, `.diagnostician_report_${task.id}.json`);
|
|
729
|
+
if (fs.existsSync(reportPath)) {
|
|
730
|
+
try {
|
|
731
|
+
const reportData = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
|
732
|
+
// Check ALL known nesting paths — matches subagent.ts parseDiagnosticianReport
|
|
733
|
+
const principle = reportData?.principle
|
|
734
|
+
|| reportData?.phases?.principle_extraction?.principle
|
|
735
|
+
|| reportData?.diagnosis_report?.principle
|
|
736
|
+
|| reportData?.diagnosis_report?.phases?.principle_extraction?.principle;
|
|
737
|
+
if (principle?.trigger_pattern && principle?.action) {
|
|
738
|
+
// Check for duplicate principle (diagnostician may output existing principle)
|
|
739
|
+
if (principle.duplicate === true) {
|
|
740
|
+
logger.info(`[PD:EvolutionWorker] Diagnostician marked principle as duplicate: ${principle.duplicate_of || 'unknown'} — skipping creation for task ${task.id}`);
|
|
741
|
+
task.status = 'completed';
|
|
742
|
+
task.completed_at = new Date().toISOString();
|
|
743
|
+
task.resolution = 'marker_detected';
|
|
744
|
+
} else {
|
|
745
|
+
logger.info(`[PD:EvolutionWorker] Creating principle from report for task ${task.id}`);
|
|
746
|
+
const principleId = wctx.evolutionReducer.createPrincipleFromDiagnosis({
|
|
747
|
+
painId: task.id,
|
|
748
|
+
painType: task.source === 'Human Intervention' ? 'user_frustration' : 'tool_failure',
|
|
749
|
+
triggerPattern: principle.trigger_pattern,
|
|
750
|
+
action: principle.action,
|
|
751
|
+
source: task.source || 'heartbeat_diagnostician',
|
|
752
|
+
evaluability: principle.evaluability || 'manual_only',
|
|
753
|
+
abstractedPrinciple: principle.abstracted_principle,
|
|
754
|
+
});
|
|
755
|
+
if (principleId) {
|
|
756
|
+
logger.info(`[PD:EvolutionWorker] Created principle ${principleId} from marker fallback for task ${task.id}`);
|
|
757
|
+
} else {
|
|
758
|
+
logger.warn(`[PD:EvolutionWorker] createPrincipleFromDiagnosis returned null for task ${task.id} (may be duplicate or blacklisted)`);
|
|
759
|
+
}
|
|
760
|
+
task.status = 'completed';
|
|
761
|
+
task.completed_at = new Date().toISOString();
|
|
762
|
+
task.resolution = 'marker_detected';
|
|
763
|
+
}
|
|
764
|
+
} else {
|
|
765
|
+
logger.warn(`[PD:EvolutionWorker] Diagnostician report for task ${task.id} missing principle fields — diagnostician did not produce a principle`);
|
|
766
|
+
}
|
|
767
|
+
} catch (err) {
|
|
768
|
+
logger.warn(`[PD:EvolutionWorker] Failed to parse diagnostician report for task ${task.id}: ${String(err)}`);
|
|
769
|
+
}
|
|
770
|
+
} else {
|
|
771
|
+
logger.warn(`[PD:EvolutionWorker] No diagnostician report found for completed task ${task.id} (expected: .diagnostician_report_${task.id}.json)`);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
task.status = 'completed';
|
|
775
|
+
task.completed_at = new Date().toISOString();
|
|
776
|
+
task.resolution = 'marker_detected';
|
|
777
|
+
try {
|
|
778
|
+
fs.unlinkSync(completeMarker);
|
|
779
|
+
} catch {}
|
|
780
|
+
|
|
781
|
+
// Log to EvolutionLogger
|
|
782
|
+
const durationMs = task.started_at
|
|
783
|
+
? Date.now() - new Date(task.started_at).getTime()
|
|
784
|
+
: undefined;
|
|
785
|
+
evoLogger.logCompleted({
|
|
786
|
+
traceId: task.traceId || task.id,
|
|
787
|
+
taskId: task.id,
|
|
788
|
+
resolution: 'marker_detected',
|
|
789
|
+
durationMs,
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
// Update evolution_tasks table
|
|
793
|
+
wctx.trajectory?.updateEvolutionTask?.(task.id, {
|
|
794
|
+
status: 'completed',
|
|
795
|
+
completedAt: task.completed_at,
|
|
796
|
+
resolution: 'marker_detected',
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
wctx.trajectory?.recordTaskOutcome({
|
|
800
|
+
sessionId: task.assigned_session_key || 'heartbeat:diagnostician',
|
|
801
|
+
taskId: task.id,
|
|
802
|
+
outcome: 'ok',
|
|
803
|
+
summary: `Task ${task.id} completed - marker file detected.`
|
|
804
|
+
});
|
|
805
|
+
queueChanged = true;
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const age = Date.now() - startedAt.getTime();
|
|
810
|
+
if (age > timeout) {
|
|
811
|
+
const timeoutMinutes = Math.round(timeout / 60000);
|
|
812
|
+
|
|
813
|
+
const completeMarker = path.join(wctx.stateDir, `.evolution_complete_${task.id}`);
|
|
814
|
+
const reportPath = path.join(wctx.stateDir, `.diagnostician_report_${task.id}.json`);
|
|
815
|
+
|
|
816
|
+
if (fs.existsSync(completeMarker) && fs.existsSync(reportPath)) {
|
|
817
|
+
if (logger) logger.info(`[PD:EvolutionWorker] Task ${task.id} timed out but marker found — creating principle anyway`);
|
|
818
|
+
let principleCreated = false;
|
|
819
|
+
try {
|
|
820
|
+
const reportData = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
|
821
|
+
const principle = reportData?.principle
|
|
822
|
+
|| reportData?.phases?.principle_extraction?.principle
|
|
823
|
+
|| reportData?.diagnosis_report?.principle
|
|
824
|
+
|| reportData?.diagnosis_report?.phases?.principle_extraction?.principle;
|
|
825
|
+
if (principle?.trigger_pattern && principle?.action) {
|
|
826
|
+
if (principle.duplicate === true) {
|
|
827
|
+
logger.info(`[PD:EvolutionWorker] Diagnostician marked principle as duplicate: ${principle.duplicate_of || 'unknown'} — skipping for task ${task.id}`);
|
|
828
|
+
} else {
|
|
829
|
+
const principleId = wctx.evolutionReducer.createPrincipleFromDiagnosis({
|
|
830
|
+
painId: task.id,
|
|
831
|
+
painType: task.source === 'Human Intervention' ? 'user_frustration' : 'tool_failure',
|
|
832
|
+
triggerPattern: principle.trigger_pattern,
|
|
833
|
+
action: principle.action,
|
|
834
|
+
source: task.source || 'heartbeat_diagnostician',
|
|
835
|
+
evaluability: principle.evaluability || 'manual_only',
|
|
836
|
+
abstractedPrinciple: principle.abstracted_principle,
|
|
837
|
+
});
|
|
838
|
+
if (principleId) {
|
|
839
|
+
logger.info(`[PD:EvolutionWorker] Created principle ${principleId} from late marker for task ${task.id}`);
|
|
840
|
+
principleCreated = true;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
} catch (err) {
|
|
845
|
+
logger.warn(`[PD:EvolutionWorker] Failed to parse late diagnostician report for task ${task.id}: ${String(err)}`);
|
|
846
|
+
}
|
|
847
|
+
try { fs.unlinkSync(completeMarker); } catch {}
|
|
848
|
+
task.resolution = principleCreated ? 'late_marker_principle_created' : 'late_marker_no_principle';
|
|
849
|
+
} else {
|
|
850
|
+
if (logger) logger.info(`[PD:EvolutionWorker] Task ${task.id} auto-completed after ${timeoutMinutes} minute timeout`);
|
|
851
|
+
task.resolution = 'auto_completed_timeout';
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Critical: mark task as completed so it doesn't get re-processed
|
|
855
|
+
task.status = 'completed';
|
|
856
|
+
task.completed_at = new Date().toISOString();
|
|
857
|
+
|
|
858
|
+
// Log to EvolutionLogger - use task.resolution, not hardcoded value
|
|
859
|
+
evoLogger.logCompleted({
|
|
860
|
+
traceId: task.traceId || task.id,
|
|
861
|
+
taskId: task.id,
|
|
862
|
+
resolution: task.resolution,
|
|
863
|
+
durationMs: age,
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
// Update evolution_tasks table - use task.resolution, not hardcoded value
|
|
867
|
+
wctx.trajectory?.updateEvolutionTask?.(task.id, {
|
|
868
|
+
status: 'completed',
|
|
869
|
+
completedAt: task.completed_at,
|
|
870
|
+
resolution: task.resolution,
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
wctx.trajectory?.recordTaskOutcome({
|
|
874
|
+
sessionId: task.assigned_session_key || 'heartbeat:diagnostician',
|
|
875
|
+
taskId: task.id,
|
|
876
|
+
outcome: 'timeout',
|
|
877
|
+
summary: `Task ${task.id} auto-completed after ${timeoutMinutes} minute timeout.`
|
|
878
|
+
});
|
|
879
|
+
queueChanged = true;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// V2: Process pain_diagnosis tasks FIRST (quick, inside lock),
|
|
884
|
+
// then sleep_reflection tasks (slow, lock released during execution).
|
|
885
|
+
// This order ensures pain tasks are never starved by long-running
|
|
886
|
+
// nocturnal reflection — sleep_reflection can safely return early
|
|
887
|
+
// because pain_diagnosis has already been handled.
|
|
888
|
+
const pendingTasks = queue.filter(t => t.status === 'pending' && t.taskKind === 'pain_diagnosis');
|
|
889
|
+
|
|
890
|
+
if (pendingTasks.length > 0) {
|
|
891
|
+
// V2: Also sort by priority within same score
|
|
892
|
+
const priorityWeight = { high: 3, medium: 2, low: 1 };
|
|
893
|
+
const highestScoreTask = pendingTasks.sort((a, b) => {
|
|
894
|
+
const scoreDiff = b.score - a.score;
|
|
895
|
+
if (scoreDiff !== 0) return scoreDiff;
|
|
896
|
+
return (priorityWeight[b.priority] || 2) - (priorityWeight[a.priority] || 2);
|
|
897
|
+
})[0];
|
|
898
|
+
const nowIso = new Date().toISOString();
|
|
899
|
+
|
|
900
|
+
const taskDescription = `Diagnose systemic pain [ID: ${highestScoreTask.id}]. Source: ${highestScoreTask.source}. Reason: ${highestScoreTask.reason}. ` +
|
|
901
|
+
`Trigger text: "${highestScoreTask.trigger_text_preview || 'N/A'}"`;
|
|
902
|
+
|
|
903
|
+
// Prepare HEARTBEAT content first
|
|
904
|
+
// Use shared diagnostician protocol (consistent with pd-diagnostician skill)
|
|
905
|
+
const heartbeatPath = wctx.resolve('HEARTBEAT');
|
|
906
|
+
const markerFilePath = path.join(wctx.stateDir, `.evolution_complete_${highestScoreTask.id}`);
|
|
907
|
+
const reportFilePath = path.join(wctx.stateDir, `.diagnostician_report_${highestScoreTask.id}.json`);
|
|
908
|
+
|
|
909
|
+
let existingPrinciplesRef = '';
|
|
910
|
+
try {
|
|
911
|
+
const activePrinciples = wctx.evolutionReducer.getActivePrinciples();
|
|
912
|
+
if (activePrinciples.length > 0) {
|
|
913
|
+
// Include all principles up to 20 — enough for duplicate detection
|
|
914
|
+
// without overwhelming the context window
|
|
915
|
+
const maxPrinciples = 20;
|
|
916
|
+
const included = activePrinciples.length > maxPrinciples
|
|
917
|
+
? activePrinciples.slice(-maxPrinciples)
|
|
918
|
+
: activePrinciples;
|
|
919
|
+
const lines = included.map((p) => {
|
|
920
|
+
let line = `### ${p.id}: ${p.text}`;
|
|
921
|
+
if (p.priority && p.priority !== 'P1') line += ` [${p.priority}]`;
|
|
922
|
+
if (p.scope === 'domain' && p.domain) line += ` (domain: ${p.domain})`;
|
|
923
|
+
return line;
|
|
924
|
+
});
|
|
925
|
+
existingPrinciplesRef = `\n**Existing Principles for Duplicate Detection** (showing ${included.length}/${activePrinciples.length}):\n${lines.join('\n')}`;
|
|
926
|
+
|
|
927
|
+
// Also inject suggested rules from existing principles (if any)
|
|
928
|
+
const rulesByPrinciple = included.filter((p) => p.suggestedRules?.length);
|
|
929
|
+
if (rulesByPrinciple.length > 0) {
|
|
930
|
+
const ruleLines = rulesByPrinciple.flatMap((p) =>
|
|
931
|
+
(p.suggestedRules ?? []).map((r) => `- [${p.id}] **${r.name}**: ${r.action} (type: ${r.type}, enforce: ${r.enforcement})`),
|
|
932
|
+
);
|
|
933
|
+
existingPrinciplesRef += `\n\n**Suggested Rules from Existing Principles**:\n${ruleLines.join('\n')}`;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
} catch {}
|
|
937
|
+
|
|
938
|
+
const heartbeatContent = [
|
|
939
|
+
`## Evolution Task [ID: ${highestScoreTask.id}]`,
|
|
940
|
+
``,
|
|
941
|
+
`**Pain Score**: ${highestScoreTask.score}`,
|
|
942
|
+
`**Source**: ${highestScoreTask.source}`,
|
|
943
|
+
`**Reason**: ${highestScoreTask.reason}`,
|
|
944
|
+
`**Trigger**: "${highestScoreTask.trigger_text_preview || 'N/A'}"`,
|
|
945
|
+
`**Queued At**: ${highestScoreTask.enqueued_at || nowIso}`,
|
|
946
|
+
`**Session ID**: ${highestScoreTask.session_id || 'N/A'}`,
|
|
947
|
+
`**Agent ID**: ${highestScoreTask.agent_id || 'main'}`,
|
|
948
|
+
``,
|
|
949
|
+
`---`,
|
|
950
|
+
``,
|
|
951
|
+
`## Diagnostician Protocol`,
|
|
952
|
+
``,
|
|
953
|
+
`You MUST use the **pd-diagnostician** skill for this task.`,
|
|
954
|
+
`Read the full skill definition and follow the 4-phase protocol (Evidence → Causal Chain → Classification → Principle Extraction) EXACTLY as specified.`,
|
|
955
|
+
`The skill defines the complete output contract — your JSON report MUST match the format specified in the skill.`,
|
|
956
|
+
``,
|
|
957
|
+
`---`,
|
|
958
|
+
``,
|
|
959
|
+
`After completing the analysis:`,
|
|
960
|
+
`1. Write your JSON diagnosis report to: ${reportFilePath}`,
|
|
961
|
+
` The JSON structure MUST match the output format defined in the pd-diagnostician skill.`,
|
|
962
|
+
`2. Mark the task complete by creating a marker file: ${markerFilePath}`,
|
|
963
|
+
` The marker file should contain: "diagnostic_completed: <timestamp>\\noutcome: <summary>"`,
|
|
964
|
+
`3. Replace this HEARTBEAT.md content with "HEARTBEAT_OK"`,
|
|
965
|
+
existingPrinciplesRef,
|
|
966
|
+
].join('\n');
|
|
967
|
+
|
|
968
|
+
// Try to write HEARTBEAT.md FIRST
|
|
969
|
+
// Only mark task as in_progress after successful write to avoid stuck tasks
|
|
970
|
+
try {
|
|
971
|
+
fs.writeFileSync(heartbeatPath, heartbeatContent, 'utf8');
|
|
972
|
+
if (logger) logger.info(`[PD:EvolutionWorker] Wrote diagnostician task to HEARTBEAT.md for task ${highestScoreTask.id}`);
|
|
973
|
+
|
|
974
|
+
// HEARTBEAT write succeeded, now mark task as in_progress
|
|
975
|
+
highestScoreTask.task = taskDescription;
|
|
976
|
+
highestScoreTask.status = 'in_progress';
|
|
977
|
+
highestScoreTask.started_at = nowIso;
|
|
978
|
+
delete highestScoreTask.completed_at;
|
|
979
|
+
// Use placeholder so marker path can correlate task (no subagent spawned for HEARTBEAT)
|
|
980
|
+
// This fixes task_outcomes being empty for HEARTBEAT-triggered diagnostician runs
|
|
981
|
+
highestScoreTask.assigned_session_key = `heartbeat:diagnostician:${highestScoreTask.id}`;
|
|
982
|
+
queueChanged = true;
|
|
983
|
+
|
|
984
|
+
// Log to EvolutionLogger
|
|
985
|
+
evoLogger.logStarted({
|
|
986
|
+
traceId: highestScoreTask.traceId || highestScoreTask.id,
|
|
987
|
+
taskId: highestScoreTask.id,
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
// Update evolution_tasks table
|
|
991
|
+
wctx.trajectory?.updateEvolutionTask?.(highestScoreTask.id, {
|
|
992
|
+
status: 'in_progress',
|
|
993
|
+
startedAt: nowIso,
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
if (eventLog) {
|
|
997
|
+
eventLog.recordEvolutionTask({
|
|
998
|
+
taskId: highestScoreTask.id,
|
|
999
|
+
taskType: highestScoreTask.source,
|
|
1000
|
+
reason: highestScoreTask.reason
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
} catch (heartbeatErr) {
|
|
1004
|
+
// HEARTBEAT write failed - keep task as pending for next cycle retry
|
|
1005
|
+
if (logger) logger.error(`[PD:EvolutionWorker] Failed to write HEARTBEAT.md for task ${highestScoreTask.id}: ${String(heartbeatErr)}. Task will remain pending for next cycle.`);
|
|
1006
|
+
SystemLogger.log(wctx.workspaceDir, 'HEARTBEAT_WRITE_FAILED', `Task ${highestScoreTask.id} HEARTBEAT write failed: ${String(heartbeatErr)}`);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Phase 2.4: Process sleep_reflection tasks AFTER pain_diagnosis.
|
|
1011
|
+
// Claim tasks inside the lock, execute reflection outside the lock,
|
|
1012
|
+
// then re-acquire the lock to write results. This prevents the long-running
|
|
1013
|
+
// nocturnal reflection from blocking all other queue consumers.
|
|
1014
|
+
// Safe to return early here because pain_diagnosis was already handled above.
|
|
1015
|
+
const sleepReflectionTasks = queue.filter(t => t.status === 'pending' && t.taskKind === 'sleep_reflection');
|
|
1016
|
+
if (sleepReflectionTasks.length > 0) {
|
|
1017
|
+
// --- Phase 1: Claim tasks (inside lock) ---
|
|
1018
|
+
for (const sleepTask of sleepReflectionTasks) {
|
|
1019
|
+
sleepTask.status = 'in_progress';
|
|
1020
|
+
sleepTask.started_at = new Date().toISOString();
|
|
1021
|
+
}
|
|
1022
|
+
queueChanged = true;
|
|
1023
|
+
|
|
1024
|
+
// Write claimed state (includes any pain changes from above) and release lock
|
|
1025
|
+
if (queueChanged) {
|
|
1026
|
+
fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
|
|
1027
|
+
}
|
|
1028
|
+
releaseLock();
|
|
1029
|
+
for (const sleepTask of sleepReflectionTasks) {
|
|
1030
|
+
try {
|
|
1031
|
+
logger?.info?.(`[PD:EvolutionWorker] Processing sleep_reflection task ${sleepTask.id}`);
|
|
1032
|
+
|
|
1033
|
+
// NOC-14: Use NocturnalWorkflowManager for sleep_reflection tasks
|
|
1034
|
+
// Lazy-create manager (needs runtimeAdapter from api)
|
|
1035
|
+
let nocturnalManager: NocturnalWorkflowManager | undefined;
|
|
1036
|
+
if (api) {
|
|
1037
|
+
nocturnalManager = new NocturnalWorkflowManager({
|
|
1038
|
+
workspaceDir: wctx.workspaceDir,
|
|
1039
|
+
stateDir: wctx.stateDir,
|
|
1040
|
+
logger: api.logger,
|
|
1041
|
+
runtimeAdapter: new OpenClawTrinityRuntimeAdapter(api),
|
|
1042
|
+
});
|
|
1043
|
+
} else {
|
|
1044
|
+
// Cannot create manager without api (runtimeAdapter required)
|
|
1045
|
+
sleepTask.status = 'failed';
|
|
1046
|
+
sleepTask.completed_at = new Date().toISOString();
|
|
1047
|
+
sleepTask.resolution = 'failed_max_retries';
|
|
1048
|
+
sleepTask.lastError = 'No API available to create NocturnalWorkflowManager';
|
|
1049
|
+
sleepTask.retryCount = (sleepTask.retryCount ?? 0) + 1;
|
|
1050
|
+
logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} skipped: no API`);
|
|
1051
|
+
continue;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Start workflow via NocturnalWorkflowManager instead of direct executeNocturnalReflectionAsync
|
|
1055
|
+
// Pass taskId in metadata for correlation
|
|
1056
|
+
const workflowHandle = await nocturnalManager.startWorkflow(nocturnalWorkflowSpec, {
|
|
1057
|
+
parentSessionId: `sleep_reflection:${sleepTask.id}`,
|
|
1058
|
+
workspaceDir: wctx.workspaceDir,
|
|
1059
|
+
taskInput: {},
|
|
1060
|
+
metadata: {
|
|
1061
|
+
snapshot: sleepTask.recentPainContext ? {
|
|
1062
|
+
sessionId: sleepTask.id,
|
|
1063
|
+
sessionStart: sleepTask.timestamp,
|
|
1064
|
+
stats: { totalAssistantTurns: 0, totalToolCalls: 0, failureCount: 0, totalPainEvents: sleepTask.recentPainContext.recentPainCount, totalGateBlocks: 0 },
|
|
1065
|
+
recentPain: sleepTask.recentPainContext.mostRecent ? [sleepTask.recentPainContext.mostRecent] : [],
|
|
1066
|
+
} : undefined,
|
|
1067
|
+
principleId: 'default',
|
|
1068
|
+
taskId: sleepTask.id, // NOC-14: correlation ID for evolution worker
|
|
1069
|
+
},
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
// Store workflowId on task for polling on subsequent cycles
|
|
1073
|
+
sleepTask.resultRef = workflowHandle.workflowId;
|
|
1074
|
+
|
|
1075
|
+
// Workflow is running asynchronously. Check if it completed in this cycle
|
|
1076
|
+
// by polling getWorkflowDebugSummary.
|
|
1077
|
+
const summary = await nocturnalManager.getWorkflowDebugSummary(workflowHandle.workflowId);
|
|
1078
|
+
if (summary) {
|
|
1079
|
+
if (summary.state === 'completed') {
|
|
1080
|
+
sleepTask.status = 'completed';
|
|
1081
|
+
sleepTask.completed_at = new Date().toISOString();
|
|
1082
|
+
sleepTask.resolution = 'marker_detected';
|
|
1083
|
+
sleepTask.resultRef = summary.metadata?.nocturnalResult ? 'trinity-draft' : workflowHandle.workflowId;
|
|
1084
|
+
logger?.info?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} workflow completed`);
|
|
1085
|
+
} else if (summary.state === 'terminal_error') {
|
|
1086
|
+
sleepTask.status = 'failed';
|
|
1087
|
+
sleepTask.completed_at = new Date().toISOString();
|
|
1088
|
+
sleepTask.resolution = 'failed_max_retries';
|
|
1089
|
+
const lastEvent = summary.recentEvents[summary.recentEvents.length - 1];
|
|
1090
|
+
sleepTask.lastError = `Workflow terminal_error: ${lastEvent?.reason ?? 'unknown'}`;
|
|
1091
|
+
sleepTask.retryCount = (sleepTask.retryCount ?? 0) + 1;
|
|
1092
|
+
logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} workflow failed: ${sleepTask.lastError}`);
|
|
1093
|
+
} else {
|
|
1094
|
+
// Workflow still active, keep task in_progress for next cycle
|
|
1095
|
+
logger?.info?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} workflow ${summary.state}, will poll again next cycle`);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
} catch (taskErr) {
|
|
1099
|
+
sleepTask.status = 'failed';
|
|
1100
|
+
sleepTask.completed_at = new Date().toISOString();
|
|
1101
|
+
sleepTask.resolution = 'failed_max_retries';
|
|
1102
|
+
sleepTask.lastError = String(taskErr);
|
|
1103
|
+
sleepTask.retryCount = (sleepTask.retryCount ?? 0) + 1;
|
|
1104
|
+
logger?.error?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} threw: ${taskErr}`);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// --- Phase 3: Write results back (re-acquire lock) ---
|
|
1109
|
+
try {
|
|
1110
|
+
const resultLock = await requireQueueLock(queuePath, logger, 'sleepReflectionResult');
|
|
1111
|
+
try {
|
|
1112
|
+
// Re-read queue to merge with any changes made while lock was released
|
|
1113
|
+
let freshQueue: (RawQueueItem | EvolutionQueueItem)[] = [];
|
|
1114
|
+
try {
|
|
1115
|
+
freshQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
|
|
1116
|
+
} catch { /* empty queue if corrupted */ }
|
|
1117
|
+
|
|
1118
|
+
// Merge: update tasks by ID
|
|
1119
|
+
for (const sleepTask of sleepReflectionTasks) {
|
|
1120
|
+
const idx = freshQueue.findIndex((t) => (t as { id?: string }).id === sleepTask.id);
|
|
1121
|
+
if (idx >= 0) {
|
|
1122
|
+
freshQueue[idx] = sleepTask;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
fs.writeFileSync(queuePath, JSON.stringify(freshQueue, null, 2), 'utf8');
|
|
1126
|
+
|
|
1127
|
+
// Log completions to EvolutionLogger
|
|
1128
|
+
for (const sleepTask of sleepReflectionTasks) {
|
|
1129
|
+
if (sleepTask.status === 'completed' || sleepTask.status === 'failed') {
|
|
1130
|
+
evoLogger.logCompleted({
|
|
1131
|
+
traceId: sleepTask.traceId || sleepTask.id,
|
|
1132
|
+
taskId: sleepTask.id,
|
|
1133
|
+
resolution: sleepTask.status === 'completed'
|
|
1134
|
+
? (sleepTask.resolution === 'marker_detected' ? 'marker_detected' : 'manual')
|
|
1135
|
+
: 'manual',
|
|
1136
|
+
durationMs: sleepTask.started_at
|
|
1137
|
+
? Date.now() - new Date(sleepTask.started_at).getTime()
|
|
1138
|
+
: undefined,
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
} finally {
|
|
1143
|
+
resultLock();
|
|
1144
|
+
}
|
|
1145
|
+
} catch (resultLockErr) {
|
|
1146
|
+
// If we can't re-acquire lock, results are in memory but not persisted.
|
|
1147
|
+
// Tasks will appear stuck as in_progress and will be retried on next cycle.
|
|
1148
|
+
logger?.warn?.(`[PD:EvolutionWorker] Failed to write sleep_reflection results back: ${String(resultLockErr)}`);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// Safe to return — pain_diagnosis was already processed above.
|
|
1152
|
+
lockReleased = true;
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
if (queueChanged) {
|
|
1157
|
+
fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
// Pipeline observability: log stage-level summary at end of cycle
|
|
1161
|
+
const pendingPain = queue.filter((t) => t.status === 'pending' && t.taskKind === 'pain_diagnosis').length;
|
|
1162
|
+
const inProgressPain = queue.filter((t) => t.status === 'in_progress' && t.taskKind === 'pain_diagnosis').length;
|
|
1163
|
+
if (inProgressPain > 0) {
|
|
1164
|
+
const stuck = queue
|
|
1165
|
+
.filter((t) => t.status === 'in_progress' && t.taskKind === 'pain_diagnosis')
|
|
1166
|
+
.map((t) => `${t.id} (since ${t.started_at || 'unknown'})`);
|
|
1167
|
+
logger?.info?.(`[PD:EvolutionWorker] Pipeline: ${inProgressPain} pain_diagnosis task(s) in_progress — awaiting agent response: ${stuck.join(', ')}`);
|
|
1168
|
+
}
|
|
1169
|
+
if (pendingPain > 0) {
|
|
1170
|
+
logger?.info?.(`[PD:EvolutionWorker] Pipeline: ${pendingPain} pain_diagnosis task(s) pending — HEARTBEAT.md will trigger next cycle`);
|
|
1171
|
+
}
|
|
1172
|
+
const painCompleted = queue.filter((t) => t.status === 'completed' && t.taskKind === 'pain_diagnosis').length;
|
|
1173
|
+
logger?.info?.(`[PD:EvolutionWorker] Pipeline summary: pain_completed=${painCompleted} pain_pending=${pendingPain} pain_in_progress=${inProgressPain}`);
|
|
1174
|
+
} catch (err) {
|
|
1175
|
+
if (logger) logger.warn(`[PD:EvolutionWorker] Error processing evolution queue: ${String(err)}`);
|
|
1176
|
+
} finally {
|
|
1177
|
+
if (!lockReleased) {
|
|
1178
|
+
releaseLock();
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
async function processDetectionQueue(wctx: WorkspaceContext, api: OpenClawPluginApi, eventLog: EventLog) {
|
|
1184
|
+
const logger = api.logger;
|
|
1185
|
+
try {
|
|
1186
|
+
const funnel = DetectionService.get(wctx.stateDir);
|
|
1187
|
+
const queue = funnel.flushQueue();
|
|
1188
|
+
if (queue.length === 0) return;
|
|
1189
|
+
|
|
1190
|
+
if (logger) logger.info(`[PD:EvolutionWorker] Processing ${queue.length} items from detection funnel.`);
|
|
1191
|
+
|
|
1192
|
+
const dictionary = DictionaryService.get(wctx.stateDir);
|
|
1193
|
+
|
|
1194
|
+
for (const text of queue) {
|
|
1195
|
+
const match = dictionary.match(text);
|
|
1196
|
+
if (match) {
|
|
1197
|
+
if (eventLog) {
|
|
1198
|
+
eventLog.recordRuleMatch(undefined, {
|
|
1199
|
+
ruleId: match.ruleId,
|
|
1200
|
+
layer: 'L2',
|
|
1201
|
+
severity: match.severity,
|
|
1202
|
+
textPreview: text.substring(0, 100)
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
} else {
|
|
1206
|
+
// L3 semantic search via trajectory database FTS5 (MEM-04)
|
|
1207
|
+
if (wctx.trajectory) {
|
|
1208
|
+
const searchResults = wctx.trajectory.searchPainEvents(text, 5);
|
|
1209
|
+
if (searchResults.length > 0) {
|
|
1210
|
+
// Found similar pain events - record as L3 semantic hit
|
|
1211
|
+
if (eventLog) {
|
|
1212
|
+
eventLog.recordRuleMatch(undefined, {
|
|
1213
|
+
ruleId: 'l3_semantic',
|
|
1214
|
+
layer: 'L3',
|
|
1215
|
+
severity: searchResults[0].score,
|
|
1216
|
+
textPreview: text.substring(0, 100)
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
// Update detection funnel cache with L3 hit result
|
|
1220
|
+
funnel.updateCache(text, { detected: true, severity: searchResults[0].score });
|
|
1221
|
+
// Don't track as candidate - this is a confirmed L3 hit
|
|
1222
|
+
if (logger) logger.info(`[PD:EvolutionWorker] L3 semantic hit: found ${searchResults.length} similar pain events for "${text.substring(0, 50)}..."`);
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
// No L3 hit - fall through to track as pain candidate
|
|
1227
|
+
await trackPainCandidate(text, wctx);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
} catch (err) {
|
|
1231
|
+
if (logger) logger.warn(`[PD:EvolutionWorker] Detection queue failed: ${String(err)}`);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
export async function trackPainCandidate(text: string, wctx: WorkspaceContext) {
|
|
1236
|
+
if (!shouldTrackPainCandidate(text)) return;
|
|
1237
|
+
|
|
1238
|
+
const candidatePath = wctx.resolve('PAIN_CANDIDATES');
|
|
1239
|
+
const releaseLock = await requireQueueLock(candidatePath, console, 'trackPainCandidate', PAIN_CANDIDATES_LOCK_SUFFIX);
|
|
1240
|
+
|
|
1241
|
+
try {
|
|
1242
|
+
let data: { candidates: Record<string, PainCandidateEntry> } = { candidates: {} };
|
|
1243
|
+
if (fs.existsSync(candidatePath)) {
|
|
1244
|
+
try {
|
|
1245
|
+
data = JSON.parse(fs.readFileSync(candidatePath, 'utf8'));
|
|
1246
|
+
} catch (e) {
|
|
1247
|
+
// Keep going with empty data if parse fails, but log it
|
|
1248
|
+
// eslint-disable-next-line no-console
|
|
1249
|
+
console.warn(`[PD:EvolutionWorker] Failed to parse pain candidates: ${String(e)}`);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
const fingerprint = createPainCandidateFingerprint(text);
|
|
1254
|
+
const now = new Date().toISOString();
|
|
1255
|
+
if (!data.candidates[fingerprint]) {
|
|
1256
|
+
data.candidates[fingerprint] = { count: 0, status: 'pending', firstSeen: now, lastSeen: now, samples: [] };
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const cand = data.candidates[fingerprint];
|
|
1260
|
+
cand.status = cand.status || 'pending';
|
|
1261
|
+
cand.count++;
|
|
1262
|
+
cand.lastSeen = now;
|
|
1263
|
+
|
|
1264
|
+
const sample = summarizePainCandidateSample(text);
|
|
1265
|
+
if (cand.samples.length < PAIN_CANDIDATE_MAX_SAMPLES && !cand.samples.includes(sample)) {
|
|
1266
|
+
cand.samples.push(sample);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
fs.writeFileSync(candidatePath, JSON.stringify(data, null, 2), 'utf8');
|
|
1270
|
+
} finally {
|
|
1271
|
+
releaseLock();
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
export async function processPromotion(wctx: WorkspaceContext, logger: PluginLogger, eventLog: EventLog) {
|
|
1276
|
+
const candidatePath = wctx.resolve('PAIN_CANDIDATES');
|
|
1277
|
+
if (!fs.existsSync(candidatePath)) return;
|
|
1278
|
+
|
|
1279
|
+
const releaseLock = await requireQueueLock(candidatePath, logger, 'processPromotion', PAIN_CANDIDATES_LOCK_SUFFIX);
|
|
1280
|
+
|
|
1281
|
+
try {
|
|
1282
|
+
const config = wctx.config;
|
|
1283
|
+
const dictionary = wctx.dictionary;
|
|
1284
|
+
const data: { candidates: Record<string, PainCandidateEntry> } = JSON.parse(fs.readFileSync(candidatePath, 'utf8'));
|
|
1285
|
+
const countThreshold = config.get('thresholds.promotion_count_threshold') || 3;
|
|
1286
|
+
|
|
1287
|
+
let promotedCount = 0;
|
|
1288
|
+
let changed = false;
|
|
1289
|
+
|
|
1290
|
+
for (const [fingerprint, cand] of Object.entries(data.candidates)) {
|
|
1291
|
+
if (isPendingPainCandidate(cand.status) && cand.count >= countThreshold) {
|
|
1292
|
+
// Normalize undefined status to 'pending'
|
|
1293
|
+
if (cand.status !== 'pending') {
|
|
1294
|
+
cand.status = 'pending';
|
|
1295
|
+
changed = true;
|
|
1296
|
+
}
|
|
1297
|
+
const commonPhrases = extractCommonSubstring(cand.samples);
|
|
1298
|
+
|
|
1299
|
+
if (commonPhrases.length > 0) {
|
|
1300
|
+
const phrase = commonPhrases[0];
|
|
1301
|
+
const ruleId = `P_PROMOTED_${fingerprint.toUpperCase()}`;
|
|
1302
|
+
|
|
1303
|
+
if (hasEquivalentPromotedRule(dictionary, phrase)) {
|
|
1304
|
+
cand.status = 'duplicate';
|
|
1305
|
+
changed = true;
|
|
1306
|
+
logger?.info?.(`[PD:EvolutionWorker] Skipping duplicate promoted rule for candidate ${fingerprint}: ${phrase}`);
|
|
1307
|
+
continue;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
if (logger) logger.info(`[PD:EvolutionWorker] Promoting candidate ${fingerprint} to formal rule: ${ruleId}`);
|
|
1311
|
+
SystemLogger.log(wctx.workspaceDir, 'RULE_PROMOTED', `Candidate ${fingerprint} promoted to rule ${ruleId}`);
|
|
1312
|
+
|
|
1313
|
+
dictionary.addRule(ruleId, {
|
|
1314
|
+
type: 'exact_match',
|
|
1315
|
+
phrases: [phrase],
|
|
1316
|
+
severity: config.get('scores.default_confusion') || 35,
|
|
1317
|
+
status: 'active'
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
cand.status = 'promoted';
|
|
1321
|
+
promotedCount++;
|
|
1322
|
+
changed = true;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
if (changed) {
|
|
1328
|
+
fs.writeFileSync(candidatePath, JSON.stringify(data, null, 2), 'utf8');
|
|
1329
|
+
}
|
|
1330
|
+
} catch (err) {
|
|
1331
|
+
if (logger) logger.warn(`[PD:EvolutionWorker] Error during rule promotion: ${String(err)}`);
|
|
1332
|
+
} finally {
|
|
1333
|
+
releaseLock();
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
export async function registerEvolutionTaskSession(
|
|
1338
|
+
workspaceResolve: (key: string) => string,
|
|
1339
|
+
taskId: string,
|
|
1340
|
+
sessionKey: string,
|
|
1341
|
+
logger?: { warn?: (message: string) => void; info?: (message: string) => void }
|
|
1342
|
+
): Promise<boolean> {
|
|
1343
|
+
const queuePath = workspaceResolve('EVOLUTION_QUEUE');
|
|
1344
|
+
if (!fs.existsSync(queuePath)) return false;
|
|
1345
|
+
|
|
1346
|
+
const releaseLock = await requireQueueLock(queuePath, logger, 'registerEvolutionTaskSession');
|
|
1347
|
+
|
|
1348
|
+
try {
|
|
1349
|
+
let rawQueue: RawQueueItem[];
|
|
1350
|
+
try {
|
|
1351
|
+
rawQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
|
|
1352
|
+
} catch (parseErr) {
|
|
1353
|
+
logger?.warn?.(`[PD:EvolutionWorker] Failed to parse EVOLUTION_QUEUE for session registration: ${queuePath} - ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`);
|
|
1354
|
+
return false;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// V2: Migrate queue to current schema
|
|
1358
|
+
const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue);
|
|
1359
|
+
|
|
1360
|
+
const task = queue.find((item) => item.id === taskId && item.status === 'in_progress');
|
|
1361
|
+
if (!task) {
|
|
1362
|
+
logger?.warn?.(`[PD:EvolutionWorker] Could not find in-progress evolution task ${taskId} for session assignment`);
|
|
1363
|
+
return false;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
task.assigned_session_key = sessionKey;
|
|
1367
|
+
if (!task.started_at) {
|
|
1368
|
+
task.started_at = new Date().toISOString();
|
|
1369
|
+
}
|
|
1370
|
+
fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
|
|
1371
|
+
return true;
|
|
1372
|
+
} finally {
|
|
1373
|
+
releaseLock();
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* Evolution Worker - Background service for pain processing and evolution task management.
|
|
1379
|
+
*
|
|
1380
|
+
* IMPORTANT: evolution_directive.json is a COMPATIBILITY-ONLY DISPLAY ARTIFACT.
|
|
1381
|
+
* This service does NOT read or use directive for Phase 3 eligibility or any decisions.
|
|
1382
|
+
* Queue (EVOLUTION_QUEUE) is the only authoritative execution truth source.
|
|
1383
|
+
*
|
|
1384
|
+
* Directive exists solely for UI/backwards compatibility display purposes.
|
|
1385
|
+
* Production evidence shows directive stopped updating on 2026-03-22 and is stale.
|
|
1386
|
+
*/
|
|
1387
|
+
|
|
1388
|
+
export interface ExtendedEvolutionWorkerService {
|
|
1389
|
+
id: string;
|
|
1390
|
+
api: OpenClawPluginApi | null;
|
|
1391
|
+
start: (ctx: OpenClawPluginServiceContext) => void | Promise<void>;
|
|
1392
|
+
stop?: (ctx: OpenClawPluginServiceContext) => void | Promise<void>;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
interface WorkerStatusReport {
|
|
1396
|
+
timestamp: string;
|
|
1397
|
+
cycle_start_ms: number;
|
|
1398
|
+
duration_ms: number;
|
|
1399
|
+
pain_flag: { exists: boolean; score: number | null; source: string | null; enqueued: boolean; skipped_reason: string | null };
|
|
1400
|
+
queue: { total: number; pending: number; in_progress: number; completed_this_cycle: number; failed_this_cycle: number };
|
|
1401
|
+
errors: string[];
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
function writeWorkerStatus(stateDir: string, report: WorkerStatusReport): void {
|
|
1405
|
+
try {
|
|
1406
|
+
const statusPath = path.join(stateDir, 'worker-status.json');
|
|
1407
|
+
fs.writeFileSync(statusPath, JSON.stringify(report, null, 2), 'utf8');
|
|
1408
|
+
} catch {}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
async function processEvolutionQueueWithResult(
|
|
1412
|
+
wctx: WorkspaceContext,
|
|
1413
|
+
logger: PluginLogger,
|
|
1414
|
+
eventLog: EventLog,
|
|
1415
|
+
api?: OpenClawPluginApi | undefined
|
|
1416
|
+
): Promise<{ queue: WorkerStatusReport['queue']; errors: string[] }> {
|
|
1417
|
+
const queueResult: WorkerStatusReport['queue'] = { total: 0, pending: 0, in_progress: 0, completed_this_cycle: 0, failed_this_cycle: 0 };
|
|
1418
|
+
const errors: string[] = [];
|
|
1419
|
+
|
|
1420
|
+
try {
|
|
1421
|
+
const queuePath = wctx.resolve('EVOLUTION_QUEUE');
|
|
1422
|
+
if (!fs.existsSync(queuePath)) {
|
|
1423
|
+
return { queue: queueResult, errors };
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
const queue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
|
|
1427
|
+
|
|
1428
|
+
// Purge stale failed tasks before processing (keeps queue lean)
|
|
1429
|
+
const purgeResult = purgeStaleFailedTasks(queue, logger);
|
|
1430
|
+
if (purgeResult.purged > 0) {
|
|
1431
|
+
// Write back the cleaned queue
|
|
1432
|
+
fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
queueResult.total = queue.length;
|
|
1436
|
+
queueResult.pending = queue.filter((t: any) => t.status === 'pending').length;
|
|
1437
|
+
queueResult.in_progress = queue.filter((t: any) => t.status === 'in_progress').length;
|
|
1438
|
+
queueResult.failed_this_cycle = queue.filter((t: any) => t.status === 'failed').length;
|
|
1439
|
+
queueResult.completed_this_cycle = queue.filter((t: any) => t.status === 'completed').length;
|
|
1440
|
+
|
|
1441
|
+
// Log queue health snapshot every cycle
|
|
1442
|
+
logger.info(`[PD:EvolutionWorker] Queue snapshot: total=${queueResult.total} pending=${queueResult.pending} in_progress=${queueResult.in_progress} completed=${queueResult.completed_this_cycle} failed=${queueResult.failed_this_cycle} purged=${purgeResult.purged}`);
|
|
1443
|
+
|
|
1444
|
+
await processEvolutionQueue(wctx, logger, eventLog, api);
|
|
1445
|
+
} catch (err) {
|
|
1446
|
+
const errMsg = `processEvolutionQueue failed: ${String(err)}`;
|
|
1447
|
+
errors.push(errMsg);
|
|
1448
|
+
logger.error(`[PD:EvolutionWorker] ${errMsg}`);
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
return { queue: queueResult, errors };
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
|
|
1455
|
+
id: 'principles-evolution-worker',
|
|
1456
|
+
api: null,
|
|
1457
|
+
|
|
1458
|
+
start(ctx: OpenClawPluginServiceContext): void {
|
|
1459
|
+
const logger = ctx?.logger || console;
|
|
1460
|
+
const api = this.api;
|
|
1461
|
+
const workspaceDir = ctx?.workspaceDir;
|
|
1462
|
+
|
|
1463
|
+
if (!workspaceDir) {
|
|
1464
|
+
if (logger) logger.warn('[PD:EvolutionWorker] workspaceDir not found in service config. Evolution cycle disabled.');
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
const wctx = WorkspaceContext.fromHookContext({ workspaceDir, ...ctx.config });
|
|
1469
|
+
if (logger) logger.info(`[PD:EvolutionWorker] Starting with workspaceDir=${wctx.workspaceDir}, stateDir=${wctx.stateDir}`);
|
|
1470
|
+
|
|
1471
|
+
initPersistence(wctx.stateDir);
|
|
1472
|
+
const eventLog = wctx.eventLog;
|
|
1473
|
+
|
|
1474
|
+
const config = wctx.config;
|
|
1475
|
+
const language = config.get('language') || 'en';
|
|
1476
|
+
ensureStateTemplates({ logger }, wctx.stateDir, language);
|
|
1477
|
+
|
|
1478
|
+
const initialDelay = 5000;
|
|
1479
|
+
const interval = config.get('intervals.worker_poll_ms') || (15 * 60 * 1000);
|
|
1480
|
+
|
|
1481
|
+
async function runCycle(): Promise<void> {
|
|
1482
|
+
const cycleStart = Date.now();
|
|
1483
|
+
const cycleResult: {
|
|
1484
|
+
timestamp: string;
|
|
1485
|
+
cycle_start_ms: number;
|
|
1486
|
+
duration_ms: number;
|
|
1487
|
+
pain_flag: { exists: boolean; score: number | null; source: string | null; enqueued: boolean; skipped_reason: string | null };
|
|
1488
|
+
queue: { total: number; pending: number; in_progress: number; completed_this_cycle: number; failed_this_cycle: number };
|
|
1489
|
+
errors: string[];
|
|
1490
|
+
} = {
|
|
1491
|
+
timestamp: new Date().toISOString(),
|
|
1492
|
+
cycle_start_ms: cycleStart,
|
|
1493
|
+
duration_ms: 0,
|
|
1494
|
+
pain_flag: { exists: false, score: null, source: null, enqueued: false, skipped_reason: null },
|
|
1495
|
+
queue: { total: 0, pending: 0, in_progress: 0, completed_this_cycle: 0, failed_this_cycle: 0 },
|
|
1496
|
+
errors: [],
|
|
1497
|
+
};
|
|
1498
|
+
|
|
1499
|
+
try {
|
|
1500
|
+
const idleResult = checkWorkspaceIdle(wctx.workspaceDir, {});
|
|
1501
|
+
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}`);
|
|
1502
|
+
if (idleResult.isIdle) {
|
|
1503
|
+
logger?.debug?.(`[PD:EvolutionWorker] Workspace idle (${idleResult.idleForMs}ms since last activity)`);
|
|
1504
|
+
const cooldown = checkCooldown(wctx.stateDir);
|
|
1505
|
+
if (!cooldown.globalCooldownActive && !cooldown.quotaExhausted) {
|
|
1506
|
+
enqueueSleepReflectionTask(wctx, logger).catch((err) => {
|
|
1507
|
+
logger?.error?.(`[PD:EvolutionWorker] Failed to enqueue sleep_reflection task: ${String(err)}`);
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
} else {
|
|
1511
|
+
logger?.debug?.(`[PD:EvolutionWorker] Workspace active (last activity ${idleResult.idleForMs}ms ago)`);
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
const painCheckResult = await checkPainFlag(wctx, logger);
|
|
1515
|
+
cycleResult.pain_flag = painCheckResult;
|
|
1516
|
+
|
|
1517
|
+
const queueResult = await processEvolutionQueueWithResult(wctx, logger, eventLog, api ?? undefined);
|
|
1518
|
+
cycleResult.queue = queueResult.queue;
|
|
1519
|
+
if (queueResult.errors) cycleResult.errors.push(...queueResult.errors);
|
|
1520
|
+
|
|
1521
|
+
// If pain flag was enqueued AND processEvolutionQueue wrote HEARTBEAT.md
|
|
1522
|
+
// with a diagnostician task, immediately trigger a heartbeat to start
|
|
1523
|
+
// the diagnostician without waiting for the next 15-minute interval.
|
|
1524
|
+
// Must run AFTER processEvolutionQueue — HEARTBEAT.md must be written first.
|
|
1525
|
+
if (painCheckResult.enqueued) {
|
|
1526
|
+
const canTrigger = !!api?.runtime?.system?.runHeartbeatOnce;
|
|
1527
|
+
logger.info(`[PD:EvolutionWorker] Pain flag enqueued — runHeartbeatOnce available: ${canTrigger} (api=${!!api}, runtime=${!!api?.runtime}, system=${!!api?.runtime?.system})`);
|
|
1528
|
+
if (canTrigger) {
|
|
1529
|
+
try {
|
|
1530
|
+
const hbResult = await api.runtime.system.runHeartbeatOnce({
|
|
1531
|
+
reason: `pd-pain-diagnosis: pain flag detected, starting diagnostician`,
|
|
1532
|
+
});
|
|
1533
|
+
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}` : ''}`);
|
|
1534
|
+
if (hbResult.status === 'skipped' || hbResult.status === 'failed') {
|
|
1535
|
+
logger.warn(`[PD:EvolutionWorker] Immediate heartbeat was ${hbResult.status} (${hbResult.reason}). Diagnostician will start on next regular heartbeat cycle.`);
|
|
1536
|
+
}
|
|
1537
|
+
} catch (hbErr) {
|
|
1538
|
+
logger.warn(`[PD:EvolutionWorker] Failed to trigger immediate heartbeat: ${String(hbErr)}. Diagnostician will start on next regular heartbeat cycle.`);
|
|
1539
|
+
}
|
|
1540
|
+
} else {
|
|
1541
|
+
logger.warn(`[PD:EvolutionWorker] runHeartbeatOnce not available. Diagnostician will start on next regular heartbeat cycle.`);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
if (api) {
|
|
1546
|
+
await processDetectionQueue(wctx, api, eventLog);
|
|
1547
|
+
}
|
|
1548
|
+
await processPromotion(wctx, logger, eventLog);
|
|
1549
|
+
|
|
1550
|
+
try {
|
|
1551
|
+
// Delegate to workflow managers' sweepExpiredWorkflows so that
|
|
1552
|
+
// session/transcript cleanup runs via driver.deleteSession().
|
|
1553
|
+
const subagentRuntime = api?.runtime?.subagent;
|
|
1554
|
+
if (subagentRuntime) {
|
|
1555
|
+
const empathyMgr = new EmpathyObserverWorkflowManager({
|
|
1556
|
+
workspaceDir: wctx.workspaceDir,
|
|
1557
|
+
logger: api.logger,
|
|
1558
|
+
subagent: subagentRuntime,
|
|
1559
|
+
});
|
|
1560
|
+
let swept = 0;
|
|
1561
|
+
try {
|
|
1562
|
+
swept += await empathyMgr.sweepExpiredWorkflows(WORKFLOW_TTL_MS);
|
|
1563
|
+
} finally {
|
|
1564
|
+
empathyMgr.dispose();
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
const deepReflectMgr = new DeepReflectWorkflowManager({
|
|
1568
|
+
workspaceDir: wctx.workspaceDir,
|
|
1569
|
+
logger: api.logger,
|
|
1570
|
+
subagent: subagentRuntime,
|
|
1571
|
+
});
|
|
1572
|
+
try {
|
|
1573
|
+
swept += await deepReflectMgr.sweepExpiredWorkflows(WORKFLOW_TTL_MS);
|
|
1574
|
+
} finally {
|
|
1575
|
+
deepReflectMgr.dispose();
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
if (swept > 0) {
|
|
1579
|
+
logger?.info?.(`[PD:EvolutionWorker] Swept ${swept} expired workflows (with session cleanup)`);
|
|
1580
|
+
}
|
|
1581
|
+
} else {
|
|
1582
|
+
// Fallback: if subagent runtime unavailable, mark as expired
|
|
1583
|
+
// but log that session cleanup was skipped.
|
|
1584
|
+
const workflowStore = new WorkflowStore({ workspaceDir: wctx.workspaceDir });
|
|
1585
|
+
const expiredWorkflows = workflowStore.getExpiredWorkflows(WORKFLOW_TTL_MS);
|
|
1586
|
+
for (const wf of expiredWorkflows) {
|
|
1587
|
+
workflowStore.updateWorkflowState(wf.workflow_id, 'expired');
|
|
1588
|
+
workflowStore.updateCleanupState(wf.workflow_id, 'failed');
|
|
1589
|
+
workflowStore.recordEvent(wf.workflow_id, 'swept', wf.state, 'expired', 'TTL expired (no runtime for session cleanup)', {});
|
|
1590
|
+
logger?.warn?.(`[PD:EvolutionWorker] Marked workflow ${wf.workflow_id} as expired but could not cleanup session (subagent runtime unavailable)`);
|
|
1591
|
+
}
|
|
1592
|
+
workflowStore.dispose();
|
|
1593
|
+
}
|
|
1594
|
+
} catch (sweepErr) {
|
|
1595
|
+
const errMsg = `Failed to sweep expired workflows: ${String(sweepErr)}`;
|
|
1596
|
+
cycleResult.errors.push(errMsg);
|
|
1597
|
+
logger?.warn?.(`[PD:EvolutionWorker] ${errMsg}`);
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
wctx.dictionary.flush();
|
|
1601
|
+
flushAllSessions();
|
|
1602
|
+
|
|
1603
|
+
cycleResult.duration_ms = Date.now() - cycleStart;
|
|
1604
|
+
writeWorkerStatus(wctx.stateDir, cycleResult);
|
|
1605
|
+
} catch (err) {
|
|
1606
|
+
const errMsg = `Error in worker interval: ${String(err)}`;
|
|
1607
|
+
if (logger) logger.error(`[PD:EvolutionWorker] ${errMsg}`);
|
|
1608
|
+
writeWorkerStatus(wctx.stateDir, {
|
|
1609
|
+
timestamp: new Date().toISOString(),
|
|
1610
|
+
cycle_start_ms: cycleStart,
|
|
1611
|
+
duration_ms: Date.now() - cycleStart,
|
|
1612
|
+
pain_flag: { exists: false, score: null, source: null, enqueued: false, skipped_reason: null },
|
|
1613
|
+
queue: { total: 0, pending: 0, in_progress: 0, completed_this_cycle: 0, failed_this_cycle: 0 },
|
|
1614
|
+
errors: [errMsg],
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
timeoutId = setTimeout(runCycle, interval);
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
timeoutId = setTimeout(() => {
|
|
1622
|
+
void (async () => {
|
|
1623
|
+
await checkPainFlag(wctx, logger);
|
|
1624
|
+
// Use the same pipeline as regular cycles (includes purge + observability)
|
|
1625
|
+
const queueResult = await processEvolutionQueueWithResult(wctx, logger, eventLog, api ?? undefined);
|
|
1626
|
+
if (queueResult.errors.length > 0) {
|
|
1627
|
+
queueResult.errors.forEach((e) => logger?.error?.(`[PD:EvolutionWorker] Startup cycle error: ${e}`));
|
|
1628
|
+
}
|
|
1629
|
+
if (api) {
|
|
1630
|
+
await processDetectionQueue(wctx, api, eventLog);
|
|
1631
|
+
}
|
|
1632
|
+
await processPromotion(wctx, logger, eventLog);
|
|
1633
|
+
timeoutId = setTimeout(runCycle, interval);
|
|
1634
|
+
})().catch((err) => {
|
|
1635
|
+
if (logger) logger.error(`[PD:EvolutionWorker] Startup worker cycle failed: ${String(err)}`);
|
|
1636
|
+
timeoutId = setTimeout(runCycle, interval);
|
|
1637
|
+
});
|
|
1638
|
+
}, initialDelay);
|
|
1639
|
+
},
|
|
1640
|
+
|
|
1641
|
+
stop(ctx: OpenClawPluginServiceContext): void {
|
|
1642
|
+
if (ctx?.logger) ctx.logger.info('[PD:EvolutionWorker] Stopping background service...');
|
|
1643
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
1644
|
+
flushAllSessions();
|
|
1645
|
+
}
|
|
1646
|
+
};
|