principles-disciple 1.41.0 → 1.42.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/pain.ts +12 -5
- package/src/commands/promote-impl.ts +13 -7
- package/src/commands/rollback.ts +10 -3
- package/src/core/event-log.ts +8 -6
- package/src/core/evolution-types.ts +33 -1
- package/src/hooks/message-sanitize.ts +18 -5
- package/src/hooks/prompt.ts +15 -4
- package/src/hooks/subagent.ts +2 -3
- package/src/http/principles-console-route.ts +21 -4
- package/src/service/evolution-worker.ts +89 -365
- package/src/service/queue-io.ts +375 -0
- package/src/service/queue-migration.ts +122 -0
- package/src/service/sleep-cycle.ts +157 -0
- package/src/service/subagent-workflow/runtime-direct-driver.ts +1 -1
- package/src/service/workflow-watchdog.ts +168 -0
- package/src/tools/deep-reflect.ts +22 -11
- 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,7 @@
|
|
|
1
1
|
/* global NodeJS */
|
|
2
|
+
|
|
2
3
|
import * as fs from 'fs';
|
|
3
4
|
import * as path from 'path';
|
|
4
|
-
import { createHash } from 'crypto';
|
|
5
5
|
import type { OpenClawPluginServiceContext, OpenClawPluginApi, PluginLogger } from '../openclaw-sdk.js';
|
|
6
6
|
import { DictionaryService } from '../core/dictionary-service.js';
|
|
7
7
|
import { DetectionService } from '../core/detection-service.js';
|
|
@@ -12,11 +12,20 @@ import type { EventLog } from '../core/event-log.js';
|
|
|
12
12
|
import { initPersistence, flushAllSessions } from '../core/session-tracker.js';
|
|
13
13
|
import { addDiagnosticianTask, completeDiagnosticianTask } from '../core/diagnostician-task-store.js';
|
|
14
14
|
import { getEvolutionLogger } from '../core/evolution-logger.js';
|
|
15
|
+
import type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
|
|
16
|
+
export type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
|
|
15
17
|
import { atomicWriteFileSync } from '../utils/io.js';
|
|
18
|
+
|
|
19
|
+
// Re-export queue I/O (extracted to queue-io.ts)
|
|
20
|
+
export { loadEvolutionQueue, saveEvolutionQueue, withQueueLock, acquireQueueLock, requireQueueLock } from './queue-io.js';
|
|
21
|
+
export { enqueueSleepReflectionTask, enqueueKeywordOptimizationTask } from './queue-io.js';
|
|
22
|
+
export { EVOLUTION_QUEUE_LOCK_SUFFIX, LOCK_MAX_RETRIES, LOCK_RETRY_DELAY_MS, LOCK_STALE_MS } from './queue-io.js';
|
|
23
|
+
import { saveEvolutionQueue, requireQueueLock, hasPendingTask, enqueueSleepReflectionTask, enqueueKeywordOptimizationTask, createEvolutionTaskId } from './queue-io.js';
|
|
24
|
+
import type { RecentPainContext } from './queue-io.js';
|
|
25
|
+
export type { RecentPainContext } from './queue-io.js';
|
|
16
26
|
import { checkWorkspaceIdle, checkCooldown, recordCooldown } from './nocturnal-runtime.js';
|
|
17
27
|
import { loadCooldownEscalationConfig, loadNocturnalConfigMerged } from './nocturnal-config.js';
|
|
18
28
|
import { WorkflowStore } from './subagent-workflow/workflow-store.js';
|
|
19
|
-
import type { WorkflowRow } from './subagent-workflow/types.js';
|
|
20
29
|
import { EmpathyObserverWorkflowManager } from './subagent-workflow/empathy-observer-workflow-manager.js';
|
|
21
30
|
import { DeepReflectWorkflowManager } from './subagent-workflow/deep-reflect-workflow-manager.js';
|
|
22
31
|
import { NocturnalWorkflowManager, nocturnalWorkflowSpec } from './subagent-workflow/nocturnal-workflow-manager.js';
|
|
@@ -29,168 +38,58 @@ import { validateNocturnalSnapshotIngress } from '../core/nocturnal-snapshot-con
|
|
|
29
38
|
import { isExpectedSubagentError } from './subagent-workflow/subagent-error-utils.js';
|
|
30
39
|
import { readPainFlagContract } from '../core/pain.js';
|
|
31
40
|
import { CorrectionObserverWorkflowManager, correctionObserverWorkflowSpec } from './subagent-workflow/correction-observer-workflow-manager.js';
|
|
41
|
+
import { findRecentDuplicateTask } from './evolution-dedup.js';
|
|
32
42
|
import type { CorrectionObserverPayload } from './subagent-workflow/correction-observer-types.js';
|
|
33
43
|
import { KeywordOptimizationService } from './keyword-optimization-service.js';
|
|
34
44
|
import { TrajectoryRegistry } from '../core/trajectory.js';
|
|
35
45
|
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
46
|
import { classifyFailure, type ClassifiableTaskKind } from './failure-classifier.js';
|
|
43
47
|
import { recordPersistentFailure, resetFailureState, isTaskKindInCooldown } from './cooldown-strategy.js';
|
|
44
48
|
import { reconcileStartup } from './startup-reconciler.js';
|
|
45
49
|
import { WORKFLOW_TTL_MS } from '../config/defaults/runtime.js';
|
|
46
50
|
import { OpenClawTrinityRuntimeAdapter } from '../core/nocturnal-trinity.js';
|
|
47
51
|
|
|
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
|
-
}
|
|
52
|
+
// ── Queue Event Payload Validation ─────────────────────────────────────────
|
|
60
53
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Validates a queue event payload string before JSON.parse.
|
|
56
|
+
* Checks:
|
|
57
|
+
* 1. typeof payload === 'string'
|
|
58
|
+
* 2. Parsed object has required fields: 'type' and 'workspaceId'
|
|
59
|
+
* Returns the parsed object only if validation passes.
|
|
60
|
+
* Returns empty object {} if payload is falsy.
|
|
61
|
+
* Throws Error if payload is a non-empty string that fails validation.
|
|
62
|
+
*/
|
|
63
|
+
function validateQueueEventPayload(payload: string | null | undefined): Record<string, unknown> {
|
|
64
|
+
if (!payload) return {};
|
|
65
|
+
if (typeof payload !== 'string') {
|
|
66
|
+
throw new Error(`Queue event payload must be a string, got: ${typeof payload}`);
|
|
67
|
+
}
|
|
73
68
|
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
|
-
}
|
|
69
|
+
const parsed = JSON.parse(payload);
|
|
70
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
71
|
+
throw new Error('Queue event payload must be a JSON object');
|
|
131
72
|
}
|
|
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();
|
|
73
|
+
if (!('type' in parsed) || !('workspaceId' in parsed)) {
|
|
74
|
+
throw new Error('Queue event payload missing required fields: type, workspaceId');
|
|
75
|
+
}
|
|
76
|
+
return parsed;
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (err instanceof SyntaxError) {
|
|
79
|
+
throw new Error(`Invalid JSON in queue event payload: ${err.message}`);
|
|
80
|
+
}
|
|
81
|
+
throw err;
|
|
186
82
|
}
|
|
187
|
-
} catch (err) {
|
|
188
|
-
logger?.warn?.(`[PD:Watchdog] Failed to scan workflows: ${String(err)}`);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
return { anomalies: details.length, details };
|
|
192
83
|
}
|
|
193
84
|
|
|
85
|
+
/* istanbul ignore next — test export for validateQueueEventPayload */
|
|
86
|
+
export { validateQueueEventPayload };
|
|
87
|
+
|
|
88
|
+
// Re-export workflow watchdog (extracted to workflow-watchdog.ts)
|
|
89
|
+
import { runWorkflowWatchdog, type WatchdogResult } from './workflow-watchdog.js';
|
|
90
|
+
export { runWorkflowWatchdog };
|
|
91
|
+
export type { WatchdogResult };
|
|
92
|
+
|
|
194
93
|
let timeoutId: NodeJS.Timeout | null = null;
|
|
195
94
|
|
|
196
95
|
/**
|
|
@@ -205,27 +104,6 @@ let timeoutId: NodeJS.Timeout | null = null;
|
|
|
205
104
|
export type QueueStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'canceled';
|
|
206
105
|
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
106
|
|
|
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
107
|
export interface EvolutionQueueItem {
|
|
230
108
|
// Core identity
|
|
231
109
|
id: string;
|
|
@@ -263,6 +141,11 @@ export interface EvolutionQueueItem {
|
|
|
263
141
|
recentPainContext?: RecentPainContext;
|
|
264
142
|
}
|
|
265
143
|
|
|
144
|
+
// ── Queue Migration (extracted to queue-migration.ts) ────────────────────────
|
|
145
|
+
import { migrateToV2, isLegacyQueueItem, migrateQueueToV2, LegacyEvolutionQueueItem, DEFAULT_TASK_KIND, DEFAULT_PRIORITY, DEFAULT_MAX_RETRIES, type RawQueueItem } from './queue-migration.js';
|
|
146
|
+
export { migrateToV2, isLegacyQueueItem, migrateQueueToV2, LegacyEvolutionQueueItem, DEFAULT_TASK_KIND, DEFAULT_PRIORITY, DEFAULT_MAX_RETRIES };
|
|
147
|
+
export type { RawQueueItem };
|
|
148
|
+
|
|
266
149
|
function isSessionAtOrBeforeTriggerTime(
|
|
267
150
|
session: { startedAt: string; updatedAt: string },
|
|
268
151
|
triggerTimeMs: number,
|
|
@@ -281,6 +164,7 @@ function isSessionAtOrBeforeTriggerTime(
|
|
|
281
164
|
return true;
|
|
282
165
|
}
|
|
283
166
|
|
|
167
|
+
|
|
284
168
|
function buildFallbackNocturnalSnapshot(
|
|
285
169
|
sleepTask: EvolutionQueueItem,
|
|
286
170
|
extractor?: ReturnType<typeof createNocturnalTrajectoryExtractor> | null,
|
|
@@ -347,20 +231,9 @@ function buildFallbackNocturnalSnapshot(
|
|
|
347
231
|
};
|
|
348
232
|
}
|
|
349
233
|
|
|
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
|
-
}
|
|
234
|
+
const PAIN_QUEUE_DEDUP_WINDOW_MS = 30 * 60 * 1000;
|
|
235
|
+
|
|
236
|
+
// Queue lock constants and requireQueueLock are imported from queue-io.ts
|
|
364
237
|
|
|
365
238
|
export function extractEvolutionTaskId(task: string): string | null {
|
|
366
239
|
if (!task) return null;
|
|
@@ -409,180 +282,31 @@ export function purgeStaleFailedTasks(
|
|
|
409
282
|
return { purged: purged.length, remaining: queue.length, byReason };
|
|
410
283
|
}
|
|
411
284
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
return queue.some(
|
|
418
|
-
(t) => t.taskKind === taskKind && (t.status === 'pending' || t.status === 'in_progress'),
|
|
419
|
-
);
|
|
420
|
-
}
|
|
421
|
-
|
|
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);
|
|
285
|
+
function normalizePainDedupKey(source: string, preview: string, reason?: string): string {
|
|
286
|
+
// Include reason in dedup key to match createEvolutionTaskId() behavior
|
|
287
|
+
// Different reasons for the same source/preview should create different tasks
|
|
288
|
+
const normalizedReason = (reason || '').trim().toLowerCase();
|
|
289
|
+
return `${source.trim().toLowerCase()}::${preview.trim().toLowerCase()}::${normalizedReason}`;
|
|
463
290
|
}
|
|
464
291
|
|
|
465
|
-
/**
|
|
466
|
-
* Build and persist a new sleep_reflection task.
|
|
467
|
-
*/
|
|
468
292
|
|
|
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}`);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
/**
|
|
500
|
-
* Enqueue a sleep_reflection task if one is not already pending.
|
|
501
|
-
* Phase 2.4: Called when workspace is idle to trigger nocturnal reflection.
|
|
502
|
-
* Phase 3c: Dedup checks recent sleep_reflection tasks by pain source pattern
|
|
503
|
-
* to prevent redundant reflections of the same underlying issue.
|
|
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;
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
// Guard 2: Dedup — skip if similar reflection completed recently
|
|
522
|
-
if (shouldSkipForDedup(queue, wctx, logger)) {
|
|
523
|
-
return;
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
// Enqueue the new task
|
|
527
|
-
const recentPainContext = readRecentPainContext(wctx);
|
|
528
|
-
enqueueNewSleepReflectionTask(queue, recentPainContext, queuePath, logger);
|
|
529
|
-
} finally {
|
|
530
|
-
releaseLock();
|
|
531
|
-
}
|
|
293
|
+
|
|
294
|
+
export function hasRecentDuplicateTask(queue: EvolutionQueueItem[], source: string, preview: string, now: number, reason?: string): boolean {
|
|
295
|
+
return !!findRecentDuplicateTask(queue, source, preview, now, reason);
|
|
532
296
|
}
|
|
533
297
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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;
|
|
298
|
+
export function hasEquivalentPromotedRule(dictionary: { getAllRules(): Record<string, { type: string; phrases?: string[]; pattern?: string; status: string; }> }, phrase: string): boolean {
|
|
299
|
+
const normalizedPhrase = phrase.trim().toLowerCase();
|
|
300
|
+
return Object.values(dictionary.getAllRules()).some((rule) => {
|
|
301
|
+
if (rule.status !== 'active') return false;
|
|
302
|
+
if (rule.type === 'exact_match' && Array.isArray(rule.phrases)) {
|
|
303
|
+
return rule.phrases.some((candidate) => candidate.trim().toLowerCase() === normalizedPhrase);
|
|
553
304
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
const learner = CorrectionCueLearner.get(wctx.stateDir);
|
|
557
|
-
if (!learner.canRunKeywordOptimization()) {
|
|
558
|
-
logger?.debug?.('[PD:EvolutionWorker] keyword_optimization throttle exhausted, skipping');
|
|
559
|
-
return;
|
|
305
|
+
if (rule.type === 'regex' && typeof rule.pattern === 'string') {
|
|
306
|
+
return rule.pattern.trim().toLowerCase() === normalizedPhrase;
|
|
560
307
|
}
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
}
|
|
308
|
+
return false;
|
|
309
|
+
});
|
|
586
310
|
}
|
|
587
311
|
|
|
588
312
|
interface ParsedPainValues {
|
|
@@ -637,7 +361,7 @@ async function doEnqueuePainTask(
|
|
|
637
361
|
retryCount: 0, maxRetries: 3,
|
|
638
362
|
});
|
|
639
363
|
|
|
640
|
-
|
|
364
|
+
saveEvolutionQueue(queuePath, queue);
|
|
641
365
|
fs.appendFileSync(painFlagPath, `\nstatus: queued\ntask_id: ${taskId}\n`, 'utf8');
|
|
642
366
|
result.enqueued = true;
|
|
643
367
|
|
|
@@ -872,7 +596,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
872
596
|
}
|
|
873
597
|
|
|
874
598
|
// V2: Migrate queue to current schema if needed
|
|
875
|
-
const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue);
|
|
599
|
+
const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue) as unknown as EvolutionQueueItem[];
|
|
876
600
|
|
|
877
601
|
let queueChanged = rawQueue.some(isLegacyQueueItem);
|
|
878
602
|
|
|
@@ -910,13 +634,13 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
910
634
|
e.event_type.includes('failed') || e.event_type.includes('error')
|
|
911
635
|
).pop();
|
|
912
636
|
if (failureEvent) {
|
|
913
|
-
const payload =
|
|
637
|
+
const payload = validateQueueEventPayload(failureEvent.payload_json);
|
|
914
638
|
detailedError = `sleep_reflection failed: ${failureEvent.reason}`;
|
|
915
639
|
if (payload.skipReason) {
|
|
916
640
|
detailedError += ` (skipReason: ${payload.skipReason})`;
|
|
917
641
|
}
|
|
918
|
-
if (payload.failures && payload.failures.length > 0) {
|
|
919
|
-
detailedError += ` | failures: ${payload.failures.slice(0, 3).join(', ')}`;
|
|
642
|
+
if (payload.failures && Array.isArray(payload.failures) && payload.failures.length > 0) {
|
|
643
|
+
detailedError += ` | failures: ${(payload.failures as string[]).slice(0, 3).join(', ')}`;
|
|
920
644
|
}
|
|
921
645
|
}
|
|
922
646
|
} catch (fetchErr) {
|
|
@@ -1432,7 +1156,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1432
1156
|
|
|
1433
1157
|
// Write claimed state (includes any pain changes from above) and release lock
|
|
1434
1158
|
if (queueChanged) {
|
|
1435
|
-
|
|
1159
|
+
saveEvolutionQueue(queuePath, queue);
|
|
1436
1160
|
}
|
|
1437
1161
|
releaseLock();
|
|
1438
1162
|
// Phase 40: Track outcomes for failure classification after queue write
|
|
@@ -1768,7 +1492,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1768
1492
|
if (keywordOptTasks.length > 0) {
|
|
1769
1493
|
// Skip all keyword_optimization tasks this cycle; release lock and return
|
|
1770
1494
|
if (queueChanged) {
|
|
1771
|
-
|
|
1495
|
+
saveEvolutionQueue(queuePath, queue);
|
|
1772
1496
|
}
|
|
1773
1497
|
releaseLock();
|
|
1774
1498
|
lockReleased = true;
|
|
@@ -1784,7 +1508,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1784
1508
|
queueChanged = queueChanged || pendingKeywordOptTasks.length > 0;
|
|
1785
1509
|
|
|
1786
1510
|
// Release lock during LLM dispatch (long-running)
|
|
1787
|
-
|
|
1511
|
+
saveEvolutionQueue(queuePath, queue);
|
|
1788
1512
|
releaseLock();
|
|
1789
1513
|
lockReleased = true;
|
|
1790
1514
|
|
|
@@ -1830,7 +1554,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1830
1554
|
const manager = new CorrectionObserverWorkflowManager({
|
|
1831
1555
|
workspaceDir: wctx.workspaceDir,
|
|
1832
1556
|
logger,
|
|
1833
|
-
subagent: api?.runtime?.subagent!,
|
|
1557
|
+
subagent: api?.runtime?.subagent!, /* eslint-disable-line @typescript-eslint/no-non-null-assertion */
|
|
1834
1558
|
agentSession: api?.runtime?.agent?.session,
|
|
1835
1559
|
});
|
|
1836
1560
|
|
|
@@ -1844,7 +1568,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1844
1568
|
workflowId = handle.workflowId;
|
|
1845
1569
|
koTask.resultRef = workflowId;
|
|
1846
1570
|
} else {
|
|
1847
|
-
workflowId = koTask.resultRef!;
|
|
1571
|
+
workflowId = koTask.resultRef!; /* eslint-disable-line @typescript-eslint/no-non-null-assertion */
|
|
1848
1572
|
}
|
|
1849
1573
|
|
|
1850
1574
|
// Poll workflow state
|
|
@@ -1945,7 +1669,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1945
1669
|
}
|
|
1946
1670
|
|
|
1947
1671
|
if (queueChanged) {
|
|
1948
|
-
|
|
1672
|
+
saveEvolutionQueue(queuePath, queue);
|
|
1949
1673
|
}
|
|
1950
1674
|
|
|
1951
1675
|
// Pipeline observability: log stage-level summary at end of cycle
|
|
@@ -2051,8 +1775,8 @@ export async function registerEvolutionTaskSession(
|
|
|
2051
1775
|
}
|
|
2052
1776
|
|
|
2053
1777
|
// V2: Migrate queue to current schema
|
|
2054
|
-
const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue);
|
|
2055
|
-
|
|
1778
|
+
const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue) as unknown as EvolutionQueueItem[];
|
|
1779
|
+
|
|
2056
1780
|
const task = queue.find((item) => item.id === taskId && item.status === 'in_progress');
|
|
2057
1781
|
if (!task) {
|
|
2058
1782
|
logger?.warn?.(`[PD:EvolutionWorker] Could not find in-progress evolution task ${taskId} for session assignment`);
|
|
@@ -2063,7 +1787,7 @@ export async function registerEvolutionTaskSession(
|
|
|
2063
1787
|
if (!task.started_at) {
|
|
2064
1788
|
task.started_at = new Date().toISOString();
|
|
2065
1789
|
}
|
|
2066
|
-
|
|
1790
|
+
saveEvolutionQueue(queuePath, queue);
|
|
2067
1791
|
return true;
|
|
2068
1792
|
} finally {
|
|
2069
1793
|
releaseLock();
|
|
@@ -2134,7 +1858,7 @@ async function processEvolutionQueueWithResult(
|
|
|
2134
1858
|
const purgeResult = purgeStaleFailedTasks(queue, logger);
|
|
2135
1859
|
if (purgeResult.purged > 0) {
|
|
2136
1860
|
// Write back the cleaned queue
|
|
2137
|
-
|
|
1861
|
+
saveEvolutionQueue(queuePath, queue);
|
|
2138
1862
|
}
|
|
2139
1863
|
|
|
2140
1864
|
queueResult.total = queue.length;
|