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