principles-disciple 1.19.0 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/commands/archive-impl.ts +5 -1
- package/src/commands/disable-impl.ts +5 -1
- package/src/commands/nocturnal-review.ts +12 -0
- package/src/commands/promote-impl.ts +10 -2
- package/src/commands/rollback-impl.ts +5 -1
- package/src/core/nocturnal-snapshot-contract.ts +12 -26
- package/src/core/nocturnal-trajectory-extractor.ts +9 -6
- package/src/core/principle-internalization/internalization-routing-policy.ts +3 -3
- package/src/core/principle-internalization/lifecycle-metrics.ts +3 -0
- package/src/service/evolution-worker.ts +47 -11
- package/src/service/nocturnal-service.ts +28 -3
- package/tests/core/nocturnal-snapshot-contract.test.ts +53 -2
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -126,7 +126,11 @@ function _handleArchiveImpl(
|
|
|
126
126
|
updateImplementation(stateDir, implId, {
|
|
127
127
|
archivedAt: new Date().toISOString(),
|
|
128
128
|
});
|
|
129
|
-
|
|
129
|
+
try {
|
|
130
|
+
refreshPrincipleLifecycle(workspaceDir, stateDir);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
console.warn('[archive-impl] Lifecycle refresh failed:', err instanceof Error ? err.stack : err);
|
|
133
|
+
}
|
|
130
134
|
|
|
131
135
|
return {
|
|
132
136
|
text: isZh
|
|
@@ -109,7 +109,11 @@ function _handleDisableImpl(
|
|
|
109
109
|
disabledBy: sessionId || 'manual',
|
|
110
110
|
disabledReason: reasonText,
|
|
111
111
|
});
|
|
112
|
-
|
|
112
|
+
try {
|
|
113
|
+
refreshPrincipleLifecycle(workspaceDir, stateDir);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.warn('[disable-impl] Lifecycle refresh failed:', err instanceof Error ? err.stack : err);
|
|
116
|
+
}
|
|
113
117
|
|
|
114
118
|
return {
|
|
115
119
|
text: isZh
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import * as fs from 'fs';
|
|
25
|
+
import * as path from 'path';
|
|
25
26
|
import {
|
|
26
27
|
listDatasetRecords,
|
|
27
28
|
getDatasetRecord,
|
|
@@ -31,6 +32,7 @@ import {
|
|
|
31
32
|
type NocturnalDatasetRecord,
|
|
32
33
|
type NocturnalReviewStatus,
|
|
33
34
|
} from '../core/nocturnal-dataset.js';
|
|
35
|
+
import { getPrincipleState, setPrincipleState } from '../core/principle-training-state.js';
|
|
34
36
|
import type { PluginCommandContext, PluginCommandResult } from '../openclaw-sdk.js';
|
|
35
37
|
|
|
36
38
|
function isZh(ctx: PluginCommandContext): boolean {
|
|
@@ -194,6 +196,16 @@ export function handleNocturnalReviewCommand(ctx: PluginCommandContext): PluginC
|
|
|
194
196
|
|
|
195
197
|
const updated = updateReviewStatus(workspaceDir, fingerprint, 'approved_for_training', reason);
|
|
196
198
|
|
|
199
|
+
// #251: Sync approvedSampleCount in training store
|
|
200
|
+
try {
|
|
201
|
+
const stateDir = path.join(workspaceDir, '.state');
|
|
202
|
+
const state = getPrincipleState(stateDir, updated.principleId);
|
|
203
|
+
state.approvedSampleCount += 1;
|
|
204
|
+
setPrincipleState(stateDir, state);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
console.warn(`[nocturnal-review] Failed to sync approvedSampleCount for ${updated.principleId}:`, err instanceof Error ? err.stack : err);
|
|
207
|
+
}
|
|
208
|
+
|
|
197
209
|
return {
|
|
198
210
|
text: zh
|
|
199
211
|
? `样本已批准用于训练:\n fingerprint: ${updated.sampleFingerprint.substring(0, 16)}...\n principleId: ${updated.principleId}\n targetModelFamily: ${updated.targetModelFamily ?? '(none)'}\n reason: ${updated.reviewReason}`
|
|
@@ -172,7 +172,11 @@ function _handlePromoteImpl(options: PromoteImplOptions): PluginCommandResult {
|
|
|
172
172
|
disabledBy: undefined,
|
|
173
173
|
disabledReason: undefined,
|
|
174
174
|
});
|
|
175
|
-
|
|
175
|
+
try {
|
|
176
|
+
refreshPrincipleLifecycle(workspaceDir, stateDir);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.warn('[promote-impl] Lifecycle refresh failed (re-enable):', err instanceof Error ? err.stack : err);
|
|
179
|
+
}
|
|
176
180
|
|
|
177
181
|
output += isZh
|
|
178
182
|
? `\n✅ 实现已重新启用: ${implId}\n 状态: disabled -> active`
|
|
@@ -225,7 +229,11 @@ function _handlePromoteImpl(options: PromoteImplOptions): PluginCommandResult {
|
|
|
225
229
|
withLock(eventPath, () => {
|
|
226
230
|
fs.writeFileSync(eventPath, JSON.stringify(promotionEvent, null, 2), 'utf-8');
|
|
227
231
|
});
|
|
228
|
-
|
|
232
|
+
try {
|
|
233
|
+
refreshPrincipleLifecycle(workspaceDir, stateDir);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
console.warn('[promote-impl] Lifecycle refresh failed (promotion):', err instanceof Error ? err.stack : err);
|
|
236
|
+
}
|
|
229
237
|
|
|
230
238
|
output += isZh
|
|
231
239
|
? `\n\n✅ 实现已晋升: ${implId}\n 状态: candidate -> active`
|
|
@@ -185,7 +185,11 @@ function _handleRollbackImpl(
|
|
|
185
185
|
withLock(rollbackPath, () => {
|
|
186
186
|
fs.writeFileSync(rollbackPath, JSON.stringify(rollbackRecord, null, 2), 'utf-8');
|
|
187
187
|
});
|
|
188
|
-
|
|
188
|
+
try {
|
|
189
|
+
refreshPrincipleLifecycle(workspaceDir, stateDir);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
console.warn('[rollback-impl] Lifecycle refresh failed:', err instanceof Error ? err.stack : err);
|
|
192
|
+
}
|
|
189
193
|
|
|
190
194
|
let output = isZh
|
|
191
195
|
? `\n\u2705 \u56de\u6eda\u5b8c\u6210: ${implId}\n \u72b6\u6001: active -> disabled\n \u539f\u56e0: ${reasonText}`
|
|
@@ -14,8 +14,9 @@ function isNonEmptyString(value: unknown): value is string {
|
|
|
14
14
|
return typeof value === 'string' && value.trim().length > 0;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
/** #246: Stats fields must now be finite numbers — null is no longer accepted. */
|
|
18
|
+
function isFiniteNumber(value: unknown): value is number {
|
|
19
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export function validateNocturnalSnapshotIngress(
|
|
@@ -56,20 +57,20 @@ export function validateNocturnalSnapshotIngress(
|
|
|
56
57
|
if (!isObjectRecord(stats)) {
|
|
57
58
|
reasons.push('snapshot.stats must be an object');
|
|
58
59
|
} else {
|
|
59
|
-
if (!
|
|
60
|
-
reasons.push('snapshot.stats.totalAssistantTurns must be a number
|
|
60
|
+
if (!isFiniteNumber(stats.totalAssistantTurns)) {
|
|
61
|
+
reasons.push('snapshot.stats.totalAssistantTurns must be a finite number');
|
|
61
62
|
}
|
|
62
|
-
if (!
|
|
63
|
-
reasons.push('snapshot.stats.totalToolCalls must be a number
|
|
63
|
+
if (!isFiniteNumber(stats.totalToolCalls)) {
|
|
64
|
+
reasons.push('snapshot.stats.totalToolCalls must be a finite number');
|
|
64
65
|
}
|
|
65
|
-
if (
|
|
66
|
+
if (!isFiniteNumber(stats.totalPainEvents)) {
|
|
66
67
|
reasons.push('snapshot.stats.totalPainEvents must be a finite number');
|
|
67
68
|
}
|
|
68
|
-
if (!
|
|
69
|
-
reasons.push('snapshot.stats.totalGateBlocks must be a number
|
|
69
|
+
if (!isFiniteNumber(stats.totalGateBlocks)) {
|
|
70
|
+
reasons.push('snapshot.stats.totalGateBlocks must be a finite number');
|
|
70
71
|
}
|
|
71
|
-
if (!
|
|
72
|
-
reasons.push('snapshot.stats.failureCount must be a number
|
|
72
|
+
if (!isFiniteNumber(stats.failureCount)) {
|
|
73
|
+
reasons.push('snapshot.stats.failureCount must be a finite number');
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
76
|
|
|
@@ -85,21 +86,6 @@ export function validateNocturnalSnapshotIngress(
|
|
|
85
86
|
}
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
if (!isFallback && isObjectRecord(stats)) {
|
|
89
|
-
if (stats.totalAssistantTurns === null) {
|
|
90
|
-
reasons.push('non-fallback snapshot.stats.totalAssistantTurns must be a number');
|
|
91
|
-
}
|
|
92
|
-
if (stats.totalToolCalls === null) {
|
|
93
|
-
reasons.push('non-fallback snapshot.stats.totalToolCalls must be a number');
|
|
94
|
-
}
|
|
95
|
-
if (stats.totalGateBlocks === null) {
|
|
96
|
-
reasons.push('non-fallback snapshot.stats.totalGateBlocks must be a number');
|
|
97
|
-
}
|
|
98
|
-
if (stats.failureCount === null) {
|
|
99
|
-
reasons.push('non-fallback snapshot.stats.failureCount must be a number');
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
89
|
if (reasons.length > 0) {
|
|
104
90
|
return { status: 'invalid', reasons };
|
|
105
91
|
}
|
|
@@ -116,15 +116,18 @@ export interface NocturnalSessionSnapshot {
|
|
|
116
116
|
gateBlocks: NocturnalGateBlock[];
|
|
117
117
|
/**
|
|
118
118
|
* Summary statistics for quick triage.
|
|
119
|
-
*
|
|
120
|
-
* to
|
|
119
|
+
* #246: All fields are now number (never null).
|
|
120
|
+
* Previously null was used to mean "no trajectory data", but this caused
|
|
121
|
+
* downstream consumers to crash on arithmetic. The fallback path now
|
|
122
|
+
* queries the trajectory extractor for real data and falls back to 0.
|
|
123
|
+
* Use _dataSource === 'pain_context_fallback' to detect partial data.
|
|
121
124
|
*/
|
|
122
125
|
stats: {
|
|
123
|
-
totalAssistantTurns: number
|
|
124
|
-
totalToolCalls: number
|
|
126
|
+
totalAssistantTurns: number;
|
|
127
|
+
totalToolCalls: number;
|
|
125
128
|
totalPainEvents: number;
|
|
126
|
-
totalGateBlocks: number
|
|
127
|
-
failureCount: number
|
|
129
|
+
totalGateBlocks: number;
|
|
130
|
+
failureCount: number;
|
|
128
131
|
};
|
|
129
132
|
/**
|
|
130
133
|
* #219: Marker for data source to identify fallback/partial stats.
|
|
@@ -121,14 +121,14 @@ export function recommendInternalizationRoute(
|
|
|
121
121
|
principle.summary.repeatedErrorSignal === 0;
|
|
122
122
|
|
|
123
123
|
if (principle.rules.length === 0) {
|
|
124
|
-
reasonCodes.push('no_material_rules');
|
|
124
|
+
reasonCodes.push('insufficient_data', 'no_material_rules');
|
|
125
125
|
return {
|
|
126
126
|
principleId: principle.principle.id,
|
|
127
127
|
route: 'defer',
|
|
128
|
-
confidence:
|
|
128
|
+
confidence: 50,
|
|
129
129
|
reasonCodes,
|
|
130
130
|
evidenceSummary,
|
|
131
|
-
nextAction: '
|
|
131
|
+
nextAction: 'No rules defined for this principle. Create at least one rule via pain→principle→rule pipeline before internalization routing can produce meaningful recommendations.',
|
|
132
132
|
};
|
|
133
133
|
}
|
|
134
134
|
|
|
@@ -11,6 +11,8 @@ export interface RuleMetricResult {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export interface PrincipleAdherenceResult {
|
|
14
|
+
/** True when no rules exist — all numeric fields are defaults, not computed values */
|
|
15
|
+
insufficientData?: boolean;
|
|
14
16
|
adherenceRate: number;
|
|
15
17
|
averageRuleCoverage: number;
|
|
16
18
|
averageFalsePositiveRate: number;
|
|
@@ -108,6 +110,7 @@ export function computePrincipleAdherence(
|
|
|
108
110
|
|
|
109
111
|
if (principle.rules.length === 0) {
|
|
110
112
|
return {
|
|
113
|
+
insufficientData: true,
|
|
111
114
|
adherenceRate: 0,
|
|
112
115
|
averageRuleCoverage: 0,
|
|
113
116
|
averageFalsePositiveRate: 0,
|
|
@@ -132,9 +132,17 @@ async function runWorkflowWatchdog(
|
|
|
132
132
|
if (dataSource === 'pain_context_fallback') {
|
|
133
133
|
details.push(`fallback_snapshot: nocturnal workflow ${wf.workflow_id} uses pain-context fallback (stats may be incomplete)`);
|
|
134
134
|
}
|
|
135
|
-
const stats = snapshot.stats as Record<string, number
|
|
136
|
-
|
|
137
|
-
|
|
135
|
+
const stats = snapshot.stats as Record<string, number> | undefined;
|
|
136
|
+
// #246: Stats are now always number (never null). Detect "empty" fallback:
|
|
137
|
+
// fallback + all counts zero means no real data was available.
|
|
138
|
+
// NOTE: totalAssistantTurns may be 0 even for valid sessions because
|
|
139
|
+
// listRecentNocturnalCandidateSessions (used in fallback path) does not
|
|
140
|
+
// populate assistantTurnCount (only getNocturnalSessionSnapshot does).
|
|
141
|
+
// We use totalToolCalls=0 as the primary indicator instead.
|
|
142
|
+
if (stats && dataSource === 'pain_context_fallback' &&
|
|
143
|
+
stats.totalToolCalls === 0 && stats.totalGateBlocks === 0 &&
|
|
144
|
+
stats.failureCount === 0) {
|
|
145
|
+
details.push(`fallback_snapshot_stats: nocturnal workflow ${wf.workflow_id} has empty fallback stats (no trajectory data found)`);
|
|
138
146
|
}
|
|
139
147
|
}
|
|
140
148
|
} catch { /* ignore malformed metadata */ }
|
|
@@ -335,7 +343,10 @@ function isSessionAtOrBeforeTriggerTime(
|
|
|
335
343
|
return true;
|
|
336
344
|
}
|
|
337
345
|
|
|
338
|
-
function buildFallbackNocturnalSnapshot(
|
|
346
|
+
function buildFallbackNocturnalSnapshot(
|
|
347
|
+
sleepTask: EvolutionQueueItem,
|
|
348
|
+
extractor?: ReturnType<typeof createNocturnalTrajectoryExtractor> | null
|
|
349
|
+
): NocturnalSessionSnapshot | null {
|
|
339
350
|
const painContext = sleepTask.recentPainContext;
|
|
340
351
|
if (!painContext) {
|
|
341
352
|
return null;
|
|
@@ -349,6 +360,30 @@ function buildFallbackNocturnalSnapshot(sleepTask: EvolutionQueueItem): Nocturna
|
|
|
349
360
|
createdAt: painContext.mostRecent.timestamp,
|
|
350
361
|
}] : [];
|
|
351
362
|
|
|
363
|
+
// #246: Try to extract real session stats from trajectory DB for the pain session.
|
|
364
|
+
// The main path tries getNocturnalSessionSnapshot which returns null when no session
|
|
365
|
+
// exists. Here we attempt a lighter query via listRecentNocturnalCandidateSessions
|
|
366
|
+
// to at least get summary counts for the pain-triggering session.
|
|
367
|
+
let realStats: { totalAssistantTurns: number; totalToolCalls: number; failureCount: number; totalGateBlocks: number } | null = null;
|
|
368
|
+
if (extractor && painContext.mostRecent?.sessionId) {
|
|
369
|
+
try {
|
|
370
|
+
// #246-fix: Use minToolCalls=0 to avoid filtering out sessions with 0 tool calls.
|
|
371
|
+
// The pain-triggering session may have no tool calls but still be worth tracking.
|
|
372
|
+
const summaries = extractor.listRecentNocturnalCandidateSessions({ limit: 300, minToolCalls: 0 });
|
|
373
|
+
const match = summaries.find(s => s.sessionId === painContext.mostRecent!.sessionId);
|
|
374
|
+
if (match) {
|
|
375
|
+
realStats = {
|
|
376
|
+
totalAssistantTurns: match.assistantTurnCount,
|
|
377
|
+
totalToolCalls: match.toolCallCount,
|
|
378
|
+
failureCount: match.failureCount,
|
|
379
|
+
totalGateBlocks: match.gateBlockCount,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
} catch {
|
|
383
|
+
// Best effort — non-fatal
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
352
387
|
return {
|
|
353
388
|
sessionId: painContext.mostRecent?.sessionId || sleepTask.id,
|
|
354
389
|
startedAt: sleepTask.timestamp,
|
|
@@ -359,11 +394,11 @@ function buildFallbackNocturnalSnapshot(sleepTask: EvolutionQueueItem): Nocturna
|
|
|
359
394
|
painEvents: fallbackPainEvents,
|
|
360
395
|
gateBlocks: [],
|
|
361
396
|
stats: {
|
|
362
|
-
totalAssistantTurns:
|
|
363
|
-
totalToolCalls:
|
|
364
|
-
failureCount:
|
|
397
|
+
totalAssistantTurns: realStats?.totalAssistantTurns ?? 0,
|
|
398
|
+
totalToolCalls: realStats?.totalToolCalls ?? 0,
|
|
399
|
+
failureCount: realStats?.failureCount ?? 0,
|
|
365
400
|
totalPainEvents: painContext.recentPainCount,
|
|
366
|
-
totalGateBlocks:
|
|
401
|
+
totalGateBlocks: realStats?.totalGateBlocks ?? 0,
|
|
367
402
|
},
|
|
368
403
|
_dataSource: 'pain_context_fallback',
|
|
369
404
|
};
|
|
@@ -1466,8 +1501,9 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1466
1501
|
} else {
|
|
1467
1502
|
// Phase 1: Build trajectory snapshot for Nocturnal pipeline
|
|
1468
1503
|
// Priority: Pain signal sessionId → Task ID → Recent session with violations
|
|
1504
|
+
let extractor: ReturnType<typeof createNocturnalTrajectoryExtractor> | null = null;
|
|
1469
1505
|
try {
|
|
1470
|
-
|
|
1506
|
+
extractor = createNocturnalTrajectoryExtractor(wctx.workspaceDir);
|
|
1471
1507
|
|
|
1472
1508
|
// 1. Try exact session ID from pain signal (most accurate)
|
|
1473
1509
|
const painSessionId = sleepTask.recentPainContext?.mostRecent?.sessionId;
|
|
@@ -1516,8 +1552,8 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1516
1552
|
|
|
1517
1553
|
// Phase 2: If no trajectory data, try pain-context fallback
|
|
1518
1554
|
if (!snapshotData && sleepTask.recentPainContext) {
|
|
1519
|
-
logger?.warn?.(`[PD:EvolutionWorker] Using pain-context fallback for ${sleepTask.id}: trajectory
|
|
1520
|
-
snapshotData = buildFallbackNocturnalSnapshot(sleepTask) ?? undefined;
|
|
1555
|
+
logger?.warn?.(`[PD:EvolutionWorker] Using pain-context fallback for ${sleepTask.id}: trajectory snapshot unavailable, will try session summary from extractor`);
|
|
1556
|
+
snapshotData = buildFallbackNocturnalSnapshot(sleepTask, extractor) ?? undefined;
|
|
1521
1557
|
}
|
|
1522
1558
|
|
|
1523
1559
|
const snapshotValidation = validateNocturnalSnapshotIngress(snapshotData);
|
|
@@ -98,9 +98,24 @@ import {
|
|
|
98
98
|
} from './nocturnal-runtime.js';
|
|
99
99
|
import { NocturnalPathResolver } from '../core/nocturnal-paths.js';
|
|
100
100
|
import { registerSample } from '../core/nocturnal-dataset.js';
|
|
101
|
+
import { getPrincipleState, setPrincipleState } from '../core/principle-training-state.js';
|
|
101
102
|
import type { Implementation } from '../types/principle-tree-schema.js';
|
|
102
103
|
import { validateNocturnalSnapshotIngress } from '../core/nocturnal-snapshot-contract.js';
|
|
103
104
|
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// #251: Sync trainingStore sample counts after registration
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
function incrementGeneratedSampleCount(stateDir: string, principleId: string): void {
|
|
110
|
+
try {
|
|
111
|
+
const state = getPrincipleState(stateDir, principleId);
|
|
112
|
+
state.generatedSampleCount += 1;
|
|
113
|
+
setPrincipleState(stateDir, state);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.warn(`[nocturnal-service] Failed to sync generatedSampleCount for ${principleId}:`, err instanceof Error ? err.stack : err);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
104
119
|
// ---------------------------------------------------------------------------
|
|
105
120
|
// Types
|
|
106
121
|
// ---------------------------------------------------------------------------
|
|
@@ -467,7 +482,11 @@ function persistCodeCandidate(
|
|
|
467
482
|
implementationId,
|
|
468
483
|
createdAt: now,
|
|
469
484
|
});
|
|
470
|
-
|
|
485
|
+
try {
|
|
486
|
+
refreshPrincipleLifecycle(workspaceDir, stateDir);
|
|
487
|
+
} catch (err) {
|
|
488
|
+
console.warn('[nocturnal-service] Lifecycle refresh failed after code candidate persistence:', err instanceof Error ? err.stack : err);
|
|
489
|
+
}
|
|
471
490
|
return {
|
|
472
491
|
status: 'persisted_candidate',
|
|
473
492
|
ruleResolution: {
|
|
@@ -993,7 +1012,10 @@ export function executeNocturnalReflection(
|
|
|
993
1012
|
// Approved artifacts must enter the dataset registry so they can be reviewed
|
|
994
1013
|
// before export. Without this, new samples never appear in the review queue.
|
|
995
1014
|
try {
|
|
996
|
-
registerSample(workspaceDir, arbiterResult.artifact, persistedPath, null);
|
|
1015
|
+
const regResult = registerSample(workspaceDir, arbiterResult.artifact, persistedPath, null);
|
|
1016
|
+
if (regResult.isNew) {
|
|
1017
|
+
incrementGeneratedSampleCount(stateDir, arbiterResult.artifact.principleId);
|
|
1018
|
+
}
|
|
997
1019
|
} catch (err) {
|
|
998
1020
|
// Non-fatal: artifact is persisted, registry is secondary.
|
|
999
1021
|
// Log but don't fail the run.
|
|
@@ -1380,7 +1402,10 @@ async function executeNocturnalReflectionWithAdapter(
|
|
|
1380
1402
|
|
|
1381
1403
|
// Step 8: Register in dataset lineage
|
|
1382
1404
|
try {
|
|
1383
|
-
registerSample(workspaceDir, arbiterResult.artifact, persistedPath, null);
|
|
1405
|
+
const regResult = registerSample(workspaceDir, arbiterResult.artifact, persistedPath, null);
|
|
1406
|
+
if (regResult.isNew) {
|
|
1407
|
+
incrementGeneratedSampleCount(stateDir, arbiterResult.artifact.principleId);
|
|
1408
|
+
}
|
|
1384
1409
|
} catch (err) {
|
|
1385
1410
|
console.warn(`[nocturnal-service] Failed to register sample in dataset registry: ${String(err)}`);
|
|
1386
1411
|
}
|
|
@@ -54,10 +54,35 @@ describe('validateNocturnalSnapshotIngress', () => {
|
|
|
54
54
|
toolCalls: [],
|
|
55
55
|
painEvents: [],
|
|
56
56
|
gateBlocks: [],
|
|
57
|
+
stats: {
|
|
58
|
+
totalAssistantTurns: 0,
|
|
59
|
+
totalToolCalls: 0,
|
|
60
|
+
totalPainEvents: 0,
|
|
61
|
+
totalGateBlocks: 0,
|
|
62
|
+
failureCount: 0,
|
|
63
|
+
},
|
|
64
|
+
_dataSource: 'pain_context_fallback',
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(result.status).toBe('invalid');
|
|
68
|
+
expect(result.reasons).toContain('fallback snapshot must contain at least one pain signal');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// #246: null stats fields should now be rejected (they used to be accepted for fallback)
|
|
72
|
+
it('rejects null values in stats fields', () => {
|
|
73
|
+
const result = validateNocturnalSnapshotIngress({
|
|
74
|
+
sessionId: 'session-1',
|
|
75
|
+
startedAt: '2026-04-10T00:00:00.000Z',
|
|
76
|
+
updatedAt: '2026-04-10T00:00:00.000Z',
|
|
77
|
+
assistantTurns: [],
|
|
78
|
+
userTurns: [],
|
|
79
|
+
toolCalls: [],
|
|
80
|
+
painEvents: [{ source: 'test', score: 5, severity: 'high', reason: 'test', createdAt: '2026-04-10T00:00:00.000Z' }],
|
|
81
|
+
gateBlocks: [],
|
|
57
82
|
stats: {
|
|
58
83
|
totalAssistantTurns: null,
|
|
59
84
|
totalToolCalls: null,
|
|
60
|
-
totalPainEvents:
|
|
85
|
+
totalPainEvents: 1,
|
|
61
86
|
totalGateBlocks: null,
|
|
62
87
|
failureCount: null,
|
|
63
88
|
},
|
|
@@ -65,6 +90,32 @@ describe('validateNocturnalSnapshotIngress', () => {
|
|
|
65
90
|
});
|
|
66
91
|
|
|
67
92
|
expect(result.status).toBe('invalid');
|
|
68
|
-
expect(result.reasons).toContain('
|
|
93
|
+
expect(result.reasons).toContain('snapshot.stats.totalAssistantTurns must be a finite number');
|
|
94
|
+
expect(result.reasons).toContain('snapshot.stats.totalToolCalls must be a finite number');
|
|
95
|
+
expect(result.reasons).toContain('snapshot.stats.totalGateBlocks must be a finite number');
|
|
96
|
+
expect(result.reasons).toContain('snapshot.stats.failureCount must be a finite number');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('accepts fallback snapshot with valid stats and pain signal', () => {
|
|
100
|
+
const result = validateNocturnalSnapshotIngress({
|
|
101
|
+
sessionId: 'session-1',
|
|
102
|
+
startedAt: '2026-04-10T00:00:00.000Z',
|
|
103
|
+
updatedAt: '2026-04-10T00:00:00.000Z',
|
|
104
|
+
assistantTurns: [],
|
|
105
|
+
userTurns: [],
|
|
106
|
+
toolCalls: [],
|
|
107
|
+
painEvents: [{ source: 'test', score: 5, severity: 'high', reason: 'test', createdAt: '2026-04-10T00:00:00.000Z' }],
|
|
108
|
+
gateBlocks: [],
|
|
109
|
+
stats: {
|
|
110
|
+
totalAssistantTurns: 0,
|
|
111
|
+
totalToolCalls: 0,
|
|
112
|
+
totalPainEvents: 1,
|
|
113
|
+
totalGateBlocks: 0,
|
|
114
|
+
failureCount: 0,
|
|
115
|
+
},
|
|
116
|
+
_dataSource: 'pain_context_fallback',
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(result.status).toBe('valid');
|
|
69
120
|
});
|
|
70
121
|
});
|