principles-disciple 1.35.0 → 1.37.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/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/commands/nocturnal-train.ts +1 -0
- package/src/core/correction-cue-learner.ts +23 -8
- package/src/core/event-log.ts +3 -0
- package/src/core/evolution-engine.ts +1 -0
- package/src/core/init.ts +2 -2
- package/src/core/nocturnal-trinity-types.ts +124 -0
- package/src/core/session-tracker.ts +1 -0
- package/src/core/training-program.ts +1 -0
- package/src/hooks/gate-block-helper.ts +1 -1
- package/src/hooks/prompt.ts +3 -3
- package/src/index.ts +2 -1
- package/src/service/central-sync-service.ts +2 -0
- package/src/service/evolution-dedup.ts +74 -0
- package/src/service/evolution-pain-context.ts +79 -0
- package/src/service/evolution-queue-lock.ts +47 -0
- package/src/service/evolution-queue-migration.ts +173 -0
- package/src/service/evolution-worker.ts +43 -34
- package/src/service/keyword-optimization-service.ts +2 -2
- package/src/service/subagent-workflow/correction-observer-types.ts +69 -0
- package/src/service/subagent-workflow/correction-observer-workflow-manager.ts +246 -0
- package/src/service/subagent-workflow/index.ts +13 -0
- package/src/service/subagent-workflow/workflow-manager-base.ts +1 -0
- package/tests/core/correction-cue-learner.test.ts +345 -0
- package/tests/core/pain-score.property.test.ts +205 -0
- package/tests/integration/chaos-resilience.test.ts +348 -0
- package/tests/integration/gate-real-io.e2e.test.ts +251 -0
- package/tests/integration/pain-diagnostician-loop.e2e.test.ts +380 -0
- package/tests/integration/tool-hooks-workspace-dir.e2e.test.ts +8 -2
- package/tests/integration/trajectory-lifecycle.e2e.test.ts +523 -0
- package/vitest.config.ts +23 -4
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -21,10 +21,17 @@ import {
|
|
|
21
21
|
CORRECTION_SEED_KEYWORDS,
|
|
22
22
|
MAX_CORRECTION_KEYWORDS,
|
|
23
23
|
} from './correction-types.js';
|
|
24
|
-
import { checkCooldown
|
|
24
|
+
import { checkCooldown } from '../service/nocturnal-runtime.js';
|
|
25
25
|
|
|
26
26
|
const KEYWORD_STORE_FILE = 'correction_keywords.json';
|
|
27
27
|
|
|
28
|
+
// CORR-08: Daily optimization throttle (uses checkCooldown in nocturnal-runtime.ts)
|
|
29
|
+
// Note: throttle state is stored in nocturnal-runtime.json, not a separate file.
|
|
30
|
+
|
|
31
|
+
// Weight bounds for correction keywords (D-39-03, D-39-15)
|
|
32
|
+
const MIN_KEYWORD_WEIGHT = 0.1;
|
|
33
|
+
const MAX_KEYWORD_WEIGHT = 0.9;
|
|
34
|
+
|
|
28
35
|
// =========================================================================
|
|
29
36
|
// Module-level cache (D-04, D-05)
|
|
30
37
|
// =========================================================================
|
|
@@ -112,6 +119,8 @@ export function saveCorrectionKeywordStore(
|
|
|
112
119
|
_correctionCueCache = null;
|
|
113
120
|
}
|
|
114
121
|
|
|
122
|
+
// =========================================================================
|
|
123
|
+
// Throttle helpers (CORR-08)
|
|
115
124
|
// =========================================================================
|
|
116
125
|
// Singleton state
|
|
117
126
|
// =========================================================================
|
|
@@ -217,7 +226,7 @@ export class CorrectionCueLearner {
|
|
|
217
226
|
keyword.hitCount = (keyword.hitCount ?? 0) + 1;
|
|
218
227
|
|
|
219
228
|
// D-39-15: Multiplicative weight decay x0.8 on confirmed FP
|
|
220
|
-
keyword.weight = Math.max(
|
|
229
|
+
keyword.weight = Math.max(MIN_KEYWORD_WEIGHT, keyword.weight * 0.8);
|
|
221
230
|
keyword.lastHitAt = new Date().toISOString();
|
|
222
231
|
|
|
223
232
|
this.flush();
|
|
@@ -238,10 +247,10 @@ export class CorrectionCueLearner {
|
|
|
238
247
|
|
|
239
248
|
/**
|
|
240
249
|
* Records that an optimization was performed.
|
|
241
|
-
*
|
|
250
|
+
* Updates lastOptimizedAt for the store. Throttle state is managed
|
|
251
|
+
* by checkCooldown() — no separate throttle file needed (CORR-08).
|
|
242
252
|
*/
|
|
243
|
-
|
|
244
|
-
await recordCooldown(this.stateDir, 24 * 60 * 60 * 1000);
|
|
253
|
+
recordOptimizationPerformed(): void {
|
|
245
254
|
this.store.lastOptimizedAt = new Date().toISOString();
|
|
246
255
|
this.flush();
|
|
247
256
|
}
|
|
@@ -270,14 +279,20 @@ export class CorrectionCueLearner {
|
|
|
270
279
|
* Throws if keyword not found.
|
|
271
280
|
*/
|
|
272
281
|
updateWeight(term: string, weight: number): void {
|
|
273
|
-
const
|
|
282
|
+
const keyword = this.store.keywords.find(
|
|
274
283
|
k => k.term.toLowerCase() === term.toLowerCase()
|
|
275
284
|
);
|
|
276
|
-
if (
|
|
285
|
+
if (!keyword) {
|
|
277
286
|
throw new Error(`Keyword not found: ${term}`);
|
|
278
287
|
}
|
|
279
288
|
|
|
280
|
-
|
|
289
|
+
keyword.weight = Math.max(MIN_KEYWORD_WEIGHT, Math.min(MAX_KEYWORD_WEIGHT, weight)); // Clamp to MIN-MAX_KEYWORD_WEIGHT
|
|
290
|
+
const idx = this.store.keywords.findIndex(
|
|
291
|
+
k => k.term.toLowerCase() === term.toLowerCase()
|
|
292
|
+
);
|
|
293
|
+
if (idx >= 0) {
|
|
294
|
+
this.store.keywords[idx] = { ...keyword };
|
|
295
|
+
}
|
|
281
296
|
this.flush();
|
|
282
297
|
}
|
|
283
298
|
|
package/src/core/event-log.ts
CHANGED
|
@@ -295,6 +295,9 @@ export class EventLog {
|
|
|
295
295
|
|
|
296
296
|
private startFlushTimer(): void {
|
|
297
297
|
this.flushTimer = setInterval(() => this.flush(), this.flushIntervalMs);
|
|
298
|
+
// Don't keep the process alive just for this timer
|
|
299
|
+
// This allows tests and CLI to exit without waiting for flush
|
|
300
|
+
this.flushTimer.unref();
|
|
298
301
|
}
|
|
299
302
|
|
|
300
303
|
flush(): void {
|
package/src/core/init.ts
CHANGED
|
@@ -46,7 +46,7 @@ export function ensureWorkspaceTemplates(api: OpenClawPluginApi, workspaceDir: s
|
|
|
46
46
|
if (fs.existsSync(commonTemplatesDir)) {
|
|
47
47
|
api.logger.info(`[PD] Syncing workspace templates: ${workspaceDir}...`);
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
|
|
50
50
|
copyRecursiveSync(commonTemplatesDir, workspaceDir, api);
|
|
51
51
|
}
|
|
52
52
|
|
|
@@ -89,7 +89,7 @@ export function ensureWorkspaceTemplates(api: OpenClawPluginApi, workspaceDir: s
|
|
|
89
89
|
fs.mkdirSync(painDestDir, { recursive: true });
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
|
|
92
|
+
|
|
93
93
|
copyRecursiveSync(painTemplatesDir, painDestDir, api);
|
|
94
94
|
}
|
|
95
95
|
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* Extracted to break circular dependency.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import type { TrinityArtificerContext } from './nocturnal-artificer.js';
|
|
9
|
+
|
|
8
10
|
// ---------------------------------------------------------------------------
|
|
9
11
|
// Dreamer Types
|
|
10
12
|
// ---------------------------------------------------------------------------
|
|
@@ -92,3 +94,125 @@ export interface PhilosopherOutput {
|
|
|
92
94
|
/** Timestamp of generation */
|
|
93
95
|
generatedAt: string;
|
|
94
96
|
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Trinity Result Types
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Tournament trace entry for explainability.
|
|
104
|
+
*/
|
|
105
|
+
export interface TournamentTraceEntry {
|
|
106
|
+
candidateIndex: number;
|
|
107
|
+
reason: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Analysis of a rejected candidate — why it lost the tournament.
|
|
112
|
+
* Informs training signal for "what to avoid".
|
|
113
|
+
*/
|
|
114
|
+
export interface RejectedAnalysis {
|
|
115
|
+
/** Mental model that led to the rejected candidate */
|
|
116
|
+
whyRejected: string;
|
|
117
|
+
/** Observable caution triggers that were missed or ignored */
|
|
118
|
+
warningSignals: string[];
|
|
119
|
+
/** Correct reasoning path that should have been seen */
|
|
120
|
+
correctiveThinking: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Justification for the chosen candidate — why it won the tournament.
|
|
125
|
+
* Informs training signal for "what to do".
|
|
126
|
+
*/
|
|
127
|
+
export interface ChosenJustification {
|
|
128
|
+
/** Why this candidate was selected over others */
|
|
129
|
+
whyChosen: string;
|
|
130
|
+
/** 1-3 transferable insights from this decision */
|
|
131
|
+
keyInsights: string[];
|
|
132
|
+
/** When this approach does NOT apply */
|
|
133
|
+
limitations: string[];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Contrastive analysis: key differences between chosen and rejected paths.
|
|
138
|
+
* Synthesizes the core lesson from the tournament.
|
|
139
|
+
*/
|
|
140
|
+
export interface ContrastiveAnalysis {
|
|
141
|
+
/** ONE key insight distinguishing chosen from rejected */
|
|
142
|
+
criticalDifference: string;
|
|
143
|
+
/** Pattern: "When X, do Y" */
|
|
144
|
+
decisionTrigger: string;
|
|
145
|
+
/** How to systematically avoid the rejected path */
|
|
146
|
+
preventionStrategy: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Telemetry about Trinity chain execution.
|
|
151
|
+
*/
|
|
152
|
+
export interface TrinityTelemetry {
|
|
153
|
+
chainMode: 'trinity' | 'single-reflector';
|
|
154
|
+
usedStubs: boolean;
|
|
155
|
+
dreamerPassed: boolean;
|
|
156
|
+
philosopherPassed: boolean;
|
|
157
|
+
scribePassed: boolean;
|
|
158
|
+
candidateCount: number;
|
|
159
|
+
selectedCandidateIndex: number;
|
|
160
|
+
stageFailures: string[];
|
|
161
|
+
tournamentTrace?: TournamentTraceEntry[];
|
|
162
|
+
winnerAggregateScore?: number;
|
|
163
|
+
winnerThresholdPassed?: boolean;
|
|
164
|
+
eligibleCandidateCount?: number;
|
|
165
|
+
diversityCheckPassed?: boolean;
|
|
166
|
+
candidateRiskLevels?: string[];
|
|
167
|
+
philosopher6D?: {
|
|
168
|
+
avgScores: {
|
|
169
|
+
principleAlignment: number;
|
|
170
|
+
specificity: number;
|
|
171
|
+
actionability: number;
|
|
172
|
+
executability: number;
|
|
173
|
+
safetyImpact: number;
|
|
174
|
+
uxImpact: number;
|
|
175
|
+
};
|
|
176
|
+
highRiskCount: number;
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Validation failure for a Trinity stage.
|
|
182
|
+
*/
|
|
183
|
+
export interface TrinityStageFailure {
|
|
184
|
+
stage: 'dreamer' | 'philosopher' | 'scribe';
|
|
185
|
+
reason: string;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Result of Trinity chain execution.
|
|
190
|
+
*/
|
|
191
|
+
export interface TrinityResult {
|
|
192
|
+
success: boolean;
|
|
193
|
+
artifact?: TrinityDraftArtifact;
|
|
194
|
+
telemetry: TrinityTelemetry;
|
|
195
|
+
failures: TrinityStageFailure[];
|
|
196
|
+
fallbackOccurred: boolean;
|
|
197
|
+
artificerContext?: TrinityArtificerContext;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Scribe output — final structured artifact draft.
|
|
202
|
+
*/
|
|
203
|
+
export interface TrinityDraftArtifact {
|
|
204
|
+
selectedCandidateIndex: number;
|
|
205
|
+
badDecision: string;
|
|
206
|
+
betterDecision: string;
|
|
207
|
+
rationale: string;
|
|
208
|
+
sessionId: string;
|
|
209
|
+
principleId: string;
|
|
210
|
+
sourceSnapshotRef: string;
|
|
211
|
+
telemetry: TrinityTelemetry;
|
|
212
|
+
thinkingModelDelta?: number;
|
|
213
|
+
planningRatioGain?: number;
|
|
214
|
+
artificerContext?: TrinityArtificerContext;
|
|
215
|
+
contrastiveAnalysis?: ContrastiveAnalysis;
|
|
216
|
+
rejectedAnalysis?: RejectedAnalysis;
|
|
217
|
+
chosenJustification?: ChosenJustification;
|
|
218
|
+
}
|
|
@@ -166,6 +166,7 @@ function schedulePersistence(state: SessionState): void {
|
|
|
166
166
|
persistSession(state);
|
|
167
167
|
persistTimers.delete(state.sessionId);
|
|
168
168
|
}, 1000); // 1 second debounce
|
|
169
|
+
timer.unref(); // Don't keep process alive for persistence
|
|
169
170
|
persistTimers.set(state.sessionId, timer);
|
|
170
171
|
}
|
|
171
172
|
|
|
@@ -362,6 +362,7 @@ export async function executeTrainer(
|
|
|
362
362
|
proc.kill();
|
|
363
363
|
reject(new Error(`Trainer timed out after ${timeoutMs}ms`));
|
|
364
364
|
}, timeoutMs);
|
|
365
|
+
timer.unref(); // Don't keep process alive for timeout
|
|
365
366
|
|
|
366
367
|
proc.on('close', (code) => {
|
|
367
368
|
clearTimeout(timer);
|
|
@@ -210,5 +210,5 @@ function scheduleTrajectoryGateBlockRetry(
|
|
|
210
210
|
logWarn(`[PD_GATE] Retrying trajectory gate block persistence (attempt ${attempt + 1}): ${String(error)}`);
|
|
211
211
|
scheduleTrajectoryGateBlockRetry(wctx, payload, attempt + 1, logWarn, logError);
|
|
212
212
|
}
|
|
213
|
-
}, TRAJECTORY_GATE_BLOCK_RETRY_DELAY_MS * attempt);
|
|
213
|
+
}, TRAJECTORY_GATE_BLOCK_RETRY_DELAY_MS * attempt).unref();
|
|
214
214
|
}
|
package/src/hooks/prompt.ts
CHANGED
|
@@ -368,7 +368,7 @@ export async function handleBeforePromptBuild(
|
|
|
368
368
|
// prependContext: Only short dynamic directives: evolutionDirective + heartbeat
|
|
369
369
|
|
|
370
370
|
|
|
371
|
-
|
|
371
|
+
|
|
372
372
|
let prependSystemContext: string;
|
|
373
373
|
let prependContext = '';
|
|
374
374
|
let appendSystemContext = '';
|
|
@@ -684,7 +684,7 @@ ${taskBlocks}${processingNote}
|
|
|
684
684
|
|
|
685
685
|
// ──── 6. Dynamic Attitude Matrix (based on GFI) ────
|
|
686
686
|
|
|
687
|
-
|
|
687
|
+
|
|
688
688
|
let attitudeDirective: string;
|
|
689
689
|
const currentGfi = session?.currentGfi || 0;
|
|
690
690
|
|
|
@@ -910,7 +910,7 @@ ${taskBlocks}${processingNote}
|
|
|
910
910
|
const toolMatches = toolPatterns.flatMap(({ pattern, tool }) => {
|
|
911
911
|
const matches: string[] = [];
|
|
912
912
|
|
|
913
|
-
|
|
913
|
+
|
|
914
914
|
let _m;
|
|
915
915
|
const r = new RegExp(pattern.source, pattern.flags);
|
|
916
916
|
|
package/src/index.ts
CHANGED
|
@@ -87,7 +87,7 @@ const plugin = {
|
|
|
87
87
|
|
|
88
88
|
// ── Startup Health Check: Verify workspaceDir resolution ──
|
|
89
89
|
// Catches OpenClaw context bugs early (e.g., missing workspaceDir in tool hooks)
|
|
90
|
-
setTimeout(() => {
|
|
90
|
+
const healthCheckTimer = setTimeout(() => {
|
|
91
91
|
const testCtx = { agentId: 'main' };
|
|
92
92
|
const toolWorkspaceDir = resolveToolHookWorkspaceDirSafe(testCtx, api, 'startup.health_check');
|
|
93
93
|
const toolIssue = validateWorkspaceDir(toolWorkspaceDir);
|
|
@@ -98,6 +98,7 @@ const plugin = {
|
|
|
98
98
|
api.logger.info(`[PD:health] Tool hook workspaceDir OK: "${toolWorkspaceDir}"`);
|
|
99
99
|
}
|
|
100
100
|
}, 1000);
|
|
101
|
+
healthCheckTimer.unref(); // Don't keep process alive for health check
|
|
101
102
|
|
|
102
103
|
const language = (api.pluginConfig?.language as string) || 'en';
|
|
103
104
|
|
|
@@ -57,6 +57,8 @@ export const CentralSyncService: OpenClawPluginService = {
|
|
|
57
57
|
|
|
58
58
|
// Schedule periodic sync
|
|
59
59
|
syncInterval = setInterval(runSyncCycle, intervalMs);
|
|
60
|
+
// Don't keep the process alive just for this timer
|
|
61
|
+
syncInterval.unref();
|
|
60
62
|
|
|
61
63
|
logger?.info?.(`[PD:CentralSync] Service started, syncing every ${intervalMs / 1000}s`);
|
|
62
64
|
},
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evolution Queue Deduplication Utilities
|
|
3
|
+
*
|
|
4
|
+
* Dedup logic for preventing duplicate pain tasks and redundant reflections.
|
|
5
|
+
* Extracted from evolution-worker.ts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { EvolutionQueueItem } from './evolution-queue-migration.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Dedup window for pain queue tasks (30 minutes).
|
|
12
|
+
*/
|
|
13
|
+
export const PAIN_QUEUE_DEDUP_WINDOW_MS = 30 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Maximum length for dedup key components to prevent memory/performance issues
|
|
17
|
+
* from extremely long source or preview strings during queue scanning.
|
|
18
|
+
*/
|
|
19
|
+
const MAX_DEDUP_KEY_COMPONENT_LENGTH = 200;
|
|
20
|
+
|
|
21
|
+
function normalizePainDedupKey(source: string, preview: string, reason?: string): string {
|
|
22
|
+
const truncate = (s: string) => s.slice(0, MAX_DEDUP_KEY_COMPONENT_LENGTH);
|
|
23
|
+
const normalizedReason = (reason || '').trim().toLowerCase().slice(0, 50);
|
|
24
|
+
return `${truncate(source.trim().toLowerCase())}::${truncate(preview.trim().toLowerCase())}::${normalizedReason}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function findRecentDuplicateTask(
|
|
28
|
+
queue: EvolutionQueueItem[],
|
|
29
|
+
source: string,
|
|
30
|
+
preview: string,
|
|
31
|
+
now: number,
|
|
32
|
+
reason?: string
|
|
33
|
+
): EvolutionQueueItem | undefined {
|
|
34
|
+
const key = normalizePainDedupKey(source, preview, reason);
|
|
35
|
+
return queue.find((task) => {
|
|
36
|
+
if (task.status === 'completed') return false;
|
|
37
|
+
const taskTime = new Date(task.enqueued_at || task.timestamp).getTime();
|
|
38
|
+
if (!Number.isFinite(taskTime) || (now - taskTime) > PAIN_QUEUE_DEDUP_WINDOW_MS) return false;
|
|
39
|
+
return normalizePainDedupKey(task.source, task.trigger_text_preview || '', task.reason) === key;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check if a similar pain task was enqueued recently.
|
|
45
|
+
*/
|
|
46
|
+
export function hasRecentDuplicateTask(
|
|
47
|
+
queue: EvolutionQueueItem[],
|
|
48
|
+
source: string,
|
|
49
|
+
preview: string,
|
|
50
|
+
now: number,
|
|
51
|
+
reason?: string
|
|
52
|
+
): boolean {
|
|
53
|
+
return !!findRecentDuplicateTask(queue, source, preview, now, reason);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Check if a phrase matches an active promoted rule.
|
|
58
|
+
*/
|
|
59
|
+
export function hasEquivalentPromotedRule(
|
|
60
|
+
dictionary: { getAllRules(): Record<string, { type: string; phrases?: string[]; pattern?: string; status: string; }> },
|
|
61
|
+
phrase: string
|
|
62
|
+
): boolean {
|
|
63
|
+
const normalizedPhrase = phrase.trim().toLowerCase();
|
|
64
|
+
return Object.values(dictionary.getAllRules()).some((rule) => {
|
|
65
|
+
if (rule.status !== 'active') return false;
|
|
66
|
+
if (rule.type === 'exact_match' && Array.isArray(rule.phrases)) {
|
|
67
|
+
return rule.phrases.some((candidate) => candidate.trim().toLowerCase() === normalizedPhrase);
|
|
68
|
+
}
|
|
69
|
+
if (rule.type === 'regex' && typeof rule.pattern === 'string') {
|
|
70
|
+
return rule.pattern.trim().toLowerCase() === normalizedPhrase;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evolution Pain Context Reader
|
|
3
|
+
*
|
|
4
|
+
* Reads and processes pain signal context for task enrichment.
|
|
5
|
+
* Extracted from evolution-worker.ts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { WorkspaceContext } from '../core/workspace-context.js';
|
|
9
|
+
import { readPainFlagContract } from '../core/pain.js';
|
|
10
|
+
import type { EvolutionQueueItem } from './evolution-queue-migration.js';
|
|
11
|
+
import type { RecentPainContext } from './evolution-queue-migration.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Read recent pain context from PAIN_FLAG file.
|
|
15
|
+
* Extracts session_id to link to trajectory DB.
|
|
16
|
+
* Returns structured pain metadata for attaching to sleep_reflection tasks.
|
|
17
|
+
* Returns null if no pain flag exists.
|
|
18
|
+
*/
|
|
19
|
+
export function readRecentPainContext(wctx: WorkspaceContext): RecentPainContext {
|
|
20
|
+
const contract = readPainFlagContract(wctx.workspaceDir);
|
|
21
|
+
if (contract.status !== 'valid') {
|
|
22
|
+
return { mostRecent: null, recentPainCount: 0, recentMaxPainScore: 0 };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const score = parseInt(contract.data.score ?? '0', 10) || 0;
|
|
27
|
+
const source = contract.data.source ?? '';
|
|
28
|
+
const reason = contract.data.reason ?? '';
|
|
29
|
+
const timestamp = contract.data.time ?? '';
|
|
30
|
+
const sessionId = contract.data.session_id ?? '';
|
|
31
|
+
|
|
32
|
+
if (score > 0) {
|
|
33
|
+
return {
|
|
34
|
+
mostRecent: { score, source, reason, timestamp, sessionId },
|
|
35
|
+
recentPainCount: 1,
|
|
36
|
+
recentMaxPainScore: score,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// Best effort — non-fatal
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { mostRecent: null, recentPainCount: 0, recentMaxPainScore: 0 };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build a dedup key from pain context.
|
|
48
|
+
* Returns null when no pain context is available (bypasses dedup).
|
|
49
|
+
*/
|
|
50
|
+
export function buildPainSourceKey(
|
|
51
|
+
painCtx: ReturnType<typeof readRecentPainContext>,
|
|
52
|
+
): string | null {
|
|
53
|
+
if (!painCtx.mostRecent) return null;
|
|
54
|
+
return `${painCtx.mostRecent.source}::${painCtx.mostRecent.reason?.slice(0, 50) ?? ''}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check whether a similar sleep_reflection task completed recently.
|
|
59
|
+
* Phase 3c: Prevents redundant reflections of the same underlying issue.
|
|
60
|
+
*/
|
|
61
|
+
export function hasRecentSimilarReflection(
|
|
62
|
+
queue: EvolutionQueueItem[],
|
|
63
|
+
painSourceKey: string,
|
|
64
|
+
now: number,
|
|
65
|
+
): EvolutionQueueItem | null {
|
|
66
|
+
const DEDUP_WINDOW_MS = 4 * 60 * 60 * 1000; // 4 hours
|
|
67
|
+
return queue.find((t) => {
|
|
68
|
+
if (t.taskKind !== 'sleep_reflection') return false;
|
|
69
|
+
// Only match completed tasks (exclude failed to allow retries)
|
|
70
|
+
if (t.status !== 'completed') return false;
|
|
71
|
+
if (!t.completed_at) return false;
|
|
72
|
+
const age = now - new Date(t.completed_at).getTime();
|
|
73
|
+
if (age > DEDUP_WINDOW_MS) return false;
|
|
74
|
+
const taskPainKey = buildPainSourceKey(t.recentPainContext ?? { mostRecent: null, recentPainCount: 0, recentMaxPainScore: 0 });
|
|
75
|
+
// If either side has no pain context, they don't match
|
|
76
|
+
if (!taskPainKey) return false;
|
|
77
|
+
return taskPainKey === painSourceKey;
|
|
78
|
+
}) ?? null;
|
|
79
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evolution Queue Lock Utilities
|
|
3
|
+
*
|
|
4
|
+
* File locking for safe concurrent queue access.
|
|
5
|
+
* Extracted from evolution-worker.ts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { acquireLockAsync, releaseLock as releaseImportedLock, type LockContext } from '../utils/file-lock.js';
|
|
9
|
+
import { LockUnavailableError } from '../config/index.js';
|
|
10
|
+
|
|
11
|
+
export const EVOLUTION_QUEUE_LOCK_SUFFIX = '.lock';
|
|
12
|
+
export const LOCK_MAX_RETRIES = 50;
|
|
13
|
+
export const LOCK_RETRY_DELAY_MS = 50;
|
|
14
|
+
export const LOCK_STALE_MS = 30_000;
|
|
15
|
+
|
|
16
|
+
export async function acquireQueueLock(
|
|
17
|
+
resourcePath: string,
|
|
18
|
+
logger: { warn?: (message: string) => void; info?: (message: string) => void } | undefined,
|
|
19
|
+
lockSuffix: string = EVOLUTION_QUEUE_LOCK_SUFFIX,
|
|
20
|
+
): Promise<() => void> {
|
|
21
|
+
try {
|
|
22
|
+
const ctx: LockContext = await acquireLockAsync(resourcePath, {
|
|
23
|
+
lockSuffix,
|
|
24
|
+
maxRetries: LOCK_MAX_RETRIES,
|
|
25
|
+
baseRetryDelayMs: LOCK_RETRY_DELAY_MS,
|
|
26
|
+
lockStaleMs: LOCK_STALE_MS,
|
|
27
|
+
});
|
|
28
|
+
return () => releaseImportedLock(ctx);
|
|
29
|
+
} catch (error: unknown) {
|
|
30
|
+
const warn = logger?.warn;
|
|
31
|
+
warn?.(`[PD:EvolutionWorker] Failed to acquire lock for ${resourcePath}: ${String(error)}`);
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function requireQueueLock(
|
|
37
|
+
resourcePath: string,
|
|
38
|
+
logger: { warn?: (message: string) => void; info?: (message: string) => void } | undefined,
|
|
39
|
+
scope: string,
|
|
40
|
+
lockSuffix: string = EVOLUTION_QUEUE_LOCK_SUFFIX,
|
|
41
|
+
): Promise<() => void> {
|
|
42
|
+
try {
|
|
43
|
+
return await acquireQueueLock(resourcePath, logger, lockSuffix);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
throw new LockUnavailableError(resourcePath, scope, { cause: err });
|
|
46
|
+
}
|
|
47
|
+
}
|