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