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,3 +1,4 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
1
2
|
import * as fs from 'fs';
|
|
2
3
|
import * as path from 'path';
|
|
3
4
|
import { readPainFlagData } from '../core/pain.js';
|
|
@@ -554,7 +555,7 @@ export class HealthQueryService {
|
|
|
554
555
|
const streamPath = resolvePdPath(this.workspaceDir, 'EVOLUTION_STREAM');
|
|
555
556
|
if (!fs.existsSync(streamPath)) return [];
|
|
556
557
|
|
|
557
|
-
|
|
558
|
+
|
|
558
559
|
let lines: string[];
|
|
559
560
|
try {
|
|
560
561
|
const raw = fs.readFileSync(streamPath, 'utf8').trim();
|
|
@@ -567,7 +568,7 @@ export class HealthQueryService {
|
|
|
567
568
|
const records: RecentPrincipleChange[] = [];
|
|
568
569
|
for (const line of lines) {
|
|
569
570
|
|
|
570
|
-
|
|
571
|
+
|
|
571
572
|
let event: EvolutionStreamRecord | null;
|
|
572
573
|
try {
|
|
573
574
|
event = JSON.parse(line) as EvolutionStreamRecord;
|
|
@@ -788,7 +789,7 @@ export class HealthQueryService {
|
|
|
788
789
|
|
|
789
790
|
|
|
790
791
|
|
|
791
|
-
|
|
792
|
+
|
|
792
793
|
private getEventDedupKey(entry: EventLogEntry): string {
|
|
793
794
|
const eventId = typeof entry.data?.eventId === 'string' ? entry.data.eventId : null;
|
|
794
795
|
if (eventId) {
|
|
@@ -860,7 +861,7 @@ export class HealthQueryService {
|
|
|
860
861
|
|
|
861
862
|
|
|
862
863
|
|
|
863
|
-
|
|
864
|
+
|
|
864
865
|
private resolveGateType(row: GateBlockRow): string {
|
|
865
866
|
if (typeof row.gate_type === 'string' && row.gate_type.trim().length > 0) {
|
|
866
867
|
return row.gate_type;
|
|
@@ -885,7 +886,7 @@ export class HealthQueryService {
|
|
|
885
886
|
}
|
|
886
887
|
|
|
887
888
|
|
|
888
|
-
|
|
889
|
+
|
|
889
890
|
private scoreToStatus(score: number): string {
|
|
890
891
|
if (score >= 70) return 'healthy';
|
|
891
892
|
if (score >= 40) return 'warning';
|
|
@@ -893,7 +894,7 @@ export class HealthQueryService {
|
|
|
893
894
|
}
|
|
894
895
|
|
|
895
896
|
|
|
896
|
-
|
|
897
|
+
|
|
897
898
|
private evolutionToStatus(tier: string, points: number): string {
|
|
898
899
|
const lower = tier.toLowerCase();
|
|
899
900
|
if (lower === 'forest' || lower === 'tree') return 'healthy';
|
|
@@ -902,7 +903,7 @@ export class HealthQueryService {
|
|
|
902
903
|
}
|
|
903
904
|
|
|
904
905
|
|
|
905
|
-
|
|
906
|
+
|
|
906
907
|
private safeListFiles(dirPath: string, predicate: (_name: string) => boolean): string[] {
|
|
907
908
|
if (!fs.existsSync(dirPath)) return [];
|
|
908
909
|
try {
|
|
@@ -915,7 +916,7 @@ export class HealthQueryService {
|
|
|
915
916
|
}
|
|
916
917
|
|
|
917
918
|
|
|
918
|
-
|
|
919
|
+
|
|
919
920
|
private readJsonFile<T>(filePath: string, fallback: T): T {
|
|
920
921
|
if (!fs.existsSync(filePath)) return fallback;
|
|
921
922
|
try {
|
|
@@ -926,13 +927,13 @@ export class HealthQueryService {
|
|
|
926
927
|
}
|
|
927
928
|
|
|
928
929
|
|
|
929
|
-
|
|
930
|
+
|
|
930
931
|
private asNumber(value: unknown, fallback: number): number {
|
|
931
932
|
return Number.isFinite(value) ? Number(value) : fallback;
|
|
932
933
|
}
|
|
933
934
|
|
|
934
935
|
|
|
935
|
-
|
|
936
|
+
|
|
936
937
|
private asNullableNumber(value: unknown): number | null {
|
|
937
938
|
if (Number.isFinite(value)) return Number(value);
|
|
938
939
|
if (typeof value === 'string' && value.trim().length > 0) {
|
|
@@ -36,7 +36,7 @@ export class MonitoringQueryService {
|
|
|
36
36
|
const now = Date.now();
|
|
37
37
|
const workflowsWithStuckDetection = workflows.map(wf => {
|
|
38
38
|
// Parse metadata for timeout configuration
|
|
39
|
-
|
|
39
|
+
|
|
40
40
|
const metadata = parseWorkflowMetadata(wf.metadata_json);
|
|
41
41
|
const timeoutMs = metadata.timeoutMs ?? 15 * 60 * 1000; // Default 15 minutes
|
|
42
42
|
|
|
@@ -85,10 +85,10 @@ export class MonitoringQueryService {
|
|
|
85
85
|
|
|
86
86
|
// Determine status
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
|
|
89
89
|
let status: 'pending' | 'running' | 'completed' | 'failed';
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
|
|
92
92
|
let reason: string | undefined;
|
|
93
93
|
|
|
94
94
|
if (!startEvent) {
|
|
@@ -110,7 +110,7 @@ export class MonitoringQueryService {
|
|
|
110
110
|
|
|
111
111
|
// Calculate duration if stage started and completed/failed
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
|
|
114
114
|
let duration: number | undefined;
|
|
115
115
|
if (startEvent && (completeEvent || failedEvent)) {
|
|
116
116
|
const endEvent = completeEvent || failedEvent;
|
|
@@ -288,7 +288,7 @@ export class NocturnalTargetSelector {
|
|
|
288
288
|
};
|
|
289
289
|
|
|
290
290
|
|
|
291
|
-
|
|
291
|
+
|
|
292
292
|
constructor(
|
|
293
293
|
workspaceDir: string,
|
|
294
294
|
stateDir: string,
|
|
@@ -533,7 +533,7 @@ export class NocturnalTargetSelector {
|
|
|
533
533
|
* This is a convenience wrapper for the common case.
|
|
534
534
|
*/
|
|
535
535
|
|
|
536
|
-
|
|
536
|
+
|
|
537
537
|
export function selectNocturnalTarget(
|
|
538
538
|
workspaceDir: string,
|
|
539
539
|
stateDir: string,
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Queue I/O + Enqueue — extracted from evolution-worker.ts
|
|
3
|
+
*
|
|
4
|
+
* Full persistence layer encapsulating queue file locking, atomic writes,
|
|
5
|
+
* queue format, and enqueue orchestration. Depends on file-lock.ts, io.ts,
|
|
6
|
+
* queue-migration.ts, correction-cue-learner.ts, and pain.ts.
|
|
7
|
+
* Zero imports from evolution-worker.ts.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as fs from 'fs';
|
|
11
|
+
import { createHash } from 'crypto';
|
|
12
|
+
import { acquireLockAsync, releaseLock as releaseImportedLock, type LockContext } from '../utils/file-lock.js';
|
|
13
|
+
import { atomicWriteFileSync } from '../utils/io.js';
|
|
14
|
+
import { LockUnavailableError } from '../config/errors.js';
|
|
15
|
+
import { migrateQueueToV2 } from './queue-migration.js';
|
|
16
|
+
import type { EvolutionQueueItem } from '../core/evolution-types.js';
|
|
17
|
+
import type { RawQueueItem } from './queue-migration.js';
|
|
18
|
+
import type { PluginLogger } from '../openclaw-sdk.js';
|
|
19
|
+
import type { WorkspaceContext } from '../core/workspace-context.js';
|
|
20
|
+
import { CorrectionCueLearner } from '../core/correction-cue-learner.js';
|
|
21
|
+
import { readPainFlagContract } from '../core/pain.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Extended EvolutionQueueItem that includes the recentPainContext field.
|
|
25
|
+
* This field is added inline in evolution-worker.ts but needs to be available
|
|
26
|
+
* in queue-io.ts for the enqueue functions.
|
|
27
|
+
*/
|
|
28
|
+
interface EvolutionQueueItemWithPain extends EvolutionQueueItem {
|
|
29
|
+
recentPainContext?: RecentPainContext;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const EVOLUTION_QUEUE_LOCK_SUFFIX = '.lock';
|
|
33
|
+
export const LOCK_MAX_RETRIES = 50;
|
|
34
|
+
export const LOCK_RETRY_DELAY_MS = 50;
|
|
35
|
+
export const LOCK_STALE_MS = 30_000;
|
|
36
|
+
|
|
37
|
+
export const PAIN_QUEUE_DEDUP_WINDOW_MS = 4 * 60 * 60 * 1000; // 4 hours
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// requireQueueLock — thin wrapper that adds LockUnavailableError
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Acquire a queue lock, throwing LockUnavailableError on failure.
|
|
45
|
+
* This is the standard lock used across all queue operations.
|
|
46
|
+
*/
|
|
47
|
+
export async function requireQueueLock(
|
|
48
|
+
resourcePath: string,
|
|
49
|
+
logger: PluginLogger | { warn?: (message: string) => void; info?: (message: string) => void } | undefined,
|
|
50
|
+
scope: string,
|
|
51
|
+
lockSuffix: string = EVOLUTION_QUEUE_LOCK_SUFFIX,
|
|
52
|
+
): Promise<() => void> {
|
|
53
|
+
try {
|
|
54
|
+
return await acquireQueueLock(resourcePath, logger, lockSuffix);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
throw new LockUnavailableError(resourcePath, scope, { cause: err });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// RecentPainContext
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
export interface RecentPainContext {
|
|
65
|
+
mostRecent: {
|
|
66
|
+
score: number;
|
|
67
|
+
source: string;
|
|
68
|
+
reason: string;
|
|
69
|
+
timestamp: string;
|
|
70
|
+
sessionId: string;
|
|
71
|
+
} | null;
|
|
72
|
+
recentPainCount: number;
|
|
73
|
+
recentMaxPainScore: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Task ID creation
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
export function createEvolutionTaskId(
|
|
81
|
+
source: string,
|
|
82
|
+
score: number,
|
|
83
|
+
preview: string,
|
|
84
|
+
reason: string,
|
|
85
|
+
now: number,
|
|
86
|
+
): string {
|
|
87
|
+
return createHash('md5')
|
|
88
|
+
.update(`${source}:${score}:${preview}:${reason}:${now}`)
|
|
89
|
+
.digest('hex')
|
|
90
|
+
.substring(0, 8);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Queue helpers
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Check whether a specific task kind has a pending or in-progress entry.
|
|
99
|
+
*/
|
|
100
|
+
export function hasPendingTask(queue: EvolutionQueueItem[], taskKind: string): boolean {
|
|
101
|
+
return queue.some(
|
|
102
|
+
(t) => t.taskKind === taskKind && (t.status === 'pending' || t.status === 'in_progress'),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Build a dedup key from pain context.
|
|
108
|
+
* Returns null when no pain context is available (bypasses dedup).
|
|
109
|
+
*/
|
|
110
|
+
function buildPainSourceKey(
|
|
111
|
+
painCtx: ReturnType<typeof readRecentPainContext>,
|
|
112
|
+
): string | null {
|
|
113
|
+
if (!painCtx.mostRecent) return null;
|
|
114
|
+
return `${painCtx.mostRecent.source}::${painCtx.mostRecent.reason?.slice(0, 50) ?? ''}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check whether a similar sleep_reflection task completed recently.
|
|
119
|
+
*/
|
|
120
|
+
function hasRecentSimilarReflection(
|
|
121
|
+
queue: EvolutionQueueItemWithPain[],
|
|
122
|
+
painSourceKey: string,
|
|
123
|
+
now: number,
|
|
124
|
+
): EvolutionQueueItem | null {
|
|
125
|
+
return queue.find((t) => {
|
|
126
|
+
if (t.taskKind !== 'sleep_reflection') return false;
|
|
127
|
+
if (t.status !== 'completed') return false;
|
|
128
|
+
if (!t.completed_at) return false;
|
|
129
|
+
const age = now - new Date(t.completed_at).getTime();
|
|
130
|
+
if (age > PAIN_QUEUE_DEDUP_WINDOW_MS) return false;
|
|
131
|
+
const taskPainKey = buildPainSourceKey(t.recentPainContext ?? { mostRecent: null, recentPainCount: 0, recentMaxPainScore: 0 });
|
|
132
|
+
if (!taskPainKey) return false;
|
|
133
|
+
return taskPainKey === painSourceKey;
|
|
134
|
+
}) ?? null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Pain context
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
export function readRecentPainContext(wctx: WorkspaceContext): RecentPainContext {
|
|
142
|
+
const contract = readPainFlagContract(wctx.workspaceDir);
|
|
143
|
+
if (contract.status !== 'valid') {
|
|
144
|
+
return { mostRecent: null, recentPainCount: 0, recentMaxPainScore: 0 };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const score = parseInt(contract.data.score ?? '0', 10) || 0;
|
|
149
|
+
const source = contract.data.source ?? '';
|
|
150
|
+
const reason = contract.data.reason ?? '';
|
|
151
|
+
const timestamp = contract.data.time ?? '';
|
|
152
|
+
const sessionId = contract.data.session_id ?? '';
|
|
153
|
+
|
|
154
|
+
if (score > 0) {
|
|
155
|
+
return {
|
|
156
|
+
mostRecent: { score, source, reason, timestamp, sessionId },
|
|
157
|
+
recentPainCount: 1,
|
|
158
|
+
recentMaxPainScore: score,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
} catch (err) {
|
|
162
|
+
// Best effort — non-fatal, but surface unexpected errors
|
|
163
|
+
/* eslint-disable no-console */
|
|
164
|
+
console.warn(`[queue-io] Failed to read pain context (non-fatal): ${String(err)}`);
|
|
165
|
+
/* eslint-enable no-console */
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { mostRecent: null, recentPainCount: 0, recentMaxPainScore: 0 };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Decide whether to skip enqueuing due to a recent similar reflection.
|
|
173
|
+
*/
|
|
174
|
+
export function shouldSkipForDedup(
|
|
175
|
+
queue: EvolutionQueueItemWithPain[],
|
|
176
|
+
wctx: WorkspaceContext,
|
|
177
|
+
logger: PluginLogger | undefined,
|
|
178
|
+
): boolean {
|
|
179
|
+
const recentPainContext = readRecentPainContext(wctx);
|
|
180
|
+
const painSourceKey = buildPainSourceKey(recentPainContext);
|
|
181
|
+
|
|
182
|
+
if (!painSourceKey) return false;
|
|
183
|
+
|
|
184
|
+
const now = Date.now();
|
|
185
|
+
const recentSimilarReflection = hasRecentSimilarReflection(queue, painSourceKey, now);
|
|
186
|
+
|
|
187
|
+
if (recentSimilarReflection) {
|
|
188
|
+
const completedTime = new Date(recentSimilarReflection.completed_at!).getTime(); /* eslint-disable-line @typescript-eslint/no-non-null-assertion */
|
|
189
|
+
logger?.debug?.(`[PD:EvolutionWorker] Skipping sleep_reflection — similar reflection completed ${Math.round((now - completedTime) / 60000)}min ago (same pain pattern: ${painSourceKey})`);
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Enqueue functions
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
function enqueueNewSleepReflectionTask(
|
|
200
|
+
queue: EvolutionQueueItemWithPain[],
|
|
201
|
+
recentPainContext: ReturnType<typeof readRecentPainContext>,
|
|
202
|
+
queuePath: string,
|
|
203
|
+
logger: PluginLogger | undefined,
|
|
204
|
+
): void {
|
|
205
|
+
const taskId = createEvolutionTaskId('nocturnal', 50, 'idle workspace', 'Sleep-mode reflection', Date.now());
|
|
206
|
+
const nowIso = new Date().toISOString();
|
|
207
|
+
|
|
208
|
+
queue.push({
|
|
209
|
+
id: taskId,
|
|
210
|
+
taskKind: 'sleep_reflection',
|
|
211
|
+
priority: 'medium',
|
|
212
|
+
score: 50,
|
|
213
|
+
source: 'nocturnal',
|
|
214
|
+
reason: 'Sleep-mode reflection triggered by idle workspace',
|
|
215
|
+
trigger_text_preview: 'Idle workspace detected',
|
|
216
|
+
timestamp: nowIso,
|
|
217
|
+
enqueued_at: nowIso,
|
|
218
|
+
status: 'pending',
|
|
219
|
+
traceId: taskId,
|
|
220
|
+
retryCount: 0,
|
|
221
|
+
maxRetries: 1,
|
|
222
|
+
recentPainContext,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Cast to EvolutionQueueItem[] because saveEvolutionQueue expects the base type
|
|
226
|
+
// but the queue may contain extended fields (recentPainContext) that are
|
|
227
|
+
// serialized as part of the JSON - this is safe at runtime.
|
|
228
|
+
saveEvolutionQueue(queuePath, queue as unknown as EvolutionQueueItem[]);
|
|
229
|
+
logger?.info?.(`[PD:EvolutionWorker] Enqueued sleep_reflection task ${taskId}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Enqueue a sleep_reflection task if one is not already pending.
|
|
234
|
+
*/
|
|
235
|
+
export async function enqueueSleepReflectionTask(
|
|
236
|
+
wctx: WorkspaceContext,
|
|
237
|
+
logger: PluginLogger | undefined,
|
|
238
|
+
): Promise<void> {
|
|
239
|
+
const queuePath = wctx.resolve('EVOLUTION_QUEUE');
|
|
240
|
+
const releaseLock = await requireQueueLock(queuePath, logger, 'enqueueSleepReflection', EVOLUTION_QUEUE_LOCK_SUFFIX);
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const queue = loadEvolutionQueue(queuePath);
|
|
244
|
+
|
|
245
|
+
if (hasPendingTask(queue, 'sleep_reflection')) {
|
|
246
|
+
logger?.debug?.('[PD:EvolutionWorker] sleep_reflection task already pending/in-progress, skipping');
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (shouldSkipForDedup(queue, wctx, logger)) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const recentPainContext = readRecentPainContext(wctx);
|
|
255
|
+
enqueueNewSleepReflectionTask(queue, recentPainContext, queuePath, logger);
|
|
256
|
+
} finally {
|
|
257
|
+
releaseLock();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Enqueue a keyword_optimization task if one is not already pending/in-progress.
|
|
263
|
+
*/
|
|
264
|
+
export async function enqueueKeywordOptimizationTask(
|
|
265
|
+
wctx: WorkspaceContext,
|
|
266
|
+
logger: PluginLogger | undefined,
|
|
267
|
+
): Promise<void> {
|
|
268
|
+
const queuePath = wctx.resolve('EVOLUTION_QUEUE');
|
|
269
|
+
const releaseLock = await requireQueueLock(queuePath, logger, 'enqueueKeywordOpt', EVOLUTION_QUEUE_LOCK_SUFFIX);
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
const queue = loadEvolutionQueue(queuePath);
|
|
273
|
+
|
|
274
|
+
if (hasPendingTask(queue, 'keyword_optimization')) {
|
|
275
|
+
logger?.debug?.('[PD:EvolutionWorker] keyword_optimization task already pending/in-progress, skipping');
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const learner = CorrectionCueLearner.get(wctx.stateDir);
|
|
280
|
+
if (!learner.canRunKeywordOptimization()) {
|
|
281
|
+
logger?.debug?.('[PD:EvolutionWorker] keyword_optimization throttle exhausted, skipping');
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const taskId = createEvolutionTaskId('keyword_optimization', 50, 'keyword optimization', 'Keyword optimization via LLM', Date.now());
|
|
286
|
+
const nowIso = new Date().toISOString();
|
|
287
|
+
|
|
288
|
+
queue.push({
|
|
289
|
+
id: taskId,
|
|
290
|
+
taskKind: 'keyword_optimization',
|
|
291
|
+
priority: 'medium',
|
|
292
|
+
score: 50,
|
|
293
|
+
source: 'correction',
|
|
294
|
+
reason: 'Keyword optimization triggered by heartbeat',
|
|
295
|
+
trigger_text_preview: 'Keyword optimization via LLM',
|
|
296
|
+
timestamp: nowIso,
|
|
297
|
+
enqueued_at: nowIso,
|
|
298
|
+
status: 'pending',
|
|
299
|
+
traceId: taskId,
|
|
300
|
+
retryCount: 0,
|
|
301
|
+
maxRetries: 1,
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
saveEvolutionQueue(queuePath, queue);
|
|
305
|
+
logger?.info?.(`[PD:EvolutionWorker] Enqueued keyword_optimization task ${taskId}`);
|
|
306
|
+
} finally {
|
|
307
|
+
releaseLock();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export async function acquireQueueLock(
|
|
312
|
+
resourcePath: string,
|
|
313
|
+
logger: PluginLogger | { warn?: (message: string) => void; info?: (message: string) => void } | undefined,
|
|
314
|
+
lockSuffix: string = EVOLUTION_QUEUE_LOCK_SUFFIX,
|
|
315
|
+
): Promise<() => void> {
|
|
316
|
+
try {
|
|
317
|
+
const ctx: LockContext = await acquireLockAsync(resourcePath, {
|
|
318
|
+
lockSuffix,
|
|
319
|
+
maxRetries: LOCK_MAX_RETRIES,
|
|
320
|
+
baseRetryDelayMs: LOCK_RETRY_DELAY_MS,
|
|
321
|
+
lockStaleMs: LOCK_STALE_MS,
|
|
322
|
+
});
|
|
323
|
+
return () => releaseImportedLock(ctx);
|
|
324
|
+
} catch (error: unknown) {
|
|
325
|
+
const warn = logger?.warn;
|
|
326
|
+
warn?.(`[PD:EvolutionWorker] Failed to acquire lock for ${resourcePath}: ${String(error)}`);
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* RAII-style lock guard — always releases the lock on exceptions.
|
|
333
|
+
*/
|
|
334
|
+
export async function withQueueLock<T>(
|
|
335
|
+
resourcePath: string,
|
|
336
|
+
logger: PluginLogger | { warn?: (message: string) => void; info?: (message: string) => void } | undefined,
|
|
337
|
+
scope: string,
|
|
338
|
+
fn: () => Promise<T>,
|
|
339
|
+
): Promise<T> {
|
|
340
|
+
const releaseLock = await acquireQueueLock(resourcePath, logger, EVOLUTION_QUEUE_LOCK_SUFFIX);
|
|
341
|
+
try {
|
|
342
|
+
return await fn();
|
|
343
|
+
} finally {
|
|
344
|
+
releaseLock();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Load and migrate the evolution queue. Returns empty array if file doesn't exist.
|
|
350
|
+
*/
|
|
351
|
+
export function loadEvolutionQueue(queuePath: string): EvolutionQueueItem[] {
|
|
352
|
+
let rawQueue: RawQueueItem[] = [];
|
|
353
|
+
try {
|
|
354
|
+
rawQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
|
|
355
|
+
} catch (err) {
|
|
356
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
357
|
+
// Queue doesn't exist yet - create empty array
|
|
358
|
+
rawQueue = [];
|
|
359
|
+
} else {
|
|
360
|
+
// Corrupted JSON or other read error — warn and recover with empty queue
|
|
361
|
+
/* eslint-disable no-console */
|
|
362
|
+
console.warn(`[queue-io] Failed to load evolution queue (recovering with empty): ${String(err)}`);
|
|
363
|
+
/* eslint-enable no-console */
|
|
364
|
+
rawQueue = [];
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return migrateQueueToV2(rawQueue) as unknown as EvolutionQueueItem[];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Atomically write the queue to disk.
|
|
372
|
+
*/
|
|
373
|
+
export function saveEvolutionQueue(queuePath: string, queue: EvolutionQueueItem[]): void {
|
|
374
|
+
atomicWriteFileSync(queuePath, JSON.stringify(queue, null, 2));
|
|
375
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Queue Migration — extracted from evolution-worker.ts (lines 297-379)
|
|
3
|
+
*
|
|
4
|
+
* Pure data transformation functions for migrating legacy queue items
|
|
5
|
+
* to the V2 schema. Zero I/O, zero imports from evolution-worker.ts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
|
|
9
|
+
|
|
10
|
+
// V2 types (not exported from evolution-types.ts — defined here for self-containment)
|
|
11
|
+
export type QueueStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
|
|
12
|
+
export type TaskResolution = 'success' | 'failure' | 'skipped';
|
|
13
|
+
export interface EvolutionQueueItem {
|
|
14
|
+
id: string;
|
|
15
|
+
taskKind: TaskKind;
|
|
16
|
+
priority: TaskPriority;
|
|
17
|
+
source: string;
|
|
18
|
+
traceId?: string;
|
|
19
|
+
task?: string;
|
|
20
|
+
score: number;
|
|
21
|
+
reason: string;
|
|
22
|
+
timestamp: string;
|
|
23
|
+
enqueued_at?: string;
|
|
24
|
+
started_at?: string;
|
|
25
|
+
completed_at?: string;
|
|
26
|
+
assigned_session_key?: string;
|
|
27
|
+
trigger_text_preview?: string;
|
|
28
|
+
status: QueueStatus;
|
|
29
|
+
resolution?: TaskResolution;
|
|
30
|
+
session_id?: string;
|
|
31
|
+
agent_id?: string;
|
|
32
|
+
retryCount: number;
|
|
33
|
+
maxRetries: number;
|
|
34
|
+
lastError?: string;
|
|
35
|
+
resultRef?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Legacy queue item shape (pre-V2) for migration compatibility.
|
|
40
|
+
* These items lack taskKind, priority, retryCount, maxRetries, lastError fields.
|
|
41
|
+
*/
|
|
42
|
+
export interface LegacyEvolutionQueueItem {
|
|
43
|
+
id: string;
|
|
44
|
+
task?: string;
|
|
45
|
+
score: number;
|
|
46
|
+
source: string;
|
|
47
|
+
reason: string;
|
|
48
|
+
timestamp: string;
|
|
49
|
+
enqueued_at?: string;
|
|
50
|
+
started_at?: string;
|
|
51
|
+
completed_at?: string;
|
|
52
|
+
assigned_session_key?: string;
|
|
53
|
+
trigger_text_preview?: string;
|
|
54
|
+
status?: string;
|
|
55
|
+
resolution?: string;
|
|
56
|
+
session_id?: string;
|
|
57
|
+
agent_id?: string;
|
|
58
|
+
traceId?: string;
|
|
59
|
+
taskKind?: string;
|
|
60
|
+
priority?: string;
|
|
61
|
+
retryCount?: number;
|
|
62
|
+
maxRetries?: number;
|
|
63
|
+
lastError?: string;
|
|
64
|
+
resultRef?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Default values for new V2 fields when migrating legacy items.
|
|
69
|
+
*/
|
|
70
|
+
const DEFAULT_TASK_KIND: TaskKind = 'pain_diagnosis';
|
|
71
|
+
const DEFAULT_PRIORITY: TaskPriority = 'medium';
|
|
72
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
73
|
+
|
|
74
|
+
export { DEFAULT_TASK_KIND, DEFAULT_PRIORITY, DEFAULT_MAX_RETRIES };
|
|
75
|
+
|
|
76
|
+
export type RawQueueItem = Record<string, unknown>;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Migrate a legacy queue item to V2 schema.
|
|
80
|
+
* Old items without taskKind are assumed to be pain_diagnosis for backward compatibility.
|
|
81
|
+
*/
|
|
82
|
+
export function migrateToV2(item: LegacyEvolutionQueueItem): EvolutionQueueItem {
|
|
83
|
+
return {
|
|
84
|
+
id: item.id,
|
|
85
|
+
taskKind: (item.taskKind as TaskKind) || DEFAULT_TASK_KIND,
|
|
86
|
+
priority: (item.priority as TaskPriority) || DEFAULT_PRIORITY,
|
|
87
|
+
source: item.source,
|
|
88
|
+
traceId: item.traceId,
|
|
89
|
+
task: item.task,
|
|
90
|
+
score: item.score,
|
|
91
|
+
reason: item.reason,
|
|
92
|
+
timestamp: item.timestamp,
|
|
93
|
+
enqueued_at: item.enqueued_at,
|
|
94
|
+
started_at: item.started_at,
|
|
95
|
+
completed_at: item.completed_at,
|
|
96
|
+
assigned_session_key: item.assigned_session_key,
|
|
97
|
+
trigger_text_preview: item.trigger_text_preview,
|
|
98
|
+
status: (item.status as QueueStatus) || 'pending',
|
|
99
|
+
resolution: item.resolution as TaskResolution | undefined,
|
|
100
|
+
session_id: item.session_id,
|
|
101
|
+
agent_id: item.agent_id,
|
|
102
|
+
retryCount: item.retryCount || 0,
|
|
103
|
+
maxRetries: item.maxRetries || DEFAULT_MAX_RETRIES,
|
|
104
|
+
lastError: item.lastError,
|
|
105
|
+
resultRef: item.resultRef,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Check if an item is a legacy (pre-V2) queue item.
|
|
111
|
+
*/
|
|
112
|
+
export function isLegacyQueueItem(item: RawQueueItem): boolean {
|
|
113
|
+
return item && typeof item === 'object' && !('taskKind' in item);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Migrate entire queue to V2 schema if needed.
|
|
118
|
+
* Returns a new array with all items migrated to V2 format.
|
|
119
|
+
*/
|
|
120
|
+
export function migrateQueueToV2(queue: RawQueueItem[]): EvolutionQueueItem[] {
|
|
121
|
+
return queue.map(item => isLegacyQueueItem(item) ? migrateToV2(item as unknown as LegacyEvolutionQueueItem) : item as unknown as EvolutionQueueItem);
|
|
122
|
+
}
|
|
@@ -420,7 +420,7 @@ export class RuntimeSummaryService {
|
|
|
420
420
|
* Queue is the only authoritative execution truth source.
|
|
421
421
|
*/
|
|
422
422
|
|
|
423
|
-
|
|
423
|
+
|
|
424
424
|
private static buildDirectiveSummary(
|
|
425
425
|
queue: QueueItem[] | null,
|
|
426
426
|
directive: DirectiveFile | null,
|