principles-disciple 1.46.0 → 1.48.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 +46 -2
- package/src/service/evolution-worker.ts +171 -4
- package/src/types/principle-tree-schema.ts +4 -0
- package/tests/core/evolution-reducer.compilation-retry.test.ts +188 -0
- package/tests/integration/pain-id-chain-e2e.test.ts +253 -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.48.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, addPrincipleToLedger, type LedgerPrinciple } 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;
|
|
@@ -420,6 +434,36 @@ export class EvolutionReducerImpl implements EvolutionReducer {
|
|
|
420
434
|
};
|
|
421
435
|
addPrincipleToLedger(this.stateDir, ledgerPrinciple);
|
|
422
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
|
+
}
|
|
423
467
|
} catch (err) {
|
|
424
468
|
SystemLogger.log(this.workspaceDir, 'LEDGER_PRINCIPLE_ADD_FAILED', `Failed to add ${principleId} to ledger tree: ${String(err)}`);
|
|
425
469
|
}
|
|
@@ -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';
|
|
@@ -567,8 +570,166 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
|
|
|
567
570
|
return result;
|
|
568
571
|
}
|
|
569
572
|
|
|
570
|
-
|
|
571
|
-
|
|
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
|
+
try {
|
|
611
|
+
updatePrinciple(wctx.stateDir, principleId, { compilationRetryCount: undefined });
|
|
612
|
+
} catch (err) {
|
|
613
|
+
SystemLogger.log(wctx.workspaceDir, 'BACKFILL_UPDATE_FAILED',
|
|
614
|
+
`Failed to mark principle ${principleId} as done: ${String(err)}`);
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
} else {
|
|
618
|
+
// Needs compilation — queue it
|
|
619
|
+
try {
|
|
620
|
+
updatePrinciple(wctx.stateDir, principleId, { compilationRetryCount: 0 });
|
|
621
|
+
backfillQueued++;
|
|
622
|
+
} catch (err) {
|
|
623
|
+
SystemLogger.log(wctx.workspaceDir, 'BACKFILL_UPDATE_FAILED',
|
|
624
|
+
`Failed to queue principle ${principleId}: ${String(err)}`);
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
if (backfillQueued > 0) {
|
|
630
|
+
SystemLogger.log(wctx.workspaceDir, 'COMPILE_BACKFILL_QUEUED',
|
|
631
|
+
`Queued ${backfillQueued} old principles for compilation`);
|
|
632
|
+
}
|
|
633
|
+
// Write marker so we don't backfill again in this process
|
|
634
|
+
try {
|
|
635
|
+
atomicWriteFileSync(backfillMarkerPath, new Date().toISOString());
|
|
636
|
+
} catch (err) {
|
|
637
|
+
SystemLogger.log(wctx.workspaceDir, 'BACKFILL_MARKER_WRITE_FAILED',
|
|
638
|
+
`Failed to write backfill marker: ${String(err)}`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ── Phase 2: Retry pending compilations ───────────────────────────────────
|
|
643
|
+
const trajectory = TrajectoryRegistry.get(wctx.workspaceDir);
|
|
644
|
+
const compiler = new PrincipleCompiler(wctx.stateDir, trajectory);
|
|
645
|
+
|
|
646
|
+
// Re-load ledger after potential backfill updates
|
|
647
|
+
ledger = loadLedger(wctx.stateDir);
|
|
648
|
+
|
|
649
|
+
for (const [principleId, principle] of Object.entries(ledger.tree.principles)) {
|
|
650
|
+
const count = principle.compilationRetryCount;
|
|
651
|
+
|
|
652
|
+
// Skip: not in retry queue (undefined = done/succeeded)
|
|
653
|
+
if (count === undefined) continue;
|
|
654
|
+
|
|
655
|
+
// Skip: already exhausted (count >= 5 means 5 attempts already made)
|
|
656
|
+
if (count >= 5) continue;
|
|
657
|
+
|
|
658
|
+
// Error-isolate each principle so one failure doesn't stop all other retries
|
|
659
|
+
try {
|
|
660
|
+
const result = compiler.compileOne(principleId);
|
|
661
|
+
if (result.success) {
|
|
662
|
+
tryUpdateRetryCount(wctx.stateDir, wctx.workspaceDir, principleId, undefined);
|
|
663
|
+
SystemLogger.log(wctx.workspaceDir, 'COMPILE_SUCCESS',
|
|
664
|
+
`Principle ${principleId} compiled successfully (attempt ${count + 1})`);
|
|
665
|
+
} else {
|
|
666
|
+
const nextCount = count + 1;
|
|
667
|
+
if (nextCount >= 5) {
|
|
668
|
+
// Exhausted: single write to set manual_only (no intermediate count write)
|
|
669
|
+
tryUpdatePrinciple(wctx.stateDir, wctx.workspaceDir, principleId, {
|
|
670
|
+
evaluability: 'manual_only',
|
|
671
|
+
compilationRetryCount: undefined,
|
|
672
|
+
});
|
|
673
|
+
SystemLogger.log(wctx.workspaceDir, 'COMPILE_EXHAUSTED',
|
|
674
|
+
`Principle ${principleId} compilation exhausted after 5 attempts: ${result.reason ?? 'unknown'}`);
|
|
675
|
+
} else {
|
|
676
|
+
tryUpdateRetryCount(wctx.stateDir, wctx.workspaceDir, principleId, nextCount);
|
|
677
|
+
SystemLogger.log(wctx.workspaceDir, 'COMPILE_FAILED',
|
|
678
|
+
`Principle ${principleId} compile failed: ${result.reason ?? 'unknown'} (attempt ${nextCount}/5)`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
} catch (compileErr) {
|
|
682
|
+
const nextCount = count + 1;
|
|
683
|
+
if (nextCount >= 5) {
|
|
684
|
+
// Exhausted: single write to set manual_only (no intermediate count write)
|
|
685
|
+
tryUpdatePrinciple(wctx.stateDir, wctx.workspaceDir, principleId, {
|
|
686
|
+
evaluability: 'manual_only',
|
|
687
|
+
compilationRetryCount: undefined,
|
|
688
|
+
});
|
|
689
|
+
SystemLogger.log(wctx.workspaceDir, 'COMPILE_EXHAUSTED',
|
|
690
|
+
`Principle ${principleId} compilation exhausted after 5 attempts: threw ${String(compileErr)}`);
|
|
691
|
+
} else {
|
|
692
|
+
tryUpdateRetryCount(wctx.stateDir, wctx.workspaceDir, principleId, nextCount);
|
|
693
|
+
SystemLogger.log(wctx.workspaceDir, 'COMPILE_FAILED',
|
|
694
|
+
`Principle ${principleId} compile threw: ${String(compileErr)} (attempt ${nextCount}/5)`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Wrapper for updateRetryCount — logs but does not propagate errors.
|
|
702
|
+
* Errors are silently swallowed: if update fails, the principle stays in its
|
|
703
|
+
* current retry state and will be picked up again on the next heartbeat.
|
|
704
|
+
*/
|
|
705
|
+
function tryUpdateRetryCount(stateDir: string, workspaceDir: string, principleId: string, count: number | undefined): void {
|
|
706
|
+
try {
|
|
707
|
+
updatePrinciple(stateDir, principleId, { compilationRetryCount: count });
|
|
708
|
+
} catch (err) {
|
|
709
|
+
SystemLogger.log(workspaceDir, 'RETRY_COUNT_UPDATE_FAILED',
|
|
710
|
+
`Failed to update retry count for ${principleId}: ${String(err)}`);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Wrapper for updatePrinciple with multiple fields — logs but does not propagate errors.
|
|
716
|
+
* Errors are silently swallowed: if update fails, the principle stays in its
|
|
717
|
+
* current state and will be picked up again on the next heartbeat.
|
|
718
|
+
*/
|
|
719
|
+
function tryUpdatePrinciple(
|
|
720
|
+
stateDir: string,
|
|
721
|
+
workspaceDir: string,
|
|
722
|
+
principleId: string,
|
|
723
|
+
updates: { evaluability?: PrincipleEvaluability; compilationRetryCount?: number },
|
|
724
|
+
): void {
|
|
725
|
+
try {
|
|
726
|
+
updatePrinciple(stateDir, principleId, updates);
|
|
727
|
+
} catch (err) {
|
|
728
|
+
SystemLogger.log(workspaceDir, 'RETRY_PRINCIPLE_UPDATE_FAILED',
|
|
729
|
+
`Failed to update principle ${principleId}: ${String(err)}`);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
572
733
|
async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogger, eventLog: EventLog, api?: OpenClawPluginApi) {
|
|
573
734
|
const queuePath = wctx.resolve('EVOLUTION_QUEUE');
|
|
574
735
|
if (!fs.existsSync(queuePath)) {
|
|
@@ -773,7 +934,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
773
934
|
} else {
|
|
774
935
|
logger.info(`[PD:EvolutionWorker] Creating principle from report for task ${task.id}`);
|
|
775
936
|
const principleId = wctx.evolutionReducer.createPrincipleFromDiagnosis({
|
|
776
|
-
painId: task.id,
|
|
937
|
+
painId: task.painEventId !== undefined ? String(task.painEventId) : task.id,
|
|
777
938
|
painType: task.source === 'Human Intervention' ? 'user_frustration' : 'tool_failure',
|
|
778
939
|
triggerPattern: principle.trigger_pattern,
|
|
779
940
|
action: principle.action,
|
|
@@ -871,7 +1032,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
871
1032
|
logger.info(`[PD:EvolutionWorker] Diagnostician marked principle as duplicate: ${principle.duplicate_of || 'unknown'} — skipping for task ${task.id}`);
|
|
872
1033
|
} else {
|
|
873
1034
|
const principleId = wctx.evolutionReducer.createPrincipleFromDiagnosis({
|
|
874
|
-
painId: task.id,
|
|
1035
|
+
painId: task.painEventId !== undefined ? String(task.painEventId) : task.id,
|
|
875
1036
|
painType: task.source === 'Human Intervention' ? 'user_frustration' : 'tool_failure',
|
|
876
1037
|
triggerPattern: principle.trigger_pattern,
|
|
877
1038
|
action: principle.action,
|
|
@@ -1957,6 +2118,12 @@ export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
|
|
|
1957
2118
|
const mergedConfig = loadNocturnalConfigMerged(wctx.stateDir);
|
|
1958
2119
|
const { sleepReflection: sleepConfig, keywordOptimization: kwOptConfig } = mergedConfig;
|
|
1959
2120
|
|
|
2121
|
+
// Compilation backfill: runs on every heartbeat to retry failed compilations.
|
|
2122
|
+
// Fire-and-forget — errors are logged within the function.
|
|
2123
|
+
processCompilationBackfill(wctx, logger).catch((err) => {
|
|
2124
|
+
logger?.error?.(`[PD:EvolutionWorker] CompilationBackfill threw: ${String(err)}`);
|
|
2125
|
+
});
|
|
2126
|
+
|
|
1960
2127
|
const idleResult = checkWorkspaceIdle(wctx.workspaceDir, {});
|
|
1961
2128
|
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}`);
|
|
1962
2129
|
|
|
@@ -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,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E Test: Pain ID Chain — pain event → createPrincipleFromDiagnosis → compile → RuleHost
|
|
3
|
+
*
|
|
4
|
+
* Tests the complete chain:
|
|
5
|
+
* 1. recordPainEvent() returns AUTOINCREMENT row ID as number
|
|
6
|
+
* 2. createPrincipleFromDiagnosis(painId: String(painEventId))
|
|
7
|
+
* 3. derivedFromPainIds stores the stringified numeric ID
|
|
8
|
+
* 4. PrincipleCompiler.compileOne() succeeds (registers active implementation)
|
|
9
|
+
* 5. RuleHost.evaluate(matching input) → block
|
|
10
|
+
* 6. RuleHost.evaluate(non-matching input) → undefined (passthrough)
|
|
11
|
+
*
|
|
12
|
+
* Pain ID chain fixed in commits 4b0dce59 and 0146bbb7:
|
|
13
|
+
* - recordPainEvent() now returns real AUTOINCREMENT ID (was -1)
|
|
14
|
+
* - derivedFromPainIds now stores String(painId) correctly
|
|
15
|
+
* - LedgerPrinciple.derivedFromPainIds used by compiler reflection
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
19
|
+
import * as fs from 'fs';
|
|
20
|
+
import * as os from 'os';
|
|
21
|
+
import * as path from 'path';
|
|
22
|
+
import { TrajectoryDatabase } from '../../src/core/trajectory.js';
|
|
23
|
+
import { PrincipleCompiler } from '../../src/core/principle-compiler/compiler.js';
|
|
24
|
+
import { RuleHost } from '../../src/core/rule-host.js';
|
|
25
|
+
import { EvolutionReducerImpl } from '../../src/core/evolution-reducer.js';
|
|
26
|
+
import {
|
|
27
|
+
loadLedger,
|
|
28
|
+
} from '../../src/core/principle-tree-ledger.js';
|
|
29
|
+
import type { RuleHostInput } from '../../src/core/rule-host-types.js';
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Helpers
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
interface TestWorkspace {
|
|
36
|
+
workspaceDir: string;
|
|
37
|
+
stateDir: string;
|
|
38
|
+
trajectory: TrajectoryDatabase;
|
|
39
|
+
reducer: EvolutionReducerImpl;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function createTestWorkspace(): TestWorkspace {
|
|
43
|
+
const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-pain-chain-e2e-'));
|
|
44
|
+
const stateDir = path.join(workspaceDir, '.state');
|
|
45
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
46
|
+
|
|
47
|
+
const trajectory = new TrajectoryDatabase({ workspaceDir });
|
|
48
|
+
const reducer = new EvolutionReducerImpl({ workspaceDir, stateDir });
|
|
49
|
+
|
|
50
|
+
return { workspaceDir, stateDir, trajectory, reducer };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function disposeTestWorkspace(ws: TestWorkspace): void {
|
|
54
|
+
ws.trajectory.dispose();
|
|
55
|
+
fs.rmSync(ws.workspaceDir, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Tests
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
describe('Pain ID Chain E2E: pain event → principle → compile → RuleHost', () => {
|
|
63
|
+
let ws: TestWorkspace;
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
ws = createTestWorkspace();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
disposeTestWorkspace(ws);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('full chain: pain event ID → createPrincipleFromDiagnosis → derivedFromPainIds → compile → block', () => {
|
|
74
|
+
const sessionId = 'session-pain-chain-001';
|
|
75
|
+
|
|
76
|
+
// ── Step 1: Record tool call + pain event, capture the returned AUTOINCREMENT ID ──
|
|
77
|
+
ws.trajectory.recordToolCall({
|
|
78
|
+
sessionId,
|
|
79
|
+
toolName: 'bash',
|
|
80
|
+
outcome: 'failure',
|
|
81
|
+
errorType: 'command_not_found',
|
|
82
|
+
errorMessage: 'heartbeat: command not found',
|
|
83
|
+
paramsJson: { command: 'heartbeat --status' },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// recordPainEvent() returns the real AUTOINCREMENT row ID as a number (fix from 4b0dce59)
|
|
87
|
+
const painEventId = ws.trajectory.recordPainEvent({
|
|
88
|
+
sessionId,
|
|
89
|
+
source: 'gate_block',
|
|
90
|
+
score: 80,
|
|
91
|
+
reason: 'Blocked bash heartbeat command due to unsafe operation',
|
|
92
|
+
severity: 'moderate',
|
|
93
|
+
origin: 'system_infer',
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Verify painEventId is a positive integer (real AUTOINCREMENT, not -1)
|
|
97
|
+
expect(typeof painEventId).toBe('number');
|
|
98
|
+
expect(painEventId).toBeGreaterThan(0);
|
|
99
|
+
|
|
100
|
+
// ── Step 2: Create principle via createPrincipleFromDiagnosis with stringified pain ID ──
|
|
101
|
+
const triggerPattern = 'heartbeat.*bash';
|
|
102
|
+
const action = 'Block heartbeat commands in bash';
|
|
103
|
+
const painIdStr = String(painEventId);
|
|
104
|
+
|
|
105
|
+
const principleId = ws.reducer.createPrincipleFromDiagnosis({
|
|
106
|
+
painId: painIdStr,
|
|
107
|
+
painType: 'tool_failure',
|
|
108
|
+
triggerPattern,
|
|
109
|
+
action,
|
|
110
|
+
source: 'pain-id-chain-e2e',
|
|
111
|
+
evaluability: 'deterministic',
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(principleId).not.toBeNull();
|
|
115
|
+
expect(typeof principleId).toBe('string');
|
|
116
|
+
|
|
117
|
+
// ── Step 3: Verify derivedFromPainIds in the ledger contains the stringified pain ID ──
|
|
118
|
+
const ledger = loadLedger(ws.stateDir);
|
|
119
|
+
const ledgerPrinciple = ledger.tree.principles[principleId!];
|
|
120
|
+
expect(ledgerPrinciple).toBeDefined();
|
|
121
|
+
expect(ledgerPrinciple!.derivedFromPainIds).toContain(painIdStr);
|
|
122
|
+
|
|
123
|
+
// ── Step 4: Compile the principle (registers active implementation) ──
|
|
124
|
+
const compiler = new PrincipleCompiler(ws.stateDir, ws.trajectory);
|
|
125
|
+
const compileResult = compiler.compileOne(principleId!);
|
|
126
|
+
|
|
127
|
+
expect(compileResult.success).toBe(true);
|
|
128
|
+
expect(compileResult.principleId).toBe(principleId);
|
|
129
|
+
expect(compileResult.code).toBeDefined();
|
|
130
|
+
expect(compileResult.code).toContain('heartbeat');
|
|
131
|
+
expect(compileResult.ruleId).toBeDefined();
|
|
132
|
+
expect(compileResult.implementationId).toBeDefined();
|
|
133
|
+
|
|
134
|
+
// Verify implementation is active
|
|
135
|
+
const updatedLedger = loadLedger(ws.stateDir);
|
|
136
|
+
const impl = updatedLedger.tree.implementations[compileResult.implementationId!];
|
|
137
|
+
expect(impl.lifecycleState).toBe('active');
|
|
138
|
+
|
|
139
|
+
// ── Step 5: RuleHost.evaluate(matching input) → block ──
|
|
140
|
+
const host = new RuleHost(ws.stateDir, { warn: () => {} });
|
|
141
|
+
|
|
142
|
+
const matchingInput: RuleHostInput = {
|
|
143
|
+
action: {
|
|
144
|
+
toolName: 'bash',
|
|
145
|
+
normalizedPath: null,
|
|
146
|
+
paramsSummary: { command: 'heartbeat --status' },
|
|
147
|
+
},
|
|
148
|
+
workspace: {
|
|
149
|
+
isRiskPath: false,
|
|
150
|
+
planStatus: 'NONE',
|
|
151
|
+
hasPlanFile: false,
|
|
152
|
+
},
|
|
153
|
+
session: {
|
|
154
|
+
sessionId: 'session-eval-001',
|
|
155
|
+
currentGfi: 50,
|
|
156
|
+
recentThinking: false,
|
|
157
|
+
},
|
|
158
|
+
evolution: {
|
|
159
|
+
epTier: 0,
|
|
160
|
+
},
|
|
161
|
+
derived: {
|
|
162
|
+
estimatedLineChanges: 0,
|
|
163
|
+
bashRisk: 'unknown',
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const blockResult = host.evaluate(matchingInput);
|
|
168
|
+
|
|
169
|
+
expect(blockResult).toBeDefined();
|
|
170
|
+
expect(blockResult!.decision).toBe('block');
|
|
171
|
+
expect(blockResult!.matched).toBe(true);
|
|
172
|
+
expect(blockResult!.reason).toContain(principleId);
|
|
173
|
+
|
|
174
|
+
// ── Step 6: RuleHost.evaluate(non-matching input) → undefined (passthrough) ──
|
|
175
|
+
const nonMatchingInput: RuleHostInput = {
|
|
176
|
+
action: {
|
|
177
|
+
toolName: 'Read',
|
|
178
|
+
normalizedPath: '/some/file.txt',
|
|
179
|
+
paramsSummary: {},
|
|
180
|
+
},
|
|
181
|
+
workspace: {
|
|
182
|
+
isRiskPath: false,
|
|
183
|
+
planStatus: 'NONE',
|
|
184
|
+
hasPlanFile: false,
|
|
185
|
+
},
|
|
186
|
+
session: {
|
|
187
|
+
sessionId: 'session-eval-002',
|
|
188
|
+
currentGfi: 50,
|
|
189
|
+
recentThinking: false,
|
|
190
|
+
},
|
|
191
|
+
evolution: {
|
|
192
|
+
epTier: 0,
|
|
193
|
+
},
|
|
194
|
+
derived: {
|
|
195
|
+
estimatedLineChanges: 0,
|
|
196
|
+
bashRisk: 'unknown',
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const passResult = host.evaluate(nonMatchingInput);
|
|
201
|
+
expect(passResult).toBeUndefined();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('compileOne returns failure for non-existent principle ID', () => {
|
|
205
|
+
const compiler = new PrincipleCompiler(ws.stateDir, ws.trajectory);
|
|
206
|
+
const badResult = compiler.compileOne('non-existent-principle-id');
|
|
207
|
+
expect(badResult.success).toBe(false);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('recordPainEvent returns sequential IDs for multiple events', () => {
|
|
211
|
+
const sessionId = 'session-seq-001';
|
|
212
|
+
|
|
213
|
+
ws.trajectory.recordToolCall({
|
|
214
|
+
sessionId,
|
|
215
|
+
toolName: 'bash',
|
|
216
|
+
outcome: 'failure',
|
|
217
|
+
errorType: 'command_not_found',
|
|
218
|
+
errorMessage: 'test error 1',
|
|
219
|
+
paramsJson: { command: 'test1' },
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const id1 = ws.trajectory.recordPainEvent({
|
|
223
|
+
sessionId,
|
|
224
|
+
source: 'gate_block',
|
|
225
|
+
score: 50,
|
|
226
|
+
reason: 'First pain event',
|
|
227
|
+
severity: 'low',
|
|
228
|
+
origin: 'system_infer',
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
ws.trajectory.recordToolCall({
|
|
232
|
+
sessionId,
|
|
233
|
+
toolName: 'bash',
|
|
234
|
+
outcome: 'failure',
|
|
235
|
+
errorType: 'command_not_found',
|
|
236
|
+
errorMessage: 'test error 2',
|
|
237
|
+
paramsJson: { command: 'test2' },
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const id2 = ws.trajectory.recordPainEvent({
|
|
241
|
+
sessionId,
|
|
242
|
+
source: 'gate_block',
|
|
243
|
+
score: 60,
|
|
244
|
+
reason: 'Second pain event',
|
|
245
|
+
severity: 'moderate',
|
|
246
|
+
origin: 'system_infer',
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(id2).toBeGreaterThan(id1);
|
|
250
|
+
expect(typeof id1).toBe('number');
|
|
251
|
+
expect(typeof id2).toBe('number');
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -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
|
+
});
|