principles-disciple 1.45.0 → 1.47.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 +4 -4
- package/package.json +1 -1
- package/src/core/evolution-reducer.ts +90 -2
- package/src/core/pain.ts +7 -3
- package/src/core/principle-tree-ledger.ts +19 -1
- package/src/core/trajectory.ts +16 -8
- package/src/hooks/pain.ts +14 -9
- package/src/service/evolution-worker.ts +165 -4
- package/src/service/queue-io.ts +5 -1
- package/src/types/principle-tree-schema.ts +4 -0
- package/tests/core/evolution-reducer.compilation-retry.test.ts +188 -0
- package/tests/service/evolution-worker.compilation-backfill.test.ts +199 -0
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "principles-disciple",
|
|
3
3
|
"name": "Principles Disciple",
|
|
4
4
|
"description": "Evolutionary programming agent framework with strategic guardrails and reflection loops.",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.47.0",
|
|
6
6
|
"skills": [
|
|
7
7
|
"./skills"
|
|
8
8
|
],
|
|
@@ -76,8 +76,8 @@
|
|
|
76
76
|
}
|
|
77
77
|
},
|
|
78
78
|
"buildFingerprint": {
|
|
79
|
-
"gitSha": "
|
|
80
|
-
"bundleMd5": "
|
|
81
|
-
"builtAt": "2026-04-
|
|
79
|
+
"gitSha": "70500e1475ef",
|
|
80
|
+
"bundleMd5": "607cfbcb4534d2cfdc45cb0cd019cd0b",
|
|
81
|
+
"builtAt": "2026-04-16T03:41:17.317Z"
|
|
82
82
|
}
|
|
83
83
|
}
|
package/package.json
CHANGED
|
@@ -21,9 +21,23 @@ import type {
|
|
|
21
21
|
PrincipleSuggestedRule,
|
|
22
22
|
} from './evolution-types.js';
|
|
23
23
|
import { isCompleteDetectorMetadata } from './evolution-types.js';
|
|
24
|
-
import { updateTrainingStore } from './principle-tree-ledger.js';
|
|
24
|
+
import { updateTrainingStore, addPrincipleToLedger, updatePrinciple, type LedgerPrinciple } from './principle-tree-ledger.js';
|
|
25
|
+
import { PrincipleCompiler } from './principle-compiler/index.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Wrapper for updatePrinciple calls in the compilation retry path.
|
|
29
|
+
* If updatePrinciple throws, logs the error instead of propagating —
|
|
30
|
+
* compilation retry state is best-effort and should not crash principle creation.
|
|
31
|
+
*/
|
|
32
|
+
function updateRetryCount(stateDir: string, workspaceDir: string, principleId: string, count: number): void {
|
|
33
|
+
try {
|
|
34
|
+
updatePrinciple(stateDir, principleId, { compilationRetryCount: count });
|
|
35
|
+
} catch (err) {
|
|
36
|
+
SystemLogger.log(workspaceDir, 'RETRY_COUNT_UPDATE_FAILED',
|
|
37
|
+
`Failed to update compilationRetryCount for ${principleId}: ${String(err)}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
25
40
|
|
|
26
|
-
|
|
27
41
|
export interface EvolutionReducer {
|
|
28
42
|
|
|
29
43
|
emit(_event: EvolutionLoopEvent): void;
|
|
@@ -381,6 +395,80 @@ export class EvolutionReducerImpl implements EvolutionReducer {
|
|
|
381
395
|
SystemLogger.log(this.workspaceDir, 'PRINCIPLE_SYNC_WARN', `Principle ${principleId} created in memory but failed to sync to PRINCIPLES.md — manual file check required`);
|
|
382
396
|
}
|
|
383
397
|
|
|
398
|
+
// Add to ledger tree so the compiler can find this principle.
|
|
399
|
+
// Without this, newly diagnosed principles are invisible to the compiler
|
|
400
|
+
// (which reads tree.principles, not trainingStore or PRINCIPLES.md).
|
|
401
|
+
if (this.stateDir) {
|
|
402
|
+
try {
|
|
403
|
+
// Build a LedgerPrinciple (tree schema) from the evolution-types Principle.
|
|
404
|
+
// Tree schema Principle does NOT have: source, guardrails, contextTags, validation,
|
|
405
|
+
// feedbackScore, usageCount, activatedAt, abstractedPrinciple, valueMetrics.
|
|
406
|
+
// Pain source info is stored via derivedFromPainIds (which the compiler uses).
|
|
407
|
+
const ledgerPrinciple: LedgerPrinciple = {
|
|
408
|
+
id: principle.id,
|
|
409
|
+
version: principle.version,
|
|
410
|
+
text: principle.text,
|
|
411
|
+
triggerPattern: principle.trigger,
|
|
412
|
+
action: principle.action,
|
|
413
|
+
status: principle.status,
|
|
414
|
+
evaluability: principle.evaluability,
|
|
415
|
+
coreAxiomId: principle.coreAxiomId,
|
|
416
|
+
priority: principle.priority ?? 'P1',
|
|
417
|
+
scope: principle.scope ?? 'general',
|
|
418
|
+
domain: principle.domain,
|
|
419
|
+
suggestedRules: principle.suggestedRules?.map((r) => r.name),
|
|
420
|
+
detectorMetadata: principle.detectorMetadata,
|
|
421
|
+
deprecatedAt: principle.deprecatedAt,
|
|
422
|
+
deprecatedReason: undefined,
|
|
423
|
+
createdAt: principle.createdAt,
|
|
424
|
+
updatedAt: now,
|
|
425
|
+
// Ledger-only fields (derived from evolution-types Principle where applicable):
|
|
426
|
+
valueScore: 0,
|
|
427
|
+
adherenceRate: 0,
|
|
428
|
+
painPreventedCount: 0,
|
|
429
|
+
lastPainPreventedAt: undefined,
|
|
430
|
+
derivedFromPainIds: [params.painId],
|
|
431
|
+
ruleIds: [],
|
|
432
|
+
conflictsWithPrincipleIds: [],
|
|
433
|
+
supersedesPrincipleId: undefined,
|
|
434
|
+
};
|
|
435
|
+
addPrincipleToLedger(this.stateDir, ledgerPrinciple);
|
|
436
|
+
SystemLogger.log(this.workspaceDir, 'LEDGER_PRINCIPLE_ADDED', `Principle ${principleId} added to ledger tree`);
|
|
437
|
+
|
|
438
|
+
// Sync compile: attempt to compile immediately unless evaluability is manual_only.
|
|
439
|
+
// Failures are not fatal — heartbeat backfill will retry automatically.
|
|
440
|
+
if (evaluability !== 'manual_only' && this.stateDir) {
|
|
441
|
+
const trajectory = TrajectoryRegistry.get(this.workspaceDir);
|
|
442
|
+
const compiler = new PrincipleCompiler(this.stateDir, trajectory);
|
|
443
|
+
try {
|
|
444
|
+
const result = compiler.compileOne(principleId);
|
|
445
|
+
if (result.success) {
|
|
446
|
+
// Reset retry count on success
|
|
447
|
+
updatePrinciple(this.stateDir, principleId, { compilationRetryCount: undefined });
|
|
448
|
+
SystemLogger.log(this.workspaceDir, 'COMPILE_SUCCESS', `Principle ${principleId} compiled successfully`);
|
|
449
|
+
} else {
|
|
450
|
+
// Compile returned failure — queue for backfill retry (count=0 means "queued", Phase 2 will pick it up).
|
|
451
|
+
// This gives exactly 5 total attempts before exhaustion (backfill: 0-4, sync: 0-4).
|
|
452
|
+
updateRetryCount(this.stateDir, this.workspaceDir, principleId, 0);
|
|
453
|
+
SystemLogger.log(
|
|
454
|
+
this.workspaceDir, 'COMPILE_FAILED',
|
|
455
|
+
`Principle ${principleId} compile failed: ${result.reason ?? 'unknown'} (attempt 1/5)`
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
} catch (compileErr) {
|
|
459
|
+
// Unexpected error during compilation — queue for backfill retry
|
|
460
|
+
updateRetryCount(this.stateDir, this.workspaceDir, principleId, 0);
|
|
461
|
+
SystemLogger.log(
|
|
462
|
+
this.workspaceDir, 'COMPILE_FAILED',
|
|
463
|
+
`Principle ${principleId} compile threw: ${String(compileErr)} (attempt 1/5)`
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
} catch (err) {
|
|
468
|
+
SystemLogger.log(this.workspaceDir, 'LEDGER_PRINCIPLE_ADD_FAILED', `Failed to add ${principleId} to ledger tree: ${String(err)}`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
384
472
|
// #204: Write to training store so listEvaluablePrinciples() can find this principle
|
|
385
473
|
if (this.stateDir) {
|
|
386
474
|
try {
|
package/src/core/pain.ts
CHANGED
|
@@ -32,9 +32,11 @@ export interface PainFlagData {
|
|
|
32
32
|
/** Whether this involves risky operation ('true' / 'false') */
|
|
33
33
|
is_risky: string;
|
|
34
34
|
/** Correlation trace ID (for linking events across the pipeline) */
|
|
35
|
-
trace_id
|
|
36
|
-
/**
|
|
37
|
-
trigger_text_preview
|
|
35
|
+
trace_id: string;
|
|
36
|
+
/** Preview of the text that triggered this pain */
|
|
37
|
+
trigger_text_preview: string;
|
|
38
|
+
/** Trajectory pain_events row ID (set by recordPainEvent) */
|
|
39
|
+
pain_event_id?: string;
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
export interface PainFlagContractResult {
|
|
@@ -65,6 +67,7 @@ export function buildPainFlag(input: {
|
|
|
65
67
|
is_risky?: boolean;
|
|
66
68
|
trace_id?: string;
|
|
67
69
|
trigger_text_preview?: string;
|
|
70
|
+
pain_event_id?: string;
|
|
68
71
|
}): PainFlagData {
|
|
69
72
|
// Omit optional fields when not provided — prevents writing empty lines to disk
|
|
70
73
|
// which causes agent confusion (SKILL.md vs reality drift)
|
|
@@ -78,6 +81,7 @@ export function buildPainFlag(input: {
|
|
|
78
81
|
is_risky: input.is_risky ? 'true' : 'false',
|
|
79
82
|
trace_id: input.trace_id ?? '',
|
|
80
83
|
trigger_text_preview: input.trigger_text_preview ?? '',
|
|
84
|
+
pain_event_id: input.pain_event_id,
|
|
81
85
|
};
|
|
82
86
|
}
|
|
83
87
|
|
|
@@ -368,7 +368,7 @@ export async function saveLedgerAsync(stateDir: string, store: HybridLedgerStore
|
|
|
368
368
|
|
|
369
369
|
export function updateTrainingStore(
|
|
370
370
|
stateDir: string,
|
|
371
|
-
|
|
371
|
+
|
|
372
372
|
mutate: (store: LegacyPrincipleTrainingStore) => void,
|
|
373
373
|
): void {
|
|
374
374
|
mutateLedger(stateDir, (store) => {
|
|
@@ -376,6 +376,24 @@ export function updateTrainingStore(
|
|
|
376
376
|
});
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
+
/**
|
|
380
|
+
* Add a new principle directly to the ledger tree.
|
|
381
|
+
* This is the companion to updatePrinciple() — use this when creating a NEW
|
|
382
|
+
* principle so the compiler can find it in tree.principles.
|
|
383
|
+
*
|
|
384
|
+
* Idempotent: if the principle already exists, overwrites (update semantics).
|
|
385
|
+
*/
|
|
386
|
+
export function addPrincipleToLedger(
|
|
387
|
+
stateDir: string,
|
|
388
|
+
principle: LedgerPrinciple,
|
|
389
|
+
): LedgerPrinciple {
|
|
390
|
+
return mutateLedger(stateDir, (store) => {
|
|
391
|
+
store.tree.principles[principle.id] = principle;
|
|
392
|
+
store.tree.lastUpdated = new Date().toISOString();
|
|
393
|
+
return principle;
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
379
397
|
export function createRule(stateDir: string, rule: LedgerRule): LedgerRule {
|
|
380
398
|
return mutateLedger(stateDir, (store) => {
|
|
381
399
|
const principle = store.tree.principles[rule.principleId];
|
package/src/core/trajectory.ts
CHANGED
|
@@ -237,10 +237,11 @@ export class TrajectoryDatabase {
|
|
|
237
237
|
return rowId;
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
-
recordPainEvent(input: TrajectoryPainEventInput):
|
|
240
|
+
recordPainEvent(input: TrajectoryPainEventInput): number {
|
|
241
241
|
this.recordSession({ sessionId: input.sessionId, startedAt: input.createdAt });
|
|
242
|
+
let insertedId = -1;
|
|
242
243
|
this.withWrite(() => {
|
|
243
|
-
this.db.prepare(`
|
|
244
|
+
const runResult = this.db.prepare(`
|
|
244
245
|
INSERT INTO pain_events (
|
|
245
246
|
session_id, source, score, reason, severity, origin, confidence, text, created_at
|
|
246
247
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
@@ -255,15 +256,22 @@ export class TrajectoryDatabase {
|
|
|
255
256
|
input.text ?? null,
|
|
256
257
|
input.createdAt ?? nowIso(),
|
|
257
258
|
);
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
259
|
+
insertedId = runResult.lastInsertRowid as number;
|
|
260
|
+
});
|
|
261
|
+
// FTS indexing is best-effort — run outside the transaction so it cannot
|
|
262
|
+
// roll back the committed pain event row (MEM-03, MEM-04).
|
|
263
|
+
if (input.text && insertedId > 0) {
|
|
264
|
+
try {
|
|
262
265
|
this.db.prepare(`
|
|
263
266
|
INSERT INTO pain_events_fts (text, pain_event_id) VALUES (?, ?)
|
|
264
|
-
`).run(input.text,
|
|
267
|
+
`).run(input.text, insertedId);
|
|
268
|
+
} catch (err) {
|
|
269
|
+
// Non-fatal: FTS index is for search convenience, not correctness.
|
|
270
|
+
// Log but do not re-throw — the pain event itself is already committed.
|
|
271
|
+
console.warn(`[trajectory] FTS index insert failed for pain_event ${insertedId}: ${String(err)}`);
|
|
265
272
|
}
|
|
266
|
-
}
|
|
273
|
+
}
|
|
274
|
+
return insertedId;
|
|
267
275
|
}
|
|
268
276
|
|
|
269
277
|
/**
|
package/src/hooks/pain.ts
CHANGED
|
@@ -295,6 +295,19 @@ export function handleAfterToolCall(
|
|
|
295
295
|
const painScore = computePainScore(1, false, false, isRisk ? 20 : 0, effectiveWorkspaceDir);
|
|
296
296
|
const traceId = createTraceId();
|
|
297
297
|
|
|
298
|
+
// Record to trajectory FIRST so we get the real auto-increment ID.
|
|
299
|
+
// This ID propagates through the pain flag → evolution task → principle,
|
|
300
|
+
// so the compiler can later resolve derivedFromPainIds correctly.
|
|
301
|
+
const trajectoryPainId = wctx.trajectory?.recordPainEvent({
|
|
302
|
+
sessionId,
|
|
303
|
+
source: 'tool_failure',
|
|
304
|
+
score: painScore,
|
|
305
|
+
reason: `Tool ${event.toolName} failed on ${relPath}`,
|
|
306
|
+
severity: painScore >= 70 ? 'severe' : painScore >= 40 ? 'moderate' : 'mild',
|
|
307
|
+
origin: 'system_infer',
|
|
308
|
+
text: params.text ?? params.content ?? undefined,
|
|
309
|
+
});
|
|
310
|
+
|
|
298
311
|
const painData = buildPainFlag({
|
|
299
312
|
source: 'tool_failure',
|
|
300
313
|
score: String(painScore),
|
|
@@ -303,6 +316,7 @@ export function handleAfterToolCall(
|
|
|
303
316
|
trace_id: traceId,
|
|
304
317
|
session_id: sessionId,
|
|
305
318
|
agent_id: ctx.agentId || '',
|
|
319
|
+
pain_event_id: trajectoryPainId !== undefined && trajectoryPainId >= 0 ? String(trajectoryPainId) : undefined,
|
|
306
320
|
});
|
|
307
321
|
|
|
308
322
|
try {
|
|
@@ -355,15 +369,6 @@ export function handleAfterToolCall(
|
|
|
355
369
|
reason: `Tool ${event.toolName} failed on ${relPath}`,
|
|
356
370
|
isRisky: isRisk,
|
|
357
371
|
});
|
|
358
|
-
wctx.trajectory?.recordPainEvent?.({
|
|
359
|
-
sessionId,
|
|
360
|
-
source: 'tool_failure',
|
|
361
|
-
score: painScore,
|
|
362
|
-
reason: `Tool ${event.toolName} failed on ${relPath}`,
|
|
363
|
-
severity: painScore >= 70 ? 'severe' : painScore >= 40 ? 'moderate' : 'mild',
|
|
364
|
-
origin: 'system_infer',
|
|
365
|
-
text: params.text ?? params.content ?? undefined, // Store original text/content that failed
|
|
366
|
-
});
|
|
367
372
|
|
|
368
373
|
// Log to EvolutionLogger
|
|
369
374
|
const evoLogger = getEvolutionLogger(effectiveWorkspaceDir, wctx.trajectory);
|
|
@@ -15,6 +15,7 @@ import { initPersistence, flushAllSessions } from '../core/session-tracker.js';
|
|
|
15
15
|
import { addDiagnosticianTask, completeDiagnosticianTask } from '../core/diagnostician-task-store.js';
|
|
16
16
|
import { getEvolutionLogger } from '../core/evolution-logger.js';
|
|
17
17
|
import type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
|
|
18
|
+
import type { PrincipleEvaluability } from '../types/principle-tree-schema.js';
|
|
18
19
|
export type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
|
|
19
20
|
import { atomicWriteFileSync } from '../utils/io.js';
|
|
20
21
|
|
|
@@ -37,6 +38,8 @@ import {
|
|
|
37
38
|
type NocturnalSessionSnapshot,
|
|
38
39
|
} from '../core/nocturnal-trajectory-extractor.js';
|
|
39
40
|
import { validateNocturnalSnapshotIngress } from '../core/nocturnal-snapshot-contract.js';
|
|
41
|
+
import { PrincipleCompiler } from '../core/principle-compiler/index.js';
|
|
42
|
+
import { loadLedger, updatePrinciple } from '../core/principle-tree-ledger.js';
|
|
40
43
|
import { isExpectedSubagentError } from './subagent-workflow/subagent-error-utils.js';
|
|
41
44
|
import { readPainFlagContract } from '../core/pain.js';
|
|
42
45
|
import { CorrectionObserverWorkflowManager, correctionObserverWorkflowSpec } from './subagent-workflow/correction-observer-workflow-manager.js';
|
|
@@ -141,6 +144,9 @@ export interface EvolutionQueueItem {
|
|
|
141
144
|
// Attaches explicit recent pain signal without merging task kinds.
|
|
142
145
|
// Used by target selector for ranking bias and context enrichment.
|
|
143
146
|
recentPainContext?: RecentPainContext;
|
|
147
|
+
|
|
148
|
+
/** Trajectory pain_events row ID — set when pain flag includes pain_event_id */
|
|
149
|
+
painEventId?: number;
|
|
144
150
|
}
|
|
145
151
|
|
|
146
152
|
// ── Queue Migration (extracted to queue-migration.ts) ────────────────────────
|
|
@@ -305,6 +311,7 @@ export function hasEquivalentPromotedRule(dictionary: { getAllRules(): Record<st
|
|
|
305
311
|
interface ParsedPainValues {
|
|
306
312
|
score: number; source: string; reason: string; preview: string;
|
|
307
313
|
traceId: string; sessionId: string; agentId: string;
|
|
314
|
+
painEventId?: number;
|
|
308
315
|
}
|
|
309
316
|
|
|
310
317
|
|
|
@@ -352,6 +359,7 @@ async function doEnqueuePainTask(
|
|
|
352
359
|
status: 'pending', session_id: v.sessionId || undefined,
|
|
353
360
|
agent_id: v.agentId || undefined, traceId: effectiveTraceId,
|
|
354
361
|
retryCount: 0, maxRetries: 3,
|
|
362
|
+
painEventId: v.painEventId,
|
|
355
363
|
});
|
|
356
364
|
|
|
357
365
|
saveEvolutionQueue(queuePath, queue);
|
|
@@ -400,6 +408,8 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
|
|
|
400
408
|
const traceId = contract.data.trace_id ?? '';
|
|
401
409
|
const sessionId = contract.data.session_id ?? '';
|
|
402
410
|
const agentId = contract.data.agent_id ?? '';
|
|
411
|
+
const painEventIdRaw = contract.data.pain_event_id;
|
|
412
|
+
const painEventId = painEventIdRaw ? parseInt(painEventIdRaw, 10) : undefined;
|
|
403
413
|
|
|
404
414
|
result.exists = true;
|
|
405
415
|
result.score = score;
|
|
@@ -414,7 +424,7 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
|
|
|
414
424
|
|
|
415
425
|
if (logger) logger.info(`[PD:EvolutionWorker] Detected pain flag (score: ${score}, source: ${source}). Enqueueing evolution task.`);
|
|
416
426
|
return doEnqueuePainTask(wctx, logger, painFlagPath, result, {
|
|
417
|
-
score, source, reason, preview, traceId, sessionId, agentId,
|
|
427
|
+
score, source, reason, preview, traceId, sessionId, agentId, painEventId,
|
|
418
428
|
});
|
|
419
429
|
}
|
|
420
430
|
|
|
@@ -470,6 +480,7 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
|
|
|
470
480
|
preview: jsonPreview, traceId: '',
|
|
471
481
|
sessionId: jsonPain.session_id || '',
|
|
472
482
|
agentId: jsonPain.agent_id || '',
|
|
483
|
+
painEventId: jsonPain.pain_event_id ? parseInt(jsonPain.pain_event_id, 10) : undefined,
|
|
473
484
|
});
|
|
474
485
|
}
|
|
475
486
|
} catch { /* Not JSON — fall through to KV/Markdown parsing */ }
|
|
@@ -492,6 +503,7 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
|
|
|
492
503
|
let traceId = '';
|
|
493
504
|
let sessionId = '';
|
|
494
505
|
let agentId = '';
|
|
506
|
+
let painEventId: number | undefined;
|
|
495
507
|
|
|
496
508
|
for (const line of lines) {
|
|
497
509
|
// KV format: "key: value"
|
|
@@ -503,6 +515,10 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
|
|
|
503
515
|
if (line.startsWith('trace_id:')) traceId = line.split(':', 2)[1].trim();
|
|
504
516
|
if (line.startsWith('session_id:')) sessionId = line.slice('session_id:'.length).trim();
|
|
505
517
|
if (line.startsWith('agent_id:')) agentId = line.slice('agent_id:'.length).trim();
|
|
518
|
+
if (line.startsWith('pain_event_id:')) {
|
|
519
|
+
const raw = line.slice('pain_event_id:'.length).trim();
|
|
520
|
+
painEventId = parseInt(raw, 10) || undefined;
|
|
521
|
+
}
|
|
506
522
|
|
|
507
523
|
// Key=Value fallback format: "key=value" (pain skill manual output)
|
|
508
524
|
// Handles both uppercase (Source=X) and lowercase (source=x) variants
|
|
@@ -544,7 +560,7 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
|
|
|
544
560
|
|
|
545
561
|
return doEnqueuePainTask(wctx, logger, painFlagPath, result, {
|
|
546
562
|
score, source, reason, preview,
|
|
547
|
-
traceId, sessionId, agentId,
|
|
563
|
+
traceId, sessionId, agentId, painEventId,
|
|
548
564
|
});
|
|
549
565
|
|
|
550
566
|
} catch (err) {
|
|
@@ -554,8 +570,147 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
|
|
|
554
570
|
return result;
|
|
555
571
|
}
|
|
556
572
|
|
|
557
|
-
|
|
558
|
-
|
|
573
|
+
/**
|
|
574
|
+
* Process compilation backfill and retry loop.
|
|
575
|
+
* Phase 1 — Backfill: on first call, scan for old principles (compilationRetryCount === undefined)
|
|
576
|
+
* with evaluability !== 'manual_only' and no active implementation, queue them (set to 0).
|
|
577
|
+
* Phase 2 — Retry: compile all principles with compilationRetryCount >= 0.
|
|
578
|
+
* After 5 consecutive failures, downgrades to manual_only and logs COMPILE_EXHAUSTED.
|
|
579
|
+
*/
|
|
580
|
+
export async function processCompilationBackfill(
|
|
581
|
+
wctx: WorkspaceContext,
|
|
582
|
+
logger: PluginLogger,
|
|
583
|
+
): Promise<void> {
|
|
584
|
+
if (!wctx.stateDir) return;
|
|
585
|
+
|
|
586
|
+
let ledger: ReturnType<typeof loadLedger>;
|
|
587
|
+
try {
|
|
588
|
+
ledger = loadLedger(wctx.stateDir);
|
|
589
|
+
} catch (err) {
|
|
590
|
+
logger?.warn?.(`[PD:EvolutionWorker] CompilationBackfill: failed to load ledger: ${String(err)}`);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ── Phase 1: Backfill old principles (runs once per process) ─────────────────
|
|
595
|
+
const backfillMarkerPath = path.join(wctx.stateDir, 'COMPILATION_BACKFILL_DONE');
|
|
596
|
+
const hasBackfillRun = fs.existsSync(backfillMarkerPath);
|
|
597
|
+
if (!hasBackfillRun) {
|
|
598
|
+
let backfillQueued = 0;
|
|
599
|
+
for (const [principleId, principle] of Object.entries(ledger.tree.principles)) {
|
|
600
|
+
if (principle.compilationRetryCount !== undefined) continue; // already processed
|
|
601
|
+
if (principle.evaluability === 'manual_only') continue;
|
|
602
|
+
// Check if already has active implementation
|
|
603
|
+
const hasActiveImpl = Object.values(ledger.tree.implementations).some(
|
|
604
|
+
(impl) => impl.lifecycleState === 'active' && (
|
|
605
|
+
ledger.tree.rules[impl.ruleId]?.principleId === principleId
|
|
606
|
+
)
|
|
607
|
+
);
|
|
608
|
+
if (hasActiveImpl) {
|
|
609
|
+
// Already compiled — mark as done
|
|
610
|
+
updatePrinciple(wctx.stateDir, principleId, { compilationRetryCount: undefined });
|
|
611
|
+
} else {
|
|
612
|
+
// Needs compilation — queue it
|
|
613
|
+
updatePrinciple(wctx.stateDir, principleId, { compilationRetryCount: 0 });
|
|
614
|
+
backfillQueued++;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (backfillQueued > 0) {
|
|
618
|
+
SystemLogger.log(wctx.workspaceDir, 'COMPILE_BACKFILL_QUEUED',
|
|
619
|
+
`Queued ${backfillQueued} old principles for compilation`);
|
|
620
|
+
}
|
|
621
|
+
// Write marker so we don't backfill again in this process
|
|
622
|
+
atomicWriteFileSync(backfillMarkerPath, new Date().toISOString());
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ── Phase 2: Retry pending compilations ───────────────────────────────────
|
|
626
|
+
const trajectory = TrajectoryRegistry.get(wctx.workspaceDir);
|
|
627
|
+
const compiler = new PrincipleCompiler(wctx.stateDir, trajectory);
|
|
628
|
+
|
|
629
|
+
// Re-load ledger after potential backfill updates
|
|
630
|
+
ledger = loadLedger(wctx.stateDir);
|
|
631
|
+
|
|
632
|
+
for (const [principleId, principle] of Object.entries(ledger.tree.principles)) {
|
|
633
|
+
const count = principle.compilationRetryCount;
|
|
634
|
+
|
|
635
|
+
// Skip: not in retry queue (undefined = done/succeeded)
|
|
636
|
+
if (count === undefined) continue;
|
|
637
|
+
|
|
638
|
+
// Skip: already exhausted (count >= 5 means 5 attempts already made)
|
|
639
|
+
if (count >= 5) continue;
|
|
640
|
+
|
|
641
|
+
// Error-isolate each principle so one failure doesn't stop all other retries
|
|
642
|
+
try {
|
|
643
|
+
const result = compiler.compileOne(principleId);
|
|
644
|
+
if (result.success) {
|
|
645
|
+
safeUpdateRetryCount(wctx.stateDir, wctx.workspaceDir, principleId, undefined);
|
|
646
|
+
SystemLogger.log(wctx.workspaceDir, 'COMPILE_SUCCESS',
|
|
647
|
+
`Principle ${principleId} compiled successfully (attempt ${count + 1})`);
|
|
648
|
+
} else {
|
|
649
|
+
const nextCount = count + 1;
|
|
650
|
+
if (nextCount >= 5) {
|
|
651
|
+
// Exhausted: single write to set manual_only (no intermediate count write)
|
|
652
|
+
safeUpdatePrinciple(wctx.stateDir, wctx.workspaceDir, principleId, {
|
|
653
|
+
evaluability: 'manual_only',
|
|
654
|
+
compilationRetryCount: undefined,
|
|
655
|
+
});
|
|
656
|
+
SystemLogger.log(wctx.workspaceDir, 'COMPILE_EXHAUSTED',
|
|
657
|
+
`Principle ${principleId} compilation exhausted after 5 attempts: ${result.reason ?? 'unknown'}`);
|
|
658
|
+
} else {
|
|
659
|
+
safeUpdateRetryCount(wctx.stateDir, wctx.workspaceDir, principleId, nextCount);
|
|
660
|
+
SystemLogger.log(wctx.workspaceDir, 'COMPILE_FAILED',
|
|
661
|
+
`Principle ${principleId} compile failed: ${result.reason ?? 'unknown'} (attempt ${nextCount}/5)`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
} catch (compileErr) {
|
|
665
|
+
const nextCount = count + 1;
|
|
666
|
+
if (nextCount >= 5) {
|
|
667
|
+
// Exhausted: single write to set manual_only (no intermediate count write)
|
|
668
|
+
safeUpdatePrinciple(wctx.stateDir, wctx.workspaceDir, principleId, {
|
|
669
|
+
evaluability: 'manual_only',
|
|
670
|
+
compilationRetryCount: undefined,
|
|
671
|
+
});
|
|
672
|
+
SystemLogger.log(wctx.workspaceDir, 'COMPILE_EXHAUSTED',
|
|
673
|
+
`Principle ${principleId} compilation exhausted after 5 attempts: threw ${String(compileErr)}`);
|
|
674
|
+
} else {
|
|
675
|
+
safeUpdateRetryCount(wctx.stateDir, wctx.workspaceDir, principleId, nextCount);
|
|
676
|
+
SystemLogger.log(wctx.workspaceDir, 'COMPILE_FAILED',
|
|
677
|
+
`Principle ${principleId} compile threw: ${String(compileErr)} (attempt ${nextCount}/5)`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Wrapper for updatePrinciple in the retry loop — logs but does not propagate errors.
|
|
685
|
+
* If update fails, the principle stays in its current retry state and will be
|
|
686
|
+
* picked up again on the next heartbeat.
|
|
687
|
+
*/
|
|
688
|
+
function safeUpdateRetryCount(stateDir: string, workspaceDir: string, principleId: string, count: number | undefined): void {
|
|
689
|
+
try {
|
|
690
|
+
updatePrinciple(stateDir, principleId, { compilationRetryCount: count });
|
|
691
|
+
} catch (err) {
|
|
692
|
+
SystemLogger.log(workspaceDir, 'RETRY_COUNT_UPDATE_FAILED',
|
|
693
|
+
`Failed to update retry count for ${principleId}: ${String(err)}`);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Wrapper for updatePrinciple with multiple fields — logs but does not propagate errors.
|
|
699
|
+
*/
|
|
700
|
+
function safeUpdatePrinciple(
|
|
701
|
+
stateDir: string,
|
|
702
|
+
workspaceDir: string,
|
|
703
|
+
principleId: string,
|
|
704
|
+
updates: { evaluability?: PrincipleEvaluability; compilationRetryCount?: number },
|
|
705
|
+
): void {
|
|
706
|
+
try {
|
|
707
|
+
updatePrinciple(stateDir, principleId, updates);
|
|
708
|
+
} catch (err) {
|
|
709
|
+
SystemLogger.log(workspaceDir, 'RETRY_PRINCIPLE_UPDATE_FAILED',
|
|
710
|
+
`Failed to update principle ${principleId}: ${String(err)}`);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
559
714
|
async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogger, eventLog: EventLog, api?: OpenClawPluginApi) {
|
|
560
715
|
const queuePath = wctx.resolve('EVOLUTION_QUEUE');
|
|
561
716
|
if (!fs.existsSync(queuePath)) {
|
|
@@ -1944,6 +2099,12 @@ export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
|
|
|
1944
2099
|
const mergedConfig = loadNocturnalConfigMerged(wctx.stateDir);
|
|
1945
2100
|
const { sleepReflection: sleepConfig, keywordOptimization: kwOptConfig } = mergedConfig;
|
|
1946
2101
|
|
|
2102
|
+
// Compilation backfill: runs on every heartbeat to retry failed compilations.
|
|
2103
|
+
// Fire-and-forget — errors are logged within the function.
|
|
2104
|
+
processCompilationBackfill(wctx, logger).catch((err) => {
|
|
2105
|
+
logger?.error?.(`[PD:EvolutionWorker] CompilationBackfill threw: ${String(err)}`);
|
|
2106
|
+
});
|
|
2107
|
+
|
|
1947
2108
|
const idleResult = checkWorkspaceIdle(wctx.workspaceDir, {});
|
|
1948
2109
|
logger?.info?.(`[PD:EvolutionWorker] HEARTBEAT cycle=${new Date().toISOString()} idle=${idleResult.isIdle} idleForMs=${idleResult.idleForMs} userActiveSessions=${idleResult.userActiveSessions} abandonedSessions=${idleResult.abandonedSessionIds.length} lastActivityEpoch=${idleResult.mostRecentActivityAt} triggerMode=${sleepConfig.trigger_mode}`);
|
|
1949
2110
|
|
package/src/service/queue-io.ts
CHANGED
|
@@ -68,6 +68,8 @@ export interface RecentPainContext {
|
|
|
68
68
|
reason: string;
|
|
69
69
|
timestamp: string;
|
|
70
70
|
sessionId: string;
|
|
71
|
+
/** Trajectory pain_events row ID — set when pain flag includes pain_event_id */
|
|
72
|
+
painEventId?: number;
|
|
71
73
|
} | null;
|
|
72
74
|
recentPainCount: number;
|
|
73
75
|
recentMaxPainScore: number;
|
|
@@ -150,10 +152,12 @@ export function readRecentPainContext(wctx: WorkspaceContext): RecentPainContext
|
|
|
150
152
|
const reason = contract.data.reason ?? '';
|
|
151
153
|
const timestamp = contract.data.time ?? '';
|
|
152
154
|
const sessionId = contract.data.session_id ?? '';
|
|
155
|
+
const painEventIdRaw = contract.data.pain_event_id;
|
|
156
|
+
const painEventId = painEventIdRaw ? parseInt(painEventIdRaw, 10) : undefined;
|
|
153
157
|
|
|
154
158
|
if (score > 0) {
|
|
155
159
|
return {
|
|
156
|
-
mostRecent: { score, source, reason, timestamp, sessionId },
|
|
160
|
+
mostRecent: { score, source, reason, timestamp, sessionId, painEventId },
|
|
157
161
|
recentPainCount: 1,
|
|
158
162
|
recentMaxPainScore: score,
|
|
159
163
|
};
|
|
@@ -76,6 +76,10 @@ export interface Principle {
|
|
|
76
76
|
|
|
77
77
|
// Detector metadata (for auto-training eligibility)
|
|
78
78
|
detectorMetadata?: PrincipleDetectorSpec;
|
|
79
|
+
|
|
80
|
+
// Compilation retry tracking (for runtime auto-trigger)
|
|
81
|
+
// undefined = not yet attempted or succeeded; 0 = queued; n >= 1 = retry attempt n
|
|
82
|
+
compilationRetryCount?: number;
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
// =========================================================================
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { EvolutionReducerImpl } from '../../src/core/evolution-reducer.js';
|
|
6
|
+
import { loadLedger } from '../../src/core/principle-tree-ledger.js';
|
|
7
|
+
|
|
8
|
+
const tempDirs: string[] = [];
|
|
9
|
+
|
|
10
|
+
function makeTempDir(): string {
|
|
11
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-evolution-compile-'));
|
|
12
|
+
tempDirs.push(dir);
|
|
13
|
+
return dir;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Minimal state dir structure
|
|
17
|
+
function makeStateDir(workspace: string): string {
|
|
18
|
+
const stateDir = path.join(workspace, '.state');
|
|
19
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
20
|
+
fs.writeFileSync(path.join(stateDir, 'EVOLUTION_STREAM'), '', 'utf8');
|
|
21
|
+
fs.writeFileSync(path.join(stateDir, 'PRINCIPLES'), '', 'utf8');
|
|
22
|
+
fs.writeFileSync(path.join(stateDir, 'evolution_queue.json'), '[]', 'utf8');
|
|
23
|
+
fs.writeFileSync(path.join(stateDir, 'ledger.json'), JSON.stringify({
|
|
24
|
+
trainingStore: {},
|
|
25
|
+
tree: { principles: {}, rules: {}, implementations: {}, metrics: {}, lastUpdated: new Date().toISOString() },
|
|
26
|
+
}), 'utf8');
|
|
27
|
+
return stateDir;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
for (const dir of tempDirs.splice(0)) {
|
|
32
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// createPrincipleFromDiagnosis — compilationRetryCount initialization
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
describe('createPrincipleFromDiagnosis — compilationRetryCount initialization', () => {
|
|
41
|
+
it('sets compilationRetryCount=0 when evaluability is weak_heuristic (queued for compilation)', () => {
|
|
42
|
+
const workspace = makeTempDir();
|
|
43
|
+
const stateDir = makeStateDir(workspace);
|
|
44
|
+
const reducer = new EvolutionReducerImpl({ workspaceDir: workspace, stateDir });
|
|
45
|
+
|
|
46
|
+
const id = reducer.createPrincipleFromDiagnosis({
|
|
47
|
+
painId: `pain-weak-heuristic-${Date.now()}`,
|
|
48
|
+
painType: 'tool_failure',
|
|
49
|
+
triggerPattern: 'bash rm fails',
|
|
50
|
+
action: 'verify file exists before rm',
|
|
51
|
+
source: 'test-compilation-retry',
|
|
52
|
+
evaluability: 'weak_heuristic',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(id).not.toBeNull();
|
|
56
|
+
const ledger = loadLedger(stateDir);
|
|
57
|
+
const principle = ledger.tree.principles[id as string];
|
|
58
|
+
expect(principle).toBeDefined();
|
|
59
|
+
// Compilation queued: count >= 0 means queued
|
|
60
|
+
expect(typeof principle?.compilationRetryCount).toBe('number');
|
|
61
|
+
expect(principle?.compilationRetryCount).toBeGreaterThanOrEqual(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('sets compilationRetryCount=0 when evaluability is deterministic', () => {
|
|
65
|
+
const workspace = makeTempDir();
|
|
66
|
+
const stateDir = makeStateDir(workspace);
|
|
67
|
+
const reducer = new EvolutionReducerImpl({ workspaceDir: workspace, stateDir });
|
|
68
|
+
|
|
69
|
+
const id = reducer.createPrincipleFromDiagnosis({
|
|
70
|
+
painId: `pain-deterministic-${Date.now()}`,
|
|
71
|
+
painType: 'tool_failure',
|
|
72
|
+
triggerPattern: 'edit without read',
|
|
73
|
+
action: 'always read before edit',
|
|
74
|
+
source: 'test-compilation-retry',
|
|
75
|
+
evaluability: 'deterministic',
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(id).not.toBeNull();
|
|
79
|
+
const ledger = loadLedger(stateDir);
|
|
80
|
+
const principle = ledger.tree.principles[id as string];
|
|
81
|
+
expect(principle).toBeDefined();
|
|
82
|
+
expect(typeof principle?.compilationRetryCount).toBe('number');
|
|
83
|
+
expect(principle?.compilationRetryCount).toBeGreaterThanOrEqual(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('does NOT set compilationRetryCount when evaluability is manual_only', () => {
|
|
87
|
+
const workspace = makeTempDir();
|
|
88
|
+
const stateDir = makeStateDir(workspace);
|
|
89
|
+
const reducer = new EvolutionReducerImpl({ workspaceDir: workspace, stateDir });
|
|
90
|
+
|
|
91
|
+
const id = reducer.createPrincipleFromDiagnosis({
|
|
92
|
+
painId: `pain-manual-only-${Date.now()}`,
|
|
93
|
+
painType: 'tool_failure',
|
|
94
|
+
triggerPattern: 'generic pain',
|
|
95
|
+
action: 'be more careful',
|
|
96
|
+
source: 'test-compilation-retry',
|
|
97
|
+
evaluability: 'manual_only',
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(id).not.toBeNull();
|
|
101
|
+
const ledger = loadLedger(stateDir);
|
|
102
|
+
const principle = ledger.tree.principles[id as string];
|
|
103
|
+
expect(principle).toBeDefined();
|
|
104
|
+
// manual_only principles should NOT be queued for compilation
|
|
105
|
+
expect(principle?.compilationRetryCount).toBeUndefined();
|
|
106
|
+
expect(principle?.evaluability).toBe('manual_only');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('defaults to weak_heuristic and queues for compilation when no evaluability provided', () => {
|
|
110
|
+
const workspace = makeTempDir();
|
|
111
|
+
const stateDir = makeStateDir(workspace);
|
|
112
|
+
const reducer = new EvolutionReducerImpl({ workspaceDir: workspace, stateDir });
|
|
113
|
+
|
|
114
|
+
const id = reducer.createPrincipleFromDiagnosis({
|
|
115
|
+
painId: `pain-default-${Date.now()}`,
|
|
116
|
+
painType: 'tool_failure',
|
|
117
|
+
triggerPattern: 'some pattern',
|
|
118
|
+
action: 'some action',
|
|
119
|
+
source: 'test-compilation-retry',
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(id).not.toBeNull();
|
|
123
|
+
const ledger = loadLedger(stateDir);
|
|
124
|
+
const principle = ledger.tree.principles[id as string];
|
|
125
|
+
expect(principle).toBeDefined();
|
|
126
|
+
// default evaluability is weak_heuristic, which should queue for compilation
|
|
127
|
+
expect(typeof principle?.compilationRetryCount).toBe('number');
|
|
128
|
+
expect(principle?.compilationRetryCount).toBeGreaterThanOrEqual(0);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// createPrincipleFromDiagnosis — compilationRetryCount increments on compile failure
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
describe('createPrincipleFromDiagnosis — compilationRetryCount increments on failure', () => {
|
|
137
|
+
it('increments to 1 when compilation fails (no trajectory data)', () => {
|
|
138
|
+
const workspace = makeTempDir();
|
|
139
|
+
const stateDir = makeStateDir(workspace);
|
|
140
|
+
const reducer = new EvolutionReducerImpl({ workspaceDir: workspace, stateDir });
|
|
141
|
+
|
|
142
|
+
const id = reducer.createPrincipleFromDiagnosis({
|
|
143
|
+
painId: `pain-fail-${Date.now()}`,
|
|
144
|
+
painType: 'tool_failure',
|
|
145
|
+
triggerPattern: 'unknown tool',
|
|
146
|
+
action: 'do nothing',
|
|
147
|
+
source: 'test-compilation-retry',
|
|
148
|
+
evaluability: 'weak_heuristic',
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(id).not.toBeNull();
|
|
152
|
+
const ledger = loadLedger(stateDir);
|
|
153
|
+
const principle = ledger.tree.principles[id as string];
|
|
154
|
+
expect(principle).toBeDefined();
|
|
155
|
+
// Compilation was attempted and failed (no trajectory data) → count should be 0
|
|
156
|
+
// (sync failure sets count=0 so Phase 2 gets exactly 5 total attempts)
|
|
157
|
+
expect(principle?.compilationRetryCount).toBe(0);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Principle schema — compilationRetryCount field exists and persists
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
describe('Principle schema — compilationRetryCount field persists', () => {
|
|
166
|
+
it('compilationRetryCount is stored and retrieved correctly', () => {
|
|
167
|
+
const workspace = makeTempDir();
|
|
168
|
+
const stateDir = makeStateDir(workspace);
|
|
169
|
+
const reducer = new EvolutionReducerImpl({ workspaceDir: workspace, stateDir });
|
|
170
|
+
|
|
171
|
+
const id = reducer.createPrincipleFromDiagnosis({
|
|
172
|
+
painId: `pain-schema-${Date.now()}`,
|
|
173
|
+
painType: 'tool_failure',
|
|
174
|
+
triggerPattern: 'test pattern',
|
|
175
|
+
action: 'test action',
|
|
176
|
+
source: 'test-compilation-retry',
|
|
177
|
+
evaluability: 'weak_heuristic',
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(id).not.toBeNull();
|
|
181
|
+
// Reload ledger to verify persistence
|
|
182
|
+
const ledger = loadLedger(stateDir);
|
|
183
|
+
const principle = ledger.tree.principles[id as string];
|
|
184
|
+
expect(principle).toBeDefined();
|
|
185
|
+
expect(typeof principle?.compilationRetryCount).toBe('number');
|
|
186
|
+
expect(principle?.compilationRetryCount).toBeGreaterThanOrEqual(0);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { addPrincipleToLedger, loadLedger, type LedgerPrinciple } from '../../src/core/principle-tree-ledger.js';
|
|
6
|
+
import type { WorkspaceContext } from '../../src/core/workspace-context.js';
|
|
7
|
+
import type { PluginLogger } from '../../src/openclaw-sdk.js';
|
|
8
|
+
import { processCompilationBackfill } from '../../src/service/evolution-worker.js';
|
|
9
|
+
|
|
10
|
+
const tempDirs: string[] = [];
|
|
11
|
+
|
|
12
|
+
function makeTempDir(): string {
|
|
13
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-backfill-test-'));
|
|
14
|
+
tempDirs.push(dir);
|
|
15
|
+
return dir;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeStateDir(workspace: string): string {
|
|
19
|
+
const stateDir = path.join(workspace, '.state');
|
|
20
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
21
|
+
fs.writeFileSync(path.join(stateDir, 'EVOLUTION_STREAM'), '', 'utf8');
|
|
22
|
+
fs.writeFileSync(path.join(stateDir, 'PRINCIPLES'), '', 'utf8');
|
|
23
|
+
fs.writeFileSync(path.join(stateDir, 'evolution_queue.json'), '[]', 'utf8');
|
|
24
|
+
fs.writeFileSync(path.join(stateDir, 'ledger.json'), JSON.stringify({
|
|
25
|
+
trainingStore: {},
|
|
26
|
+
tree: { principles: {}, rules: {}, implementations: {}, metrics: {}, lastUpdated: new Date().toISOString() },
|
|
27
|
+
}), 'utf8');
|
|
28
|
+
return stateDir;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeWctx(workspace: string, stateDir: string): WorkspaceContext {
|
|
32
|
+
return {
|
|
33
|
+
workspaceDir: workspace,
|
|
34
|
+
stateDir,
|
|
35
|
+
resolve: (file: string) => path.join(stateDir, file),
|
|
36
|
+
} as unknown as WorkspaceContext;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makePrinciple(id: string, overrides: Partial<LedgerPrinciple> = {}): LedgerPrinciple {
|
|
40
|
+
return {
|
|
41
|
+
id,
|
|
42
|
+
version: 1,
|
|
43
|
+
text: `principle ${id}`,
|
|
44
|
+
triggerPattern: 'test',
|
|
45
|
+
action: 'test action',
|
|
46
|
+
status: 'active',
|
|
47
|
+
priority: 'P1',
|
|
48
|
+
scope: 'general',
|
|
49
|
+
evaluability: 'weak_heuristic',
|
|
50
|
+
compilationRetryCount: undefined,
|
|
51
|
+
ruleIds: [],
|
|
52
|
+
conflictsWithPrincipleIds: [],
|
|
53
|
+
derivedFromPainIds: [],
|
|
54
|
+
valueScore: 0,
|
|
55
|
+
adherenceRate: 0,
|
|
56
|
+
painPreventedCount: 0,
|
|
57
|
+
createdAt: new Date().toISOString(),
|
|
58
|
+
updatedAt: new Date().toISOString(),
|
|
59
|
+
...overrides,
|
|
60
|
+
} as LedgerPrinciple;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const noopLogger: PluginLogger = {
|
|
64
|
+
debug: () => {},
|
|
65
|
+
info: () => {},
|
|
66
|
+
warn: () => {},
|
|
67
|
+
error: () => {},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
vi.restoreAllMocks();
|
|
72
|
+
for (const dir of tempDirs.splice(0)) {
|
|
73
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Phase 1: Backfill
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
describe('processCompilationBackfill — Phase 1 backfill', () => {
|
|
82
|
+
it('sets compilationRetryCount=0 for old principles without retry count', () => {
|
|
83
|
+
const workspace = makeTempDir();
|
|
84
|
+
const stateDir = makeStateDir(workspace);
|
|
85
|
+
|
|
86
|
+
addPrincipleToLedger(stateDir, makePrinciple('P_001', {
|
|
87
|
+
evaluability: 'weak_heuristic',
|
|
88
|
+
compilationRetryCount: undefined,
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
const wctx = makeWctx(workspace, stateDir);
|
|
92
|
+
processCompilationBackfill(wctx, noopLogger);
|
|
93
|
+
|
|
94
|
+
const ledger = loadLedger(stateDir);
|
|
95
|
+
// Phase 1 sets count=0, then Phase 2 runs and increments to 1 (compilation fails without trajectory data)
|
|
96
|
+
// The key assertion: count was set to 0 at some point (proves backfill ran)
|
|
97
|
+
expect(ledger.tree.principles['P_001'].compilationRetryCount).toBeGreaterThanOrEqual(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('skips principles with manual_only evaluability', () => {
|
|
101
|
+
const workspace = makeTempDir();
|
|
102
|
+
const stateDir = makeStateDir(workspace);
|
|
103
|
+
|
|
104
|
+
addPrincipleToLedger(stateDir, makePrinciple('P_002', {
|
|
105
|
+
evaluability: 'manual_only',
|
|
106
|
+
compilationRetryCount: undefined,
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
const wctx = makeWctx(workspace, stateDir);
|
|
110
|
+
processCompilationBackfill(wctx, noopLogger);
|
|
111
|
+
|
|
112
|
+
const ledger = loadLedger(stateDir);
|
|
113
|
+
expect(ledger.tree.principles['P_002'].compilationRetryCount).toBeUndefined();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('writes COMPILATION_BACKFILL_DONE marker after backfill', () => {
|
|
117
|
+
const workspace = makeTempDir();
|
|
118
|
+
const stateDir = makeStateDir(workspace);
|
|
119
|
+
|
|
120
|
+
addPrincipleToLedger(stateDir, makePrinciple('P_003', {
|
|
121
|
+
evaluability: 'weak_heuristic',
|
|
122
|
+
compilationRetryCount: undefined,
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
const wctx = makeWctx(workspace, stateDir);
|
|
126
|
+
processCompilationBackfill(wctx, noopLogger);
|
|
127
|
+
|
|
128
|
+
const markerPath = path.join(stateDir, 'COMPILATION_BACKFILL_DONE');
|
|
129
|
+
expect(fs.existsSync(markerPath)).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('does not re-backfill if marker already exists', () => {
|
|
133
|
+
const workspace = makeTempDir();
|
|
134
|
+
const stateDir = makeStateDir(workspace);
|
|
135
|
+
|
|
136
|
+
// Pre-write the marker so Phase 1 (backfill) is skipped
|
|
137
|
+
const markerPath = path.join(stateDir, 'COMPILATION_BACKFILL_DONE');
|
|
138
|
+
fs.writeFileSync(markerPath, new Date().toISOString(), 'utf8');
|
|
139
|
+
|
|
140
|
+
// Add principle with compilationRetryCount already set to a high value
|
|
141
|
+
// (simulating already-processed-by-Phase2)
|
|
142
|
+
addPrincipleToLedger(stateDir, makePrinciple('P_004', {
|
|
143
|
+
evaluability: 'weak_heuristic',
|
|
144
|
+
compilationRetryCount: 2,
|
|
145
|
+
}));
|
|
146
|
+
|
|
147
|
+
const wctx = makeWctx(workspace, stateDir);
|
|
148
|
+
processCompilationBackfill(wctx, noopLogger);
|
|
149
|
+
|
|
150
|
+
const ledger = loadLedger(stateDir);
|
|
151
|
+
// Phase 1 was skipped (marker exists), but Phase 2 still ran and incremented count
|
|
152
|
+
// So count goes from 2 -> 3 (compilation fails without trajectory data)
|
|
153
|
+
expect(ledger.tree.principles['P_004'].compilationRetryCount).toBe(3);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Phase 2: Retry loop
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
describe('processCompilationBackfill — Phase 2 retry loop', () => {
|
|
162
|
+
it('increments count on compile failure (below exhaustion)', () => {
|
|
163
|
+
const workspace = makeTempDir();
|
|
164
|
+
const stateDir = makeStateDir(workspace);
|
|
165
|
+
|
|
166
|
+
// Principle queued with count=1 — next failure should make it 2
|
|
167
|
+
addPrincipleToLedger(stateDir, makePrinciple('P_012', {
|
|
168
|
+
evaluability: 'weak_heuristic',
|
|
169
|
+
compilationRetryCount: 1,
|
|
170
|
+
}));
|
|
171
|
+
|
|
172
|
+
const wctx = makeWctx(workspace, stateDir);
|
|
173
|
+
processCompilationBackfill(wctx, noopLogger);
|
|
174
|
+
|
|
175
|
+
const ledger = loadLedger(stateDir);
|
|
176
|
+
// Compilation fails (no trajectory data), so count increments
|
|
177
|
+
expect(ledger.tree.principles['P_012'].compilationRetryCount).toBe(2);
|
|
178
|
+
expect(ledger.tree.principles['P_012'].evaluability).toBe('weak_heuristic');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('downgrades to manual_only after 5 consecutive failures', () => {
|
|
182
|
+
const workspace = makeTempDir();
|
|
183
|
+
const stateDir = makeStateDir(workspace);
|
|
184
|
+
|
|
185
|
+
// Principle at count=4 — next failure exhausts it
|
|
186
|
+
addPrincipleToLedger(stateDir, makePrinciple('P_011', {
|
|
187
|
+
evaluability: 'weak_heuristic',
|
|
188
|
+
compilationRetryCount: 4,
|
|
189
|
+
}));
|
|
190
|
+
|
|
191
|
+
const wctx = makeWctx(workspace, stateDir);
|
|
192
|
+
processCompilationBackfill(wctx, noopLogger);
|
|
193
|
+
|
|
194
|
+
const ledger = loadLedger(stateDir);
|
|
195
|
+
// Compilation fails (no trajectory), count becomes 5 >= 5, downgrades to manual_only
|
|
196
|
+
expect(ledger.tree.principles['P_011'].evaluability).toBe('manual_only');
|
|
197
|
+
expect(ledger.tree.principles['P_011'].compilationRetryCount).toBeUndefined();
|
|
198
|
+
});
|
|
199
|
+
});
|