principles-disciple 1.47.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
CHANGED
package/package.json
CHANGED
|
@@ -607,11 +607,23 @@ export async function processCompilationBackfill(
|
|
|
607
607
|
);
|
|
608
608
|
if (hasActiveImpl) {
|
|
609
609
|
// Already compiled — mark as done
|
|
610
|
-
|
|
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
|
+
}
|
|
611
617
|
} else {
|
|
612
618
|
// Needs compilation — queue it
|
|
613
|
-
|
|
614
|
-
|
|
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
|
+
}
|
|
615
627
|
}
|
|
616
628
|
}
|
|
617
629
|
if (backfillQueued > 0) {
|
|
@@ -619,7 +631,12 @@ export async function processCompilationBackfill(
|
|
|
619
631
|
`Queued ${backfillQueued} old principles for compilation`);
|
|
620
632
|
}
|
|
621
633
|
// Write marker so we don't backfill again in this process
|
|
622
|
-
|
|
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
|
+
}
|
|
623
640
|
}
|
|
624
641
|
|
|
625
642
|
// ── Phase 2: Retry pending compilations ───────────────────────────────────
|
|
@@ -642,21 +659,21 @@ export async function processCompilationBackfill(
|
|
|
642
659
|
try {
|
|
643
660
|
const result = compiler.compileOne(principleId);
|
|
644
661
|
if (result.success) {
|
|
645
|
-
|
|
662
|
+
tryUpdateRetryCount(wctx.stateDir, wctx.workspaceDir, principleId, undefined);
|
|
646
663
|
SystemLogger.log(wctx.workspaceDir, 'COMPILE_SUCCESS',
|
|
647
664
|
`Principle ${principleId} compiled successfully (attempt ${count + 1})`);
|
|
648
665
|
} else {
|
|
649
666
|
const nextCount = count + 1;
|
|
650
667
|
if (nextCount >= 5) {
|
|
651
668
|
// Exhausted: single write to set manual_only (no intermediate count write)
|
|
652
|
-
|
|
669
|
+
tryUpdatePrinciple(wctx.stateDir, wctx.workspaceDir, principleId, {
|
|
653
670
|
evaluability: 'manual_only',
|
|
654
671
|
compilationRetryCount: undefined,
|
|
655
672
|
});
|
|
656
673
|
SystemLogger.log(wctx.workspaceDir, 'COMPILE_EXHAUSTED',
|
|
657
674
|
`Principle ${principleId} compilation exhausted after 5 attempts: ${result.reason ?? 'unknown'}`);
|
|
658
675
|
} else {
|
|
659
|
-
|
|
676
|
+
tryUpdateRetryCount(wctx.stateDir, wctx.workspaceDir, principleId, nextCount);
|
|
660
677
|
SystemLogger.log(wctx.workspaceDir, 'COMPILE_FAILED',
|
|
661
678
|
`Principle ${principleId} compile failed: ${result.reason ?? 'unknown'} (attempt ${nextCount}/5)`);
|
|
662
679
|
}
|
|
@@ -665,14 +682,14 @@ export async function processCompilationBackfill(
|
|
|
665
682
|
const nextCount = count + 1;
|
|
666
683
|
if (nextCount >= 5) {
|
|
667
684
|
// Exhausted: single write to set manual_only (no intermediate count write)
|
|
668
|
-
|
|
685
|
+
tryUpdatePrinciple(wctx.stateDir, wctx.workspaceDir, principleId, {
|
|
669
686
|
evaluability: 'manual_only',
|
|
670
687
|
compilationRetryCount: undefined,
|
|
671
688
|
});
|
|
672
689
|
SystemLogger.log(wctx.workspaceDir, 'COMPILE_EXHAUSTED',
|
|
673
690
|
`Principle ${principleId} compilation exhausted after 5 attempts: threw ${String(compileErr)}`);
|
|
674
691
|
} else {
|
|
675
|
-
|
|
692
|
+
tryUpdateRetryCount(wctx.stateDir, wctx.workspaceDir, principleId, nextCount);
|
|
676
693
|
SystemLogger.log(wctx.workspaceDir, 'COMPILE_FAILED',
|
|
677
694
|
`Principle ${principleId} compile threw: ${String(compileErr)} (attempt ${nextCount}/5)`);
|
|
678
695
|
}
|
|
@@ -681,11 +698,11 @@ export async function processCompilationBackfill(
|
|
|
681
698
|
}
|
|
682
699
|
|
|
683
700
|
/**
|
|
684
|
-
* Wrapper for
|
|
685
|
-
*
|
|
686
|
-
* picked up again on the next heartbeat.
|
|
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.
|
|
687
704
|
*/
|
|
688
|
-
function
|
|
705
|
+
function tryUpdateRetryCount(stateDir: string, workspaceDir: string, principleId: string, count: number | undefined): void {
|
|
689
706
|
try {
|
|
690
707
|
updatePrinciple(stateDir, principleId, { compilationRetryCount: count });
|
|
691
708
|
} catch (err) {
|
|
@@ -696,8 +713,10 @@ function safeUpdateRetryCount(stateDir: string, workspaceDir: string, principleI
|
|
|
696
713
|
|
|
697
714
|
/**
|
|
698
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.
|
|
699
718
|
*/
|
|
700
|
-
function
|
|
719
|
+
function tryUpdatePrinciple(
|
|
701
720
|
stateDir: string,
|
|
702
721
|
workspaceDir: string,
|
|
703
722
|
principleId: string,
|
|
@@ -915,7 +934,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
915
934
|
} else {
|
|
916
935
|
logger.info(`[PD:EvolutionWorker] Creating principle from report for task ${task.id}`);
|
|
917
936
|
const principleId = wctx.evolutionReducer.createPrincipleFromDiagnosis({
|
|
918
|
-
painId: task.id,
|
|
937
|
+
painId: task.painEventId !== undefined ? String(task.painEventId) : task.id,
|
|
919
938
|
painType: task.source === 'Human Intervention' ? 'user_frustration' : 'tool_failure',
|
|
920
939
|
triggerPattern: principle.trigger_pattern,
|
|
921
940
|
action: principle.action,
|
|
@@ -1013,7 +1032,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1013
1032
|
logger.info(`[PD:EvolutionWorker] Diagnostician marked principle as duplicate: ${principle.duplicate_of || 'unknown'} — skipping for task ${task.id}`);
|
|
1014
1033
|
} else {
|
|
1015
1034
|
const principleId = wctx.evolutionReducer.createPrincipleFromDiagnosis({
|
|
1016
|
-
painId: task.id,
|
|
1035
|
+
painId: task.painEventId !== undefined ? String(task.painEventId) : task.id,
|
|
1017
1036
|
painType: task.source === 'Human Intervention' ? 'user_frustration' : 'tool_failure',
|
|
1018
1037
|
triggerPattern: principle.trigger_pattern,
|
|
1019
1038
|
action: principle.action,
|
|
@@ -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
|
+
});
|