principles-disciple 1.41.0 → 1.43.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/.planning/codebase/ARCHITECTURE.md +157 -0
- package/.planning/codebase/CONCERNS.md +145 -0
- package/.planning/codebase/CONVENTIONS.md +148 -0
- package/.planning/codebase/INTEGRATIONS.md +81 -0
- package/.planning/codebase/STACK.md +87 -0
- package/.planning/codebase/STRUCTURE.md +193 -0
- package/.planning/codebase/TESTING.md +243 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/commands/archive-impl.ts +5 -3
- package/src/commands/context.ts +1 -0
- package/src/commands/disable-impl.ts +1 -1
- package/src/commands/evolution-status.ts +2 -2
- package/src/commands/pain.ts +12 -5
- package/src/commands/principle-rollback.ts +1 -1
- package/src/commands/promote-impl.ts +13 -7
- package/src/commands/rollback.ts +10 -4
- package/src/commands/samples.ts +1 -1
- package/src/commands/thinking-os.ts +1 -0
- package/src/commands/workflow-debug.ts +1 -1
- package/src/core/config.ts +1 -0
- package/src/core/dictionary.ts +1 -0
- package/src/core/event-log.ts +8 -6
- package/src/core/evolution-types.ts +33 -1
- package/src/core/external-training-contract.ts +1 -1
- package/src/core/merge-gate-audit.ts +3 -3
- package/src/core/nocturnal-arbiter.ts +1 -1
- package/src/core/nocturnal-compliance.ts +21 -21
- package/src/core/nocturnal-executability.ts +1 -1
- package/src/core/nocturnal-reasoning-deriver.ts +4 -4
- package/src/core/nocturnal-rule-implementation-validator.ts +1 -1
- package/src/core/nocturnal-snapshot-contract.ts +1 -1
- package/src/core/pain-context-extractor.ts +2 -2
- package/src/core/path-resolver.ts +1 -0
- package/src/core/pd-task-reconciler.ts +1 -0
- package/src/core/pd-task-service.ts +1 -1
- package/src/core/pd-task-store.ts +1 -0
- package/src/core/principle-internalization/deprecated-readiness.ts +1 -1
- package/src/core/principle-internalization/principle-lifecycle-service.ts +1 -1
- package/src/core/principle-training-state.ts +2 -2
- package/src/core/principle-tree-migration.ts +1 -1
- package/src/core/replay-engine.ts +1 -0
- package/src/core/risk-calculator.ts +2 -1
- package/src/core/rule-host.ts +1 -1
- package/src/core/session-tracker.ts +1 -0
- package/src/core/shadow-observation-registry.ts +1 -1
- package/src/core/thinking-models.ts +1 -1
- package/src/core/thinking-os-parser.ts +1 -1
- package/src/core/trajectory.ts +2 -0
- package/src/hooks/bash-risk.ts +2 -2
- package/src/hooks/edit-verification.ts +3 -3
- package/src/hooks/gate.ts +8 -8
- package/src/hooks/gfi-gate.ts +2 -2
- package/src/hooks/lifecycle-routing.ts +1 -1
- package/src/hooks/message-sanitize.ts +18 -5
- package/src/hooks/pain.ts +2 -2
- package/src/hooks/progressive-trust-gate.ts +3 -3
- package/src/hooks/prompt.ts +17 -4
- package/src/hooks/subagent.ts +2 -3
- package/src/hooks/thinking-checkpoint.ts +1 -1
- package/src/http/principles-console-route.ts +21 -4
- package/src/service/central-database.ts +3 -2
- package/src/service/central-health-service.ts +2 -1
- package/src/service/central-overview-service.ts +3 -2
- package/src/service/control-ui-query-service.ts +2 -2
- package/src/service/event-log-auditor.ts +2 -2
- package/src/service/evolution-query-service.ts +1 -1
- package/src/service/evolution-worker.ts +96 -370
- package/src/service/health-query-service.ts +11 -10
- package/src/service/monitoring-query-service.ts +4 -4
- package/src/service/nocturnal-target-selector.ts +2 -2
- package/src/service/queue-io.ts +375 -0
- package/src/service/queue-migration.ts +122 -0
- package/src/service/runtime-summary-service.ts +1 -1
- package/src/service/sleep-cycle.ts +157 -0
- package/src/service/subagent-workflow/empathy-observer-workflow-manager.ts +1 -0
- package/src/service/subagent-workflow/runtime-direct-driver.ts +1 -1
- package/src/service/subagent-workflow/subagent-error-utils.ts +1 -1
- package/src/service/subagent-workflow/workflow-store.ts +3 -2
- package/src/service/workflow-watchdog.ts +168 -0
- package/src/tools/critique-prompt.ts +1 -1
- package/src/tools/deep-reflect.ts +22 -11
- package/src/tools/model-index.ts +1 -1
- package/src/types/event-payload.ts +80 -0
- package/src/types/queue.ts +70 -0
- package/src/utils/file-lock.ts +2 -2
- package/src/utils/io.ts +11 -3
- package/tests/core/evolution-migration.test.ts +325 -1
- package/tests/core/queue-purge.test.ts +337 -0
- package/tests/fixtures/legacy-queue-v1.json +74 -0
- package/tests/queue/async-lock.test.ts +200 -0
- package/tests/service/evolution-worker.queue.test.ts +296 -0
- package/tests/service/queue-io.test.ts +229 -0
- package/tests/service/queue-migration.test.ts +147 -0
- package/tests/service/workflow-watchdog.test.ts +372 -0
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
1
3
|
/* global NodeJS */
|
|
4
|
+
|
|
2
5
|
import * as fs from 'fs';
|
|
3
6
|
import * as path from 'path';
|
|
4
|
-
import { createHash } from 'crypto';
|
|
5
7
|
import type { OpenClawPluginServiceContext, OpenClawPluginApi, PluginLogger } from '../openclaw-sdk.js';
|
|
6
8
|
import { DictionaryService } from '../core/dictionary-service.js';
|
|
7
9
|
import { DetectionService } from '../core/detection-service.js';
|
|
@@ -12,11 +14,20 @@ import type { EventLog } from '../core/event-log.js';
|
|
|
12
14
|
import { initPersistence, flushAllSessions } from '../core/session-tracker.js';
|
|
13
15
|
import { addDiagnosticianTask, completeDiagnosticianTask } from '../core/diagnostician-task-store.js';
|
|
14
16
|
import { getEvolutionLogger } from '../core/evolution-logger.js';
|
|
17
|
+
import type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
|
|
18
|
+
export type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
|
|
15
19
|
import { atomicWriteFileSync } from '../utils/io.js';
|
|
20
|
+
|
|
21
|
+
// Re-export queue I/O (extracted to queue-io.ts)
|
|
22
|
+
export { loadEvolutionQueue, saveEvolutionQueue, withQueueLock, acquireQueueLock, requireQueueLock } from './queue-io.js';
|
|
23
|
+
export { enqueueSleepReflectionTask, enqueueKeywordOptimizationTask } from './queue-io.js';
|
|
24
|
+
export { EVOLUTION_QUEUE_LOCK_SUFFIX, LOCK_MAX_RETRIES, LOCK_RETRY_DELAY_MS, LOCK_STALE_MS } from './queue-io.js';
|
|
25
|
+
import { saveEvolutionQueue, requireQueueLock, hasPendingTask, enqueueSleepReflectionTask, enqueueKeywordOptimizationTask, createEvolutionTaskId } from './queue-io.js';
|
|
26
|
+
import type { RecentPainContext } from './queue-io.js';
|
|
27
|
+
export type { RecentPainContext } from './queue-io.js';
|
|
16
28
|
import { checkWorkspaceIdle, checkCooldown, recordCooldown } from './nocturnal-runtime.js';
|
|
17
29
|
import { loadCooldownEscalationConfig, loadNocturnalConfigMerged } from './nocturnal-config.js';
|
|
18
30
|
import { WorkflowStore } from './subagent-workflow/workflow-store.js';
|
|
19
|
-
import type { WorkflowRow } from './subagent-workflow/types.js';
|
|
20
31
|
import { EmpathyObserverWorkflowManager } from './subagent-workflow/empathy-observer-workflow-manager.js';
|
|
21
32
|
import { DeepReflectWorkflowManager } from './subagent-workflow/deep-reflect-workflow-manager.js';
|
|
22
33
|
import { NocturnalWorkflowManager, nocturnalWorkflowSpec } from './subagent-workflow/nocturnal-workflow-manager.js';
|
|
@@ -29,168 +40,58 @@ import { validateNocturnalSnapshotIngress } from '../core/nocturnal-snapshot-con
|
|
|
29
40
|
import { isExpectedSubagentError } from './subagent-workflow/subagent-error-utils.js';
|
|
30
41
|
import { readPainFlagContract } from '../core/pain.js';
|
|
31
42
|
import { CorrectionObserverWorkflowManager, correctionObserverWorkflowSpec } from './subagent-workflow/correction-observer-workflow-manager.js';
|
|
43
|
+
import { findRecentDuplicateTask } from './evolution-dedup.js';
|
|
32
44
|
import type { CorrectionObserverPayload } from './subagent-workflow/correction-observer-types.js';
|
|
33
45
|
import { KeywordOptimizationService } from './keyword-optimization-service.js';
|
|
34
46
|
import { TrajectoryRegistry } from '../core/trajectory.js';
|
|
35
47
|
import { CorrectionCueLearner } from '../core/correction-cue-learner.js';
|
|
36
|
-
import { isLegacyQueueItem, migrateQueueToV2, type RawQueueItem, type TaskKind, type TaskPriority } from './evolution-queue-migration.js';
|
|
37
|
-
export type { TaskKind, TaskPriority } from './evolution-queue-migration.js';
|
|
38
|
-
export { acquireQueueLock, EVOLUTION_QUEUE_LOCK_SUFFIX, LOCK_MAX_RETRIES, LOCK_RETRY_DELAY_MS, LOCK_STALE_MS } from './evolution-queue-lock.js';
|
|
39
|
-
import { requireQueueLock, EVOLUTION_QUEUE_LOCK_SUFFIX } from './evolution-queue-lock.js';
|
|
40
|
-
import { readRecentPainContext, buildPainSourceKey, hasRecentSimilarReflection } from './evolution-pain-context.js';
|
|
41
|
-
import { findRecentDuplicateTask } from './evolution-dedup.js';
|
|
42
48
|
import { classifyFailure, type ClassifiableTaskKind } from './failure-classifier.js';
|
|
43
49
|
import { recordPersistentFailure, resetFailureState, isTaskKindInCooldown } from './cooldown-strategy.js';
|
|
44
50
|
import { reconcileStartup } from './startup-reconciler.js';
|
|
45
51
|
import { WORKFLOW_TTL_MS } from '../config/defaults/runtime.js';
|
|
46
52
|
import { OpenClawTrinityRuntimeAdapter } from '../core/nocturnal-trinity.js';
|
|
47
53
|
|
|
48
|
-
// ──
|
|
49
|
-
// Detects stale/orphaned workflows, invalid results, and cleanup failures.
|
|
50
|
-
// Runs every heartbeat cycle, catching bugs like:
|
|
51
|
-
// #185 — orphaned active workflows
|
|
52
|
-
// #181 — structurally invalid results (all zeros)
|
|
53
|
-
// #180/#183 — expired workflows not swept
|
|
54
|
-
// #182 — unhandled rejections leaving workflows in limbo
|
|
55
|
-
|
|
56
|
-
interface WatchdogResult {
|
|
57
|
-
anomalies: number;
|
|
58
|
-
details: string[];
|
|
59
|
-
}
|
|
54
|
+
// ── Queue Event Payload Validation ─────────────────────────────────────────
|
|
60
55
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Validates a queue event payload string before JSON.parse.
|
|
58
|
+
* Checks:
|
|
59
|
+
* 1. typeof payload === 'string'
|
|
60
|
+
* 2. Parsed object has required fields: 'type' and 'workspaceId'
|
|
61
|
+
* Returns the parsed object only if validation passes.
|
|
62
|
+
* Returns empty object {} if payload is falsy.
|
|
63
|
+
* Throws Error if payload is a non-empty string that fails validation.
|
|
64
|
+
*/
|
|
65
|
+
function validateQueueEventPayload(payload: string | null | undefined): Record<string, unknown> {
|
|
66
|
+
if (!payload) return {};
|
|
67
|
+
if (typeof payload !== 'string') {
|
|
68
|
+
throw new Error(`Queue event payload must be a string, got: ${typeof payload}`);
|
|
69
|
+
}
|
|
73
70
|
try {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const staleThreshold = WORKFLOW_TTL_MS * 2;
|
|
78
|
-
const staleActive = allWorkflows.filter(
|
|
79
|
-
(wf: WorkflowRow) => wf.state === 'active' && (now - wf.created_at) > staleThreshold,
|
|
80
|
-
);
|
|
81
|
-
if (staleActive.length > 0) {
|
|
82
|
-
for (const wf of staleActive) {
|
|
83
|
-
const ageMin = Math.round((now - wf.created_at) / 60000);
|
|
84
|
-
details.push(`stale_active: ${wf.workflow_id} (${wf.workflow_type}, ${ageMin}min old)`);
|
|
85
|
-
|
|
86
|
-
// #257: Check if the last recorded event reason indicates expected subagent unavailability.
|
|
87
|
-
// If so, skip marking as terminal_error — the workflow is stale because the subagent
|
|
88
|
-
// was expectedly unavailable (daemon mode, process isolation), not due to a hard failure.
|
|
89
|
-
const events = store.getEvents(wf.workflow_id);
|
|
90
|
-
const lastEventReason = events.length > 0 ? events[events.length - 1].reason : 'unknown';
|
|
91
|
-
if (isExpectedSubagentError(lastEventReason)) {
|
|
92
|
-
logger?.debug?.(`[PD:Watchdog] Skipping stale active workflow ${wf.workflow_id}: expected subagent error (${lastEventReason})`);
|
|
93
|
-
continue;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
store.updateWorkflowState(wf.workflow_id, 'terminal_error');
|
|
97
|
-
store.recordEvent(wf.workflow_id, 'watchdog_timeout', 'active', 'terminal_error', `Stale active > ${staleThreshold / 60000}s`, { ageMs: now - wf.created_at });
|
|
98
|
-
|
|
99
|
-
// Cleanup session if possible (#188: gateway-safe fallback)
|
|
100
|
-
if (wf.child_session_key) {
|
|
101
|
-
try {
|
|
102
|
-
if (subagentRuntime) {
|
|
103
|
-
await subagentRuntime.deleteSession({ sessionKey: wf.child_session_key, deleteTranscript: true });
|
|
104
|
-
logger?.info?.(`[PD:Watchdog] Cleaned up stale session: ${wf.child_session_key}`);
|
|
105
|
-
} else if (agentSession) {
|
|
106
|
-
const storePath = agentSession.resolveStorePath();
|
|
107
|
-
const sessionStore = agentSession.loadSessionStore(storePath, { skipCache: true });
|
|
108
|
-
const normalizedKey = wf.child_session_key.toLowerCase();
|
|
109
|
-
if (sessionStore[normalizedKey]) {
|
|
110
|
-
delete sessionStore[normalizedKey];
|
|
111
|
-
await agentSession.saveSessionStore(storePath, sessionStore);
|
|
112
|
-
logger?.info?.(`[PD:Watchdog] Cleaned up stale session via agentSession fallback: ${wf.child_session_key}`);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
} catch (cleanupErr) {
|
|
116
|
-
const errMsg = String(cleanupErr);
|
|
117
|
-
if (errMsg.includes('gateway request') && agentSession) {
|
|
118
|
-
const storePath = agentSession.resolveStorePath();
|
|
119
|
-
const sessionStore = agentSession.loadSessionStore(storePath, { skipCache: true });
|
|
120
|
-
const normalizedKey = wf.child_session_key.toLowerCase();
|
|
121
|
-
if (sessionStore[normalizedKey]) {
|
|
122
|
-
delete sessionStore[normalizedKey];
|
|
123
|
-
await agentSession.saveSessionStore(storePath, sessionStore);
|
|
124
|
-
logger?.info?.(`[PD:Watchdog] Cleaned up stale session via agentSession fallback after gateway error: ${wf.child_session_key}`);
|
|
125
|
-
}
|
|
126
|
-
} else {
|
|
127
|
-
logger?.warn?.(`[PD:Watchdog] Failed to cleanup session ${wf.child_session_key}: ${errMsg}`);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
71
|
+
const parsed = JSON.parse(payload);
|
|
72
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
73
|
+
throw new Error('Queue event payload must be a JSON object');
|
|
131
74
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
// Check 3: Nocturnal workflow result validation (#181 pattern)
|
|
143
|
-
const nocturnalCompleted = allWorkflows.filter(
|
|
144
|
-
(wf: WorkflowRow) => wf.workflow_type === 'nocturnal' && wf.state === 'completed',
|
|
145
|
-
);
|
|
146
|
-
for (const wf of nocturnalCompleted) {
|
|
147
|
-
// Check if the metadata snapshot has all zeros (invalid data)
|
|
148
|
-
try {
|
|
149
|
-
const meta = JSON.parse(wf.metadata_json) as Record<string, unknown>;
|
|
150
|
-
const snapshot = meta.snapshot as Record<string, unknown> | undefined;
|
|
151
|
-
if (snapshot) {
|
|
152
|
-
// #219: Check for fallback data source (partial stats from pain context)
|
|
153
|
-
const dataSource = snapshot._dataSource as string | undefined;
|
|
154
|
-
if (dataSource === 'pain_context_fallback') {
|
|
155
|
-
details.push(`fallback_snapshot: nocturnal workflow ${wf.workflow_id} uses pain-context fallback (stats may be incomplete)`);
|
|
156
|
-
}
|
|
157
|
-
const stats = snapshot.stats as Record<string, number> | undefined;
|
|
158
|
-
// #246: Stats are now always number (never null). Detect "empty" fallback:
|
|
159
|
-
// fallback + all counts zero means no real data was available.
|
|
160
|
-
// NOTE: totalAssistantTurns may be 0 even for valid sessions because
|
|
161
|
-
// listRecentNocturnalCandidateSessions (used in fallback path) does not
|
|
162
|
-
// populate assistantTurnCount (only getNocturnalSessionSnapshot does).
|
|
163
|
-
// We use totalToolCalls=0 as the primary indicator instead.
|
|
164
|
-
if (stats && dataSource === 'pain_context_fallback' &&
|
|
165
|
-
stats.totalToolCalls === 0 && stats.totalGateBlocks === 0 &&
|
|
166
|
-
stats.failureCount === 0) {
|
|
167
|
-
details.push(`fallback_snapshot_stats: nocturnal workflow ${wf.workflow_id} has empty fallback stats (no trajectory data found)`);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
} catch { /* ignore malformed metadata */ }
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Summary
|
|
174
|
-
const stateCounts: Record<string, number> = {};
|
|
175
|
-
for (const wf of allWorkflows) {
|
|
176
|
-
stateCounts[wf.state] = (stateCounts[wf.state] || 0) + 1;
|
|
177
|
-
}
|
|
178
|
-
const stateSummary = Object.entries(stateCounts).map(([s, c]) => `${s}=${c}`).join(', ');
|
|
179
|
-
if (details.length === 0) {
|
|
180
|
-
logger?.debug?.(`[PD:Watchdog] OK — ${allWorkflows.length} workflows (${stateSummary})`);
|
|
181
|
-
} else {
|
|
182
|
-
logger?.info?.(`[PD:Watchdog] ${details.length} anomalies — ${allWorkflows.length} workflows (${stateSummary})`);
|
|
183
|
-
}
|
|
184
|
-
} finally {
|
|
185
|
-
store.dispose();
|
|
75
|
+
if (!('type' in parsed) || !('workspaceId' in parsed)) {
|
|
76
|
+
throw new Error('Queue event payload missing required fields: type, workspaceId');
|
|
77
|
+
}
|
|
78
|
+
return parsed;
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (err instanceof SyntaxError) {
|
|
81
|
+
throw new Error(`Invalid JSON in queue event payload: ${err.message}`);
|
|
82
|
+
}
|
|
83
|
+
throw err;
|
|
186
84
|
}
|
|
187
|
-
} catch (err) {
|
|
188
|
-
logger?.warn?.(`[PD:Watchdog] Failed to scan workflows: ${String(err)}`);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
return { anomalies: details.length, details };
|
|
192
85
|
}
|
|
193
86
|
|
|
87
|
+
/* istanbul ignore next — test export for validateQueueEventPayload */
|
|
88
|
+
export { validateQueueEventPayload };
|
|
89
|
+
|
|
90
|
+
// Re-export workflow watchdog (extracted to workflow-watchdog.ts)
|
|
91
|
+
import { runWorkflowWatchdog, type WatchdogResult } from './workflow-watchdog.js';
|
|
92
|
+
export { runWorkflowWatchdog };
|
|
93
|
+
export type { WatchdogResult };
|
|
94
|
+
|
|
194
95
|
let timeoutId: NodeJS.Timeout | null = null;
|
|
195
96
|
|
|
196
97
|
/**
|
|
@@ -205,27 +106,6 @@ let timeoutId: NodeJS.Timeout | null = null;
|
|
|
205
106
|
export type QueueStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'canceled';
|
|
206
107
|
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';
|
|
207
108
|
|
|
208
|
-
/**
|
|
209
|
-
* Recent pain context attached to sleep_reflection tasks.
|
|
210
|
-
* Carries explicit recent pain signal metadata without being a separate task kind.
|
|
211
|
-
* Used by NocturnalTargetSelector for ranking bias and context enrichment.
|
|
212
|
-
*/
|
|
213
|
-
export interface RecentPainContext {
|
|
214
|
-
/** Most recent unresolved pain event */
|
|
215
|
-
mostRecent: {
|
|
216
|
-
score: number;
|
|
217
|
-
source: string;
|
|
218
|
-
reason: string;
|
|
219
|
-
timestamp: string;
|
|
220
|
-
/** Session ID where the pain occurred */
|
|
221
|
-
sessionId: string;
|
|
222
|
-
} | null;
|
|
223
|
-
/** Count of pain events in the recent window (for signal strength) */
|
|
224
|
-
recentPainCount: number;
|
|
225
|
-
/** Highest pain score in the recent window */
|
|
226
|
-
recentMaxPainScore: number;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
109
|
export interface EvolutionQueueItem {
|
|
230
110
|
// Core identity
|
|
231
111
|
id: string;
|
|
@@ -263,6 +143,11 @@ export interface EvolutionQueueItem {
|
|
|
263
143
|
recentPainContext?: RecentPainContext;
|
|
264
144
|
}
|
|
265
145
|
|
|
146
|
+
// ── Queue Migration (extracted to queue-migration.ts) ────────────────────────
|
|
147
|
+
import { migrateToV2, isLegacyQueueItem, migrateQueueToV2, LegacyEvolutionQueueItem, DEFAULT_TASK_KIND, DEFAULT_PRIORITY, DEFAULT_MAX_RETRIES, type RawQueueItem } from './queue-migration.js';
|
|
148
|
+
export { migrateToV2, isLegacyQueueItem, migrateQueueToV2, LegacyEvolutionQueueItem, DEFAULT_TASK_KIND, DEFAULT_PRIORITY, DEFAULT_MAX_RETRIES };
|
|
149
|
+
export type { RawQueueItem };
|
|
150
|
+
|
|
266
151
|
function isSessionAtOrBeforeTriggerTime(
|
|
267
152
|
session: { startedAt: string; updatedAt: string },
|
|
268
153
|
triggerTimeMs: number,
|
|
@@ -281,6 +166,7 @@ function isSessionAtOrBeforeTriggerTime(
|
|
|
281
166
|
return true;
|
|
282
167
|
}
|
|
283
168
|
|
|
169
|
+
|
|
284
170
|
function buildFallbackNocturnalSnapshot(
|
|
285
171
|
sleepTask: EvolutionQueueItem,
|
|
286
172
|
extractor?: ReturnType<typeof createNocturnalTrajectoryExtractor> | null,
|
|
@@ -347,20 +233,9 @@ function buildFallbackNocturnalSnapshot(
|
|
|
347
233
|
};
|
|
348
234
|
}
|
|
349
235
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
preview: string,
|
|
354
|
-
reason: string,
|
|
355
|
-
now: number
|
|
356
|
-
): string {
|
|
357
|
-
// Keep ids short for prompt injection, but include enough entropy to avoid
|
|
358
|
-
// collisions between different pain events that share the same source/score/preview.
|
|
359
|
-
return createHash('md5')
|
|
360
|
-
.update(`${source}:${score}:${preview}:${reason}:${now}`)
|
|
361
|
-
.digest('hex')
|
|
362
|
-
.substring(0, 8);
|
|
363
|
-
}
|
|
236
|
+
const PAIN_QUEUE_DEDUP_WINDOW_MS = 30 * 60 * 1000;
|
|
237
|
+
|
|
238
|
+
// Queue lock constants and requireQueueLock are imported from queue-io.ts
|
|
364
239
|
|
|
365
240
|
export function extractEvolutionTaskId(task: string): string | null {
|
|
366
241
|
if (!task) return null;
|
|
@@ -409,180 +284,31 @@ export function purgeStaleFailedTasks(
|
|
|
409
284
|
return { purged: purged.length, remaining: queue.length, byReason };
|
|
410
285
|
}
|
|
411
286
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
return queue.some(
|
|
418
|
-
(t) => t.taskKind === taskKind && (t.status === 'pending' || t.status === 'in_progress'),
|
|
419
|
-
);
|
|
287
|
+
function normalizePainDedupKey(source: string, preview: string, reason?: string): string {
|
|
288
|
+
// Include reason in dedup key to match createEvolutionTaskId() behavior
|
|
289
|
+
// Different reasons for the same source/preview should create different tasks
|
|
290
|
+
const normalizedReason = (reason || '').trim().toLowerCase();
|
|
291
|
+
return `${source.trim().toLowerCase()}::${preview.trim().toLowerCase()}::${normalizedReason}`;
|
|
420
292
|
}
|
|
421
293
|
|
|
422
|
-
/**
|
|
423
|
-
* Decide whether to skip enqueuing due to a recent similar reflection.
|
|
424
|
-
* Returns true if skipped (with log), false if should proceed.
|
|
425
|
-
*/
|
|
426
|
-
function shouldSkipForDedup(
|
|
427
|
-
queue: EvolutionQueueItem[],
|
|
428
|
-
wctx: WorkspaceContext,
|
|
429
|
-
logger: PluginLogger,
|
|
430
|
-
): boolean {
|
|
431
|
-
const recentPainContext = readRecentPainContext(wctx);
|
|
432
|
-
const painSourceKey = buildPainSourceKey(recentPainContext);
|
|
433
|
-
|
|
434
|
-
// Bypass dedup when there is no pain context — general idle reflections
|
|
435
|
-
// should not be throttled by the 'no_pain_context' sentinel.
|
|
436
|
-
if (!painSourceKey) return false;
|
|
437
|
-
|
|
438
|
-
const now = Date.now();
|
|
439
|
-
const recentSimilarReflection = hasRecentSimilarReflection(queue, painSourceKey, now);
|
|
440
|
-
|
|
441
|
-
if (recentSimilarReflection) {
|
|
442
|
-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
443
|
-
const completedTime = new Date(recentSimilarReflection.completed_at!).getTime();
|
|
444
|
-
logger?.debug?.(`[PD:EvolutionWorker] Skipping sleep_reflection — similar reflection completed ${Math.round((now - completedTime) / 60000)}min ago (same pain pattern: ${painSourceKey})`);
|
|
445
|
-
return true;
|
|
446
|
-
}
|
|
447
|
-
return false;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/**
|
|
451
|
-
* Load and migrate the evolution queue. Returns empty array if file doesn't exist.
|
|
452
|
-
*/
|
|
453
|
-
function loadEvolutionQueue(queuePath: string): EvolutionQueueItem[] {
|
|
454
|
-
|
|
455
|
-
let rawQueue: RawQueueItem[] = [];
|
|
456
|
-
try {
|
|
457
|
-
rawQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
|
|
458
|
-
} catch {
|
|
459
|
-
// Queue doesn't exist yet - create empty array
|
|
460
|
-
rawQueue = [];
|
|
461
|
-
}
|
|
462
|
-
return migrateQueueToV2(rawQueue);
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
/**
|
|
466
|
-
* Build and persist a new sleep_reflection task.
|
|
467
|
-
*/
|
|
468
294
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
queuePath: string,
|
|
473
|
-
logger: PluginLogger,
|
|
474
|
-
): void {
|
|
475
|
-
const taskId = createEvolutionTaskId('nocturnal', 50, 'idle workspace', 'Sleep-mode reflection', Date.now());
|
|
476
|
-
const nowIso = new Date().toISOString();
|
|
477
|
-
|
|
478
|
-
queue.push({
|
|
479
|
-
id: taskId,
|
|
480
|
-
taskKind: 'sleep_reflection',
|
|
481
|
-
priority: 'medium',
|
|
482
|
-
score: 50,
|
|
483
|
-
source: 'nocturnal',
|
|
484
|
-
reason: 'Sleep-mode reflection triggered by idle workspace',
|
|
485
|
-
trigger_text_preview: 'Idle workspace detected',
|
|
486
|
-
timestamp: nowIso,
|
|
487
|
-
enqueued_at: nowIso,
|
|
488
|
-
status: 'pending',
|
|
489
|
-
traceId: taskId,
|
|
490
|
-
retryCount: 0,
|
|
491
|
-
maxRetries: 1, // sleep_reflection doesn't retry
|
|
492
|
-
recentPainContext,
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
|
|
496
|
-
logger?.info?.(`[PD:EvolutionWorker] Enqueued sleep_reflection task ${taskId}`);
|
|
295
|
+
|
|
296
|
+
export function hasRecentDuplicateTask(queue: EvolutionQueueItem[], source: string, preview: string, now: number, reason?: string): boolean {
|
|
297
|
+
return !!findRecentDuplicateTask(queue, source, preview, now, reason);
|
|
497
298
|
}
|
|
498
299
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
async function enqueueSleepReflectionTask(
|
|
506
|
-
wctx: WorkspaceContext,
|
|
507
|
-
logger: PluginLogger,
|
|
508
|
-
): Promise<void> {
|
|
509
|
-
const queuePath = wctx.resolve('EVOLUTION_QUEUE');
|
|
510
|
-
const releaseLock = await requireQueueLock(queuePath, logger, 'enqueueSleepReflection', EVOLUTION_QUEUE_LOCK_SUFFIX);
|
|
511
|
-
|
|
512
|
-
try {
|
|
513
|
-
const queue = loadEvolutionQueue(queuePath);
|
|
514
|
-
|
|
515
|
-
// Guard 1: Skip if a sleep_reflection task is already pending/in-progress
|
|
516
|
-
if (hasPendingTask(queue, 'sleep_reflection')) {
|
|
517
|
-
logger?.debug?.('[PD:EvolutionWorker] sleep_reflection task already pending/in-progress, skipping');
|
|
518
|
-
return;
|
|
300
|
+
export function hasEquivalentPromotedRule(dictionary: { getAllRules(): Record<string, { type: string; phrases?: string[]; pattern?: string; status: string; }> }, phrase: string): boolean {
|
|
301
|
+
const normalizedPhrase = phrase.trim().toLowerCase();
|
|
302
|
+
return Object.values(dictionary.getAllRules()).some((rule) => {
|
|
303
|
+
if (rule.status !== 'active') return false;
|
|
304
|
+
if (rule.type === 'exact_match' && Array.isArray(rule.phrases)) {
|
|
305
|
+
return rule.phrases.some((candidate) => candidate.trim().toLowerCase() === normalizedPhrase);
|
|
519
306
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
if (shouldSkipForDedup(queue, wctx, logger)) {
|
|
523
|
-
return;
|
|
307
|
+
if (rule.type === 'regex' && typeof rule.pattern === 'string') {
|
|
308
|
+
return rule.pattern.trim().toLowerCase() === normalizedPhrase;
|
|
524
309
|
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
const recentPainContext = readRecentPainContext(wctx);
|
|
528
|
-
enqueueNewSleepReflectionTask(queue, recentPainContext, queuePath, logger);
|
|
529
|
-
} finally {
|
|
530
|
-
releaseLock();
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
/**
|
|
535
|
-
* Enqueue a keyword_optimization task if one is not already pending/in-progress (CORR-08).
|
|
536
|
-
* Dispatches LLM subagent via CorrectionObserverWorkflowManager to optimize
|
|
537
|
-
* correction keywords based on FPR and match history.
|
|
538
|
-
*/
|
|
539
|
-
async function enqueueKeywordOptimizationTask(
|
|
540
|
-
wctx: WorkspaceContext,
|
|
541
|
-
logger: PluginLogger,
|
|
542
|
-
): Promise<void> {
|
|
543
|
-
const queuePath = wctx.resolve('EVOLUTION_QUEUE');
|
|
544
|
-
const releaseLock = await requireQueueLock(queuePath, logger, 'enqueueKeywordOpt', EVOLUTION_QUEUE_LOCK_SUFFIX);
|
|
545
|
-
|
|
546
|
-
try {
|
|
547
|
-
const queue = loadEvolutionQueue(queuePath);
|
|
548
|
-
|
|
549
|
-
// Guard: Skip if a keyword_optimization task is already pending/in-progress (CORR-08)
|
|
550
|
-
if (hasPendingTask(queue, 'keyword_optimization')) {
|
|
551
|
-
logger?.debug?.('[PD:EvolutionWorker] keyword_optimization task already pending/in-progress, skipping');
|
|
552
|
-
return;
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// Guard: Skip if daily optimization throttle is exhausted (CORR-08)
|
|
556
|
-
const learner = CorrectionCueLearner.get(wctx.stateDir);
|
|
557
|
-
if (!learner.canRunKeywordOptimization()) {
|
|
558
|
-
logger?.debug?.('[PD:EvolutionWorker] keyword_optimization throttle exhausted, skipping');
|
|
559
|
-
return;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
const taskId = createEvolutionTaskId('keyword_optimization', 50, 'keyword optimization', 'Keyword optimization via LLM', Date.now());
|
|
563
|
-
const nowIso = new Date().toISOString();
|
|
564
|
-
|
|
565
|
-
queue.push({
|
|
566
|
-
id: taskId,
|
|
567
|
-
taskKind: 'keyword_optimization',
|
|
568
|
-
priority: 'medium',
|
|
569
|
-
score: 50,
|
|
570
|
-
source: 'correction',
|
|
571
|
-
reason: 'Keyword optimization triggered by heartbeat',
|
|
572
|
-
trigger_text_preview: 'Keyword optimization via LLM',
|
|
573
|
-
timestamp: nowIso,
|
|
574
|
-
enqueued_at: nowIso,
|
|
575
|
-
status: 'pending',
|
|
576
|
-
traceId: taskId,
|
|
577
|
-
retryCount: 0,
|
|
578
|
-
maxRetries: 1,
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
|
|
582
|
-
logger?.info?.(`[PD:EvolutionWorker] Enqueued keyword_optimization task ${taskId}`);
|
|
583
|
-
} finally {
|
|
584
|
-
releaseLock();
|
|
585
|
-
}
|
|
310
|
+
return false;
|
|
311
|
+
});
|
|
586
312
|
}
|
|
587
313
|
|
|
588
314
|
interface ParsedPainValues {
|
|
@@ -637,7 +363,7 @@ async function doEnqueuePainTask(
|
|
|
637
363
|
retryCount: 0, maxRetries: 3,
|
|
638
364
|
});
|
|
639
365
|
|
|
640
|
-
|
|
366
|
+
saveEvolutionQueue(queuePath, queue);
|
|
641
367
|
fs.appendFileSync(painFlagPath, `\nstatus: queued\ntask_id: ${taskId}\n`, 'utf8');
|
|
642
368
|
result.enqueued = true;
|
|
643
369
|
|
|
@@ -872,7 +598,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
872
598
|
}
|
|
873
599
|
|
|
874
600
|
// V2: Migrate queue to current schema if needed
|
|
875
|
-
const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue);
|
|
601
|
+
const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue) as unknown as EvolutionQueueItem[];
|
|
876
602
|
|
|
877
603
|
let queueChanged = rawQueue.some(isLegacyQueueItem);
|
|
878
604
|
|
|
@@ -910,13 +636,13 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
910
636
|
e.event_type.includes('failed') || e.event_type.includes('error')
|
|
911
637
|
).pop();
|
|
912
638
|
if (failureEvent) {
|
|
913
|
-
const payload =
|
|
639
|
+
const payload = validateQueueEventPayload(failureEvent.payload_json);
|
|
914
640
|
detailedError = `sleep_reflection failed: ${failureEvent.reason}`;
|
|
915
641
|
if (payload.skipReason) {
|
|
916
642
|
detailedError += ` (skipReason: ${payload.skipReason})`;
|
|
917
643
|
}
|
|
918
|
-
if (payload.failures && payload.failures.length > 0) {
|
|
919
|
-
detailedError += ` | failures: ${payload.failures.slice(0, 3).join(', ')}`;
|
|
644
|
+
if (payload.failures && Array.isArray(payload.failures) && payload.failures.length > 0) {
|
|
645
|
+
detailedError += ` | failures: ${(payload.failures as string[]).slice(0, 3).join(', ')}`;
|
|
920
646
|
}
|
|
921
647
|
}
|
|
922
648
|
} catch (fetchErr) {
|
|
@@ -1432,7 +1158,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1432
1158
|
|
|
1433
1159
|
// Write claimed state (includes any pain changes from above) and release lock
|
|
1434
1160
|
if (queueChanged) {
|
|
1435
|
-
|
|
1161
|
+
saveEvolutionQueue(queuePath, queue);
|
|
1436
1162
|
}
|
|
1437
1163
|
releaseLock();
|
|
1438
1164
|
// Phase 40: Track outcomes for failure classification after queue write
|
|
@@ -1611,15 +1337,15 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1611
1337
|
|
|
1612
1338
|
try {
|
|
1613
1339
|
payload = lastEvent?.payload ?? {};
|
|
1614
|
-
|
|
1340
|
+
|
|
1615
1341
|
if ((payload as any).skipReason) {
|
|
1616
|
-
|
|
1342
|
+
|
|
1617
1343
|
detailedError += ` (skipReason: ${(payload as any).skipReason})`;
|
|
1618
1344
|
|
|
1619
1345
|
}
|
|
1620
|
-
|
|
1346
|
+
|
|
1621
1347
|
if ((payload as any).failures && Array.isArray((payload as any).failures) && (payload as any).failures.length > 0) {
|
|
1622
|
-
|
|
1348
|
+
|
|
1623
1349
|
detailedError += ` | failures: ${((payload as any).failures as string[]).slice(0, 3).join(', ')}`;
|
|
1624
1350
|
}
|
|
1625
1351
|
} catch { /* ignore parse errors */ }
|
|
@@ -1635,7 +1361,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1635
1361
|
sleepOutcomes.push({ taskKind: 'sleep_reflection', succeeded: true });
|
|
1636
1362
|
|
|
1637
1363
|
logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} background runtime unavailable, using stub fallback: ${errorReason}`);
|
|
1638
|
-
|
|
1364
|
+
|
|
1639
1365
|
} else if ((payload as any).skipReason === 'no_violating_sessions') {
|
|
1640
1366
|
// #244: No meaningful violations found (thin filter) → skip without failure
|
|
1641
1367
|
sleepTask.status = 'completed';
|
|
@@ -1768,7 +1494,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1768
1494
|
if (keywordOptTasks.length > 0) {
|
|
1769
1495
|
// Skip all keyword_optimization tasks this cycle; release lock and return
|
|
1770
1496
|
if (queueChanged) {
|
|
1771
|
-
|
|
1497
|
+
saveEvolutionQueue(queuePath, queue);
|
|
1772
1498
|
}
|
|
1773
1499
|
releaseLock();
|
|
1774
1500
|
lockReleased = true;
|
|
@@ -1784,7 +1510,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1784
1510
|
queueChanged = queueChanged || pendingKeywordOptTasks.length > 0;
|
|
1785
1511
|
|
|
1786
1512
|
// Release lock during LLM dispatch (long-running)
|
|
1787
|
-
|
|
1513
|
+
saveEvolutionQueue(queuePath, queue);
|
|
1788
1514
|
releaseLock();
|
|
1789
1515
|
lockReleased = true;
|
|
1790
1516
|
|
|
@@ -1830,7 +1556,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1830
1556
|
const manager = new CorrectionObserverWorkflowManager({
|
|
1831
1557
|
workspaceDir: wctx.workspaceDir,
|
|
1832
1558
|
logger,
|
|
1833
|
-
subagent: api?.runtime?.subagent!,
|
|
1559
|
+
subagent: api?.runtime?.subagent!, /* eslint-disable-line @typescript-eslint/no-non-null-assertion */
|
|
1834
1560
|
agentSession: api?.runtime?.agent?.session,
|
|
1835
1561
|
});
|
|
1836
1562
|
|
|
@@ -1844,7 +1570,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1844
1570
|
workflowId = handle.workflowId;
|
|
1845
1571
|
koTask.resultRef = workflowId;
|
|
1846
1572
|
} else {
|
|
1847
|
-
workflowId = koTask.resultRef!;
|
|
1573
|
+
workflowId = koTask.resultRef!; /* eslint-disable-line @typescript-eslint/no-non-null-assertion */
|
|
1848
1574
|
}
|
|
1849
1575
|
|
|
1850
1576
|
// Poll workflow state
|
|
@@ -1945,7 +1671,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1945
1671
|
}
|
|
1946
1672
|
|
|
1947
1673
|
if (queueChanged) {
|
|
1948
|
-
|
|
1674
|
+
saveEvolutionQueue(queuePath, queue);
|
|
1949
1675
|
}
|
|
1950
1676
|
|
|
1951
1677
|
// Pipeline observability: log stage-level summary at end of cycle
|
|
@@ -2051,8 +1777,8 @@ export async function registerEvolutionTaskSession(
|
|
|
2051
1777
|
}
|
|
2052
1778
|
|
|
2053
1779
|
// V2: Migrate queue to current schema
|
|
2054
|
-
const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue);
|
|
2055
|
-
|
|
1780
|
+
const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue) as unknown as EvolutionQueueItem[];
|
|
1781
|
+
|
|
2056
1782
|
const task = queue.find((item) => item.id === taskId && item.status === 'in_progress');
|
|
2057
1783
|
if (!task) {
|
|
2058
1784
|
logger?.warn?.(`[PD:EvolutionWorker] Could not find in-progress evolution task ${taskId} for session assignment`);
|
|
@@ -2063,7 +1789,7 @@ export async function registerEvolutionTaskSession(
|
|
|
2063
1789
|
if (!task.started_at) {
|
|
2064
1790
|
task.started_at = new Date().toISOString();
|
|
2065
1791
|
}
|
|
2066
|
-
|
|
1792
|
+
saveEvolutionQueue(queuePath, queue);
|
|
2067
1793
|
return true;
|
|
2068
1794
|
} finally {
|
|
2069
1795
|
releaseLock();
|
|
@@ -2134,7 +1860,7 @@ async function processEvolutionQueueWithResult(
|
|
|
2134
1860
|
const purgeResult = purgeStaleFailedTasks(queue, logger);
|
|
2135
1861
|
if (purgeResult.purged > 0) {
|
|
2136
1862
|
// Write back the cleaned queue
|
|
2137
|
-
|
|
1863
|
+
saveEvolutionQueue(queuePath, queue);
|
|
2138
1864
|
}
|
|
2139
1865
|
|
|
2140
1866
|
queueResult.total = queue.length;
|