principles-disciple 1.46.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.
@@ -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.46.0",
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": "9ae5cc1407da",
80
- "bundleMd5": "68bc85f0121a780b83931b7ed5491b97",
81
- "builtAt": "2026-04-16T02:19:50.219Z"
79
+ "gitSha": "70500e1475ef",
80
+ "bundleMd5": "607cfbcb4534d2cfdc45cb0cd019cd0b",
81
+ "builtAt": "2026-04-16T03:41:17.317Z"
82
82
  }
83
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "principles-disciple",
3
- "version": "1.46.0",
3
+ "version": "1.47.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -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,147 @@ 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
+ 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
+
572
714
  async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogger, eventLog: EventLog, api?: OpenClawPluginApi) {
573
715
  const queuePath = wctx.resolve('EVOLUTION_QUEUE');
574
716
  if (!fs.existsSync(queuePath)) {
@@ -1957,6 +2099,12 @@ export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
1957
2099
  const mergedConfig = loadNocturnalConfigMerged(wctx.stateDir);
1958
2100
  const { sleepReflection: sleepConfig, keywordOptimization: kwOptConfig } = mergedConfig;
1959
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
+
1960
2108
  const idleResult = checkWorkspaceIdle(wctx.workspaceDir, {});
1961
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}`);
1962
2110
 
@@ -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
+ });