principles-disciple 1.14.0 → 1.15.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/core/principle-tree-migration.ts +196 -0
- package/src/service/evolution-worker.ts +81 -61
- package/src/service/monitoring-query-service.ts +277 -0
- package/src/service/nocturnal-service.ts +9 -1
- package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +10 -2
- package/tests/core/principle-tree-migration.test.ts +77 -0
- package/tests/service/evolution-worker.nocturnal.test.ts +208 -0
- package/tests/service/monitoring-query-service.test.ts +113 -0
- package/tests/service/nocturnal-runtime-hardening.test.ts +85 -0
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Principle Tree Migration — Migrates trainingStore to tree.principles
|
|
3
|
+
*
|
|
4
|
+
* This migration handles the Phase 11 gap: existing principles in trainingStore
|
|
5
|
+
* were never written to tree.principles, blocking the Rule/Implementation layer.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* - Called automatically by migratePrincipleTree() during plugin initialization
|
|
9
|
+
* - Or run manually: node scripts/migrate-principle-tree.mjs <workspace-dir>
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import * as path from 'path';
|
|
14
|
+
import {
|
|
15
|
+
loadLedger,
|
|
16
|
+
saveLedger,
|
|
17
|
+
type LedgerPrinciple,
|
|
18
|
+
} from './principle-tree-ledger.js';
|
|
19
|
+
import type { LegacyPrincipleTrainingState } from './principle-tree-ledger.js';
|
|
20
|
+
import { SystemLogger } from './system-logger.js';
|
|
21
|
+
|
|
22
|
+
export interface PrincipleTreeMigrationResult {
|
|
23
|
+
migratedCount: number;
|
|
24
|
+
skippedCount: number;
|
|
25
|
+
errorCount: number;
|
|
26
|
+
details: Array<{
|
|
27
|
+
principleId: string;
|
|
28
|
+
status: 'migrated' | 'skipped' | 'error';
|
|
29
|
+
reason?: string;
|
|
30
|
+
}>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Check if migration is needed by comparing trainingStore and tree.principles
|
|
35
|
+
*/
|
|
36
|
+
export function needsMigration(stateDir: string): boolean {
|
|
37
|
+
const ledger = loadLedger(stateDir);
|
|
38
|
+
return Object.keys(ledger.trainingStore).some((principleId) => !ledger.tree.principles[principleId]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create a minimal LedgerPrinciple from LegacyPrincipleTrainingState
|
|
43
|
+
*/
|
|
44
|
+
function trainingStateToTreePrinciple(
|
|
45
|
+
principleId: string,
|
|
46
|
+
state: LegacyPrincipleTrainingState,
|
|
47
|
+
now: string
|
|
48
|
+
): LedgerPrinciple {
|
|
49
|
+
return {
|
|
50
|
+
id: principleId,
|
|
51
|
+
version: 1,
|
|
52
|
+
text: `Principle ${principleId}`, // Minimal text, will be enriched from PRINCIPLES.md if available
|
|
53
|
+
triggerPattern: '', // Unknown from legacy data
|
|
54
|
+
action: '', // Unknown from legacy data
|
|
55
|
+
status: mapInternalizationStatusToPrincipleStatus(state.internalizationStatus),
|
|
56
|
+
priority: 'P1', // Default priority
|
|
57
|
+
scope: 'general',
|
|
58
|
+
evaluability: state.evaluability,
|
|
59
|
+
valueScore: 0,
|
|
60
|
+
adherenceRate: state.complianceRate * 100, // Convert 0-1 to 0-100
|
|
61
|
+
painPreventedCount: 0,
|
|
62
|
+
derivedFromPainIds: [],
|
|
63
|
+
ruleIds: [],
|
|
64
|
+
conflictsWithPrincipleIds: [],
|
|
65
|
+
createdAt: now,
|
|
66
|
+
updatedAt: now,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Map internalization status to principle status
|
|
72
|
+
*/
|
|
73
|
+
function mapInternalizationStatusToPrincipleStatus(
|
|
74
|
+
status: LegacyPrincipleTrainingState['internalizationStatus']
|
|
75
|
+
): 'candidate' | 'active' | 'deprecated' {
|
|
76
|
+
switch (status) {
|
|
77
|
+
case 'internalized':
|
|
78
|
+
case 'deployed_pending_eval':
|
|
79
|
+
return 'active';
|
|
80
|
+
case 'regressed':
|
|
81
|
+
case 'needs_training':
|
|
82
|
+
return 'candidate';
|
|
83
|
+
case 'prompt_only':
|
|
84
|
+
case 'in_training':
|
|
85
|
+
return 'candidate';
|
|
86
|
+
default:
|
|
87
|
+
return 'candidate';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Migrate trainingStore principles to tree.principles
|
|
93
|
+
*
|
|
94
|
+
* This function is idempotent: it only migrates principles that don't exist
|
|
95
|
+
* in tree.principles yet.
|
|
96
|
+
*/
|
|
97
|
+
export function migratePrincipleTree(
|
|
98
|
+
stateDir: string,
|
|
99
|
+
workspaceDir?: string
|
|
100
|
+
): PrincipleTreeMigrationResult {
|
|
101
|
+
const result: PrincipleTreeMigrationResult = {
|
|
102
|
+
migratedCount: 0,
|
|
103
|
+
skippedCount: 0,
|
|
104
|
+
errorCount: 0,
|
|
105
|
+
details: [],
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const ledger = loadLedger(stateDir);
|
|
110
|
+
const now = new Date().toISOString();
|
|
111
|
+
|
|
112
|
+
for (const [principleId, state] of Object.entries(ledger.trainingStore)) {
|
|
113
|
+
// Skip if already exists in tree.principles
|
|
114
|
+
if (ledger.tree.principles[principleId]) {
|
|
115
|
+
result.skippedCount++;
|
|
116
|
+
result.details.push({
|
|
117
|
+
principleId,
|
|
118
|
+
status: 'skipped',
|
|
119
|
+
reason: 'Already exists in tree.principles',
|
|
120
|
+
});
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const treePrinciple = trainingStateToTreePrinciple(principleId, state, now);
|
|
126
|
+
const nextLedger = loadLedger(stateDir);
|
|
127
|
+
if (!nextLedger.tree.principles[principleId]) {
|
|
128
|
+
nextLedger.tree.principles[principleId] = treePrinciple;
|
|
129
|
+
nextLedger.tree.lastUpdated = now;
|
|
130
|
+
saveLedger(stateDir, nextLedger);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
result.migratedCount++;
|
|
134
|
+
result.details.push({
|
|
135
|
+
principleId,
|
|
136
|
+
status: 'migrated',
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (workspaceDir) {
|
|
140
|
+
SystemLogger.log(
|
|
141
|
+
workspaceDir,
|
|
142
|
+
'PRINCIPLE_TREE_MIGRATED',
|
|
143
|
+
`Migrated ${principleId} from trainingStore to tree.principles`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
} catch (err) {
|
|
147
|
+
result.errorCount++;
|
|
148
|
+
result.details.push({
|
|
149
|
+
principleId,
|
|
150
|
+
status: 'error',
|
|
151
|
+
reason: String(err),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (workspaceDir) {
|
|
155
|
+
SystemLogger.log(
|
|
156
|
+
workspaceDir,
|
|
157
|
+
'PRINCIPLE_TREE_MIGRATION_ERROR',
|
|
158
|
+
`Failed to migrate ${principleId}: ${String(err)}`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (workspaceDir && result.migratedCount > 0) {
|
|
165
|
+
SystemLogger.log(
|
|
166
|
+
workspaceDir,
|
|
167
|
+
'PRINCIPLE_TREE_MIGRATION_COMPLETE',
|
|
168
|
+
`Migrated ${result.migratedCount} principles to tree.principles (${result.skippedCount} skipped, ${result.errorCount} errors)`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
} catch (err) {
|
|
172
|
+
if (workspaceDir) {
|
|
173
|
+
SystemLogger.log(
|
|
174
|
+
workspaceDir,
|
|
175
|
+
'PRINCIPLE_TREE_MIGRATION_FAILED',
|
|
176
|
+
`Migration failed: ${String(err)}`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Run migration if needed (called during plugin initialization)
|
|
186
|
+
*/
|
|
187
|
+
export function runMigrationIfNeeded(
|
|
188
|
+
stateDir: string,
|
|
189
|
+
workspaceDir?: string
|
|
190
|
+
): PrincipleTreeMigrationResult | null {
|
|
191
|
+
if (!needsMigration(stateDir)) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return migratePrincipleTree(stateDir, workspaceDir);
|
|
196
|
+
}
|
|
@@ -188,6 +188,30 @@ export interface RecentPainContext {
|
|
|
188
188
|
recentMaxPainScore: number;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
function hasUsableNocturnalSnapshot(snapshotData: Record<string, unknown> | undefined): boolean {
|
|
192
|
+
if (!snapshotData || typeof snapshotData.sessionId !== 'string' || snapshotData.sessionId.length === 0) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (snapshotData._dataSource !== 'pain_context_fallback') {
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const stats = (snapshotData.stats && typeof snapshotData.stats === 'object')
|
|
201
|
+
? snapshotData.stats as Record<string, number | null | undefined>
|
|
202
|
+
: undefined;
|
|
203
|
+
const recentPain = Array.isArray(snapshotData.recentPain) ? snapshotData.recentPain.length : 0;
|
|
204
|
+
const hasNonZeroStats = !!stats && [
|
|
205
|
+
'totalAssistantTurns',
|
|
206
|
+
'totalToolCalls',
|
|
207
|
+
'failureCount',
|
|
208
|
+
'totalPainEvents',
|
|
209
|
+
'totalGateBlocks',
|
|
210
|
+
].some((key) => Number(stats[key] ?? 0) > 0);
|
|
211
|
+
|
|
212
|
+
return hasNonZeroStats || recentPain > 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
191
215
|
export interface EvolutionQueueItem {
|
|
192
216
|
// Core identity
|
|
193
217
|
id: string;
|
|
@@ -1367,42 +1391,16 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1367
1391
|
logger?.info?.(`[PD:EvolutionWorker] Processing sleep_reflection task ${sleepTask.id}`);
|
|
1368
1392
|
}
|
|
1369
1393
|
|
|
1370
|
-
|
|
1371
|
-
//
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
nocturnalManager = new NocturnalWorkflowManager({
|
|
1376
|
-
workspaceDir: wctx.workspaceDir,
|
|
1377
|
-
stateDir: wctx.stateDir,
|
|
1378
|
-
logger: api.logger,
|
|
1379
|
-
runtimeAdapter: new OpenClawTrinityRuntimeAdapter(api),
|
|
1380
|
-
});
|
|
1381
|
-
} else {
|
|
1382
|
-
// Cannot create manager without api (runtimeAdapter required)
|
|
1383
|
-
sleepTask.status = 'failed';
|
|
1384
|
-
sleepTask.completed_at = new Date().toISOString();
|
|
1385
|
-
sleepTask.resolution = 'failed_max_retries';
|
|
1386
|
-
sleepTask.lastError = 'No API available to create NocturnalWorkflowManager';
|
|
1387
|
-
sleepTask.retryCount = (sleepTask.retryCount ?? 0) + 1;
|
|
1388
|
-
logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} skipped: no API`);
|
|
1389
|
-
continue;
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
// eslint-disable-next-line @typescript-eslint/init-declarations -- assigned in both if/else branches
|
|
1393
|
-
let workflowId: string;
|
|
1394
|
+
let workflowId: string | undefined;
|
|
1395
|
+
// eslint-disable-next-line @typescript-eslint/init-declarations -- assigned when runtime API is available
|
|
1396
|
+
let nocturnalManager: NocturnalWorkflowManager;
|
|
1397
|
+
// eslint-disable-next-line @typescript-eslint/init-declarations -- assigned only for newly started workflows
|
|
1398
|
+
let snapshotData: Record<string, unknown> | undefined;
|
|
1394
1399
|
|
|
1395
1400
|
if (isPollingTask) {
|
|
1396
|
-
//
|
|
1397
|
-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Reason: isPollingTask flag is only set when resultRef is expected to be present
|
|
1401
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Reason: polling path requires existing resultRef
|
|
1398
1402
|
workflowId = sleepTask.resultRef!;
|
|
1399
1403
|
} else {
|
|
1400
|
-
// Start workflow via NocturnalWorkflowManager instead of direct executeNocturnalReflectionAsync
|
|
1401
|
-
// Pass taskId in metadata for correlation
|
|
1402
|
-
|
|
1403
|
-
// #181: Build a proper snapshot from trajectory.db instead of hardcoded zeros
|
|
1404
|
-
// eslint-disable-next-line @typescript-eslint/init-declarations -- undefined is valid zero value, assigned conditionally in if/fallback blocks
|
|
1405
|
-
let snapshotData: Record<string, unknown> | undefined;
|
|
1406
1404
|
if (sleepTask.recentPainContext) {
|
|
1407
1405
|
try {
|
|
1408
1406
|
const extractor = createNocturnalTrajectoryExtractor(wctx.workspaceDir);
|
|
@@ -1412,16 +1410,14 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1412
1410
|
sessionId: fullSnapshot.sessionId,
|
|
1413
1411
|
sessionStart: fullSnapshot.startedAt,
|
|
1414
1412
|
stats: fullSnapshot.stats,
|
|
1415
|
-
recentPain: fullSnapshot.painEvents.slice(-5),
|
|
1413
|
+
recentPain: fullSnapshot.painEvents.slice(-5),
|
|
1416
1414
|
};
|
|
1417
1415
|
}
|
|
1418
1416
|
} catch (snapErr) {
|
|
1419
1417
|
logger?.warn?.(`[PD:EvolutionWorker] Failed to build trajectory snapshot for ${sleepTask.id}: ${String(snapErr)}`);
|
|
1420
1418
|
}
|
|
1421
1419
|
}
|
|
1422
|
-
// Fallback: use pain context only if trajectory extractor failed
|
|
1423
1420
|
if (!snapshotData && sleepTask.recentPainContext) {
|
|
1424
|
-
// #200: Log fallback usage to make data gaps visible
|
|
1425
1421
|
logger?.warn?.(`[PD:EvolutionWorker] Using pain-context fallback for ${sleepTask.id}: trajectory stats unavailable (stats will be partial)`);
|
|
1426
1422
|
snapshotData = {
|
|
1427
1423
|
sessionId: sleepTask.id,
|
|
@@ -1434,31 +1430,63 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1434
1430
|
totalGateBlocks: 0,
|
|
1435
1431
|
},
|
|
1436
1432
|
recentPain: sleepTask.recentPainContext.mostRecent ? [sleepTask.recentPainContext.mostRecent] : [],
|
|
1437
|
-
// #200: Mark data source so downstream can handle appropriately
|
|
1438
1433
|
_dataSource: 'pain_context_fallback',
|
|
1439
1434
|
};
|
|
1440
1435
|
}
|
|
1441
1436
|
|
|
1437
|
+
if (!hasUsableNocturnalSnapshot(snapshotData)) {
|
|
1438
|
+
sleepTask.status = 'failed';
|
|
1439
|
+
sleepTask.completed_at = new Date().toISOString();
|
|
1440
|
+
sleepTask.resolution = 'failed_max_retries';
|
|
1441
|
+
sleepTask.lastError = 'sleep_reflection failed: missing_usable_snapshot (skipReason: empty_fallback_snapshot)';
|
|
1442
|
+
sleepTask.retryCount = (sleepTask.retryCount ?? 0) + 1;
|
|
1443
|
+
logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} rejected: missing usable snapshot`);
|
|
1444
|
+
continue;
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
if (!api) {
|
|
1449
|
+
sleepTask.status = 'failed';
|
|
1450
|
+
sleepTask.completed_at = new Date().toISOString();
|
|
1451
|
+
sleepTask.resolution = 'failed_max_retries';
|
|
1452
|
+
sleepTask.lastError = 'No API available to create NocturnalWorkflowManager';
|
|
1453
|
+
sleepTask.retryCount = (sleepTask.retryCount ?? 0) + 1;
|
|
1454
|
+
logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} skipped: no API`);
|
|
1455
|
+
continue;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
nocturnalManager = new NocturnalWorkflowManager({
|
|
1459
|
+
workspaceDir: wctx.workspaceDir,
|
|
1460
|
+
stateDir: wctx.stateDir,
|
|
1461
|
+
logger: api.logger,
|
|
1462
|
+
runtimeAdapter: new OpenClawTrinityRuntimeAdapter(api),
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
if (!isPollingTask) {
|
|
1442
1466
|
const workflowHandle = await nocturnalManager.startWorkflow(nocturnalWorkflowSpec, {
|
|
1443
1467
|
parentSessionId: `sleep_reflection:${sleepTask.id}`,
|
|
1444
1468
|
workspaceDir: wctx.workspaceDir,
|
|
1445
1469
|
taskInput: {},
|
|
1446
1470
|
metadata: {
|
|
1447
1471
|
snapshot: snapshotData,
|
|
1448
|
-
|
|
1449
|
-
// via executeNocturnalReflectionAsync when no principleId is provided
|
|
1450
|
-
taskId: sleepTask.id, // NOC-14: correlation ID for evolution worker
|
|
1451
|
-
// Pass painContext to Selector for principle ranking bias
|
|
1472
|
+
taskId: sleepTask.id,
|
|
1452
1473
|
painContext: sleepTask.recentPainContext,
|
|
1453
1474
|
},
|
|
1454
1475
|
});
|
|
1455
|
-
|
|
1456
|
-
// Store workflowId on task for polling on subsequent cycles
|
|
1457
1476
|
sleepTask.resultRef = workflowHandle.workflowId;
|
|
1458
|
-
// eslint-disable-next-line @typescript-eslint/prefer-destructuring -- Reason: workflowId is reassignable outer let - destructuring would shadow
|
|
1459
1477
|
workflowId = workflowHandle.workflowId;
|
|
1460
1478
|
}
|
|
1461
1479
|
|
|
1480
|
+
if (!workflowId) {
|
|
1481
|
+
sleepTask.status = 'failed';
|
|
1482
|
+
sleepTask.completed_at = new Date().toISOString();
|
|
1483
|
+
sleepTask.resolution = 'failed_max_retries';
|
|
1484
|
+
sleepTask.lastError = 'sleep_reflection failed: missing_workflow_id';
|
|
1485
|
+
sleepTask.retryCount = (sleepTask.retryCount ?? 0) + 1;
|
|
1486
|
+
logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} missing workflow id after startup`);
|
|
1487
|
+
continue;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1462
1490
|
// Workflow is running asynchronously. Check if it completed in this cycle
|
|
1463
1491
|
// by polling getWorkflowDebugSummary.
|
|
1464
1492
|
const summary = await nocturnalManager.getWorkflowDebugSummary(workflowId);
|
|
@@ -1490,16 +1518,12 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1490
1518
|
sleepTask.lastError = detailedError;
|
|
1491
1519
|
sleepTask.retryCount = (sleepTask.retryCount ?? 0) + 1;
|
|
1492
1520
|
|
|
1521
|
+
sleepTask.status = 'failed';
|
|
1522
|
+
sleepTask.completed_at = new Date().toISOString();
|
|
1523
|
+
sleepTask.resolution = 'failed_max_retries';
|
|
1493
1524
|
if (isExpectedSubagentError(errorReason)) {
|
|
1494
|
-
|
|
1495
|
-
sleepTask.status = 'completed';
|
|
1496
|
-
sleepTask.completed_at = new Date().toISOString();
|
|
1497
|
-
sleepTask.resolution = 'stub_fallback';
|
|
1498
|
-
logger?.info?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} workflow completed with stub fallback (expected subagent error: ${errorReason})`);
|
|
1525
|
+
logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} background runtime unavailable: ${errorReason}`);
|
|
1499
1526
|
} else {
|
|
1500
|
-
sleepTask.status = 'failed';
|
|
1501
|
-
sleepTask.completed_at = new Date().toISOString();
|
|
1502
|
-
sleepTask.resolution = 'failed_max_retries';
|
|
1503
1527
|
logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} workflow failed: ${sleepTask.lastError}`);
|
|
1504
1528
|
}
|
|
1505
1529
|
} else {
|
|
@@ -1511,18 +1535,14 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1511
1535
|
// #202: Handle expected subagent unavailability (e.g., process isolation in daemon mode)
|
|
1512
1536
|
// When subagent is unavailable due to gateway running in separate process,
|
|
1513
1537
|
// use stub fallback instead of failing the task.
|
|
1538
|
+
sleepTask.status = 'failed';
|
|
1539
|
+
sleepTask.completed_at = new Date().toISOString();
|
|
1540
|
+
sleepTask.resolution = 'failed_max_retries';
|
|
1541
|
+
sleepTask.lastError = String(taskErr);
|
|
1542
|
+
sleepTask.retryCount = (sleepTask.retryCount ?? 0) + 1;
|
|
1514
1543
|
if (isExpectedSubagentError(taskErr)) {
|
|
1515
|
-
sleepTask.
|
|
1516
|
-
sleepTask.completed_at = new Date().toISOString();
|
|
1517
|
-
sleepTask.resolution = 'stub_fallback';
|
|
1518
|
-
sleepTask.lastError = String(taskErr);
|
|
1519
|
-
logger?.info?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} completed with stub fallback (subagent unavailable)`);
|
|
1544
|
+
logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} background runtime unavailable: ${String(taskErr)}`);
|
|
1520
1545
|
} else {
|
|
1521
|
-
sleepTask.status = 'failed';
|
|
1522
|
-
sleepTask.completed_at = new Date().toISOString();
|
|
1523
|
-
sleepTask.resolution = 'failed_max_retries';
|
|
1524
|
-
sleepTask.lastError = String(taskErr);
|
|
1525
|
-
sleepTask.retryCount = (sleepTask.retryCount ?? 0) + 1;
|
|
1526
1546
|
logger?.error?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} threw: ${taskErr}`);
|
|
1527
1547
|
}
|
|
1528
1548
|
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { WorkflowStore } from './subagent-workflow/workflow-store.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Monitoring query service for Nocturnal workflows and Trinity stages.
|
|
5
|
+
* Encapsulates all monitoring data queries, keeping logic separate from API routes.
|
|
6
|
+
*/
|
|
7
|
+
export class MonitoringQueryService {
|
|
8
|
+
private readonly workspaceDir: string;
|
|
9
|
+
private readonly store: WorkflowStore;
|
|
10
|
+
|
|
11
|
+
constructor(workspaceDir: string) {
|
|
12
|
+
this.workspaceDir = workspaceDir;
|
|
13
|
+
this.store = new WorkflowStore({ workspaceDir });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
dispose(): void {
|
|
17
|
+
this.store.dispose();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Get workflows with optional filtering and stuck detection.
|
|
22
|
+
* @param filters - Optional state and type filters
|
|
23
|
+
* @returns Workflow list with stuck detection
|
|
24
|
+
*/
|
|
25
|
+
getWorkflows(filters: { state?: string; type?: string } = {}): WorkflowListResponse {
|
|
26
|
+
// Query workflows from WorkflowStore
|
|
27
|
+
let workflows = filters.state
|
|
28
|
+
? this.store.listWorkflows(filters.state)
|
|
29
|
+
: this.store.listWorkflows();
|
|
30
|
+
|
|
31
|
+
// Filter by workflow type if specified
|
|
32
|
+
if (filters.type) {
|
|
33
|
+
workflows = workflows.filter(wf => wf.workflow_type === filters.type);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
const workflowsWithStuckDetection = workflows.map(wf => {
|
|
38
|
+
// Parse metadata for timeout configuration
|
|
39
|
+
const metadata = parseWorkflowMetadata(wf.metadata_json);
|
|
40
|
+
const timeoutMs = metadata.timeoutMs ?? 15 * 60 * 1000; // Default 15 minutes
|
|
41
|
+
|
|
42
|
+
// Check if workflow is stuck (active and exceeded timeout)
|
|
43
|
+
const isStuck = wf.state === 'active' && (now - wf.created_at) > timeoutMs;
|
|
44
|
+
const stuckDuration = isStuck ? now - wf.created_at : null;
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
workflowId: wf.workflow_id,
|
|
48
|
+
type: wf.workflow_type,
|
|
49
|
+
state: isStuck ? 'stuck' : wf.state,
|
|
50
|
+
duration: now - wf.created_at,
|
|
51
|
+
createdAt: new Date(wf.created_at).toISOString(),
|
|
52
|
+
stuckDuration,
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return { workflows: workflowsWithStuckDetection };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get Trinity stage status for a specific workflow.
|
|
61
|
+
* @param workflowId - Workflow ID to query
|
|
62
|
+
* @returns Trinity stage status or null if workflow not found
|
|
63
|
+
*/
|
|
64
|
+
getTrinityStatus(workflowId: string): TrinityStatusResponse | null {
|
|
65
|
+
// Get workflow and validate
|
|
66
|
+
const workflow = this.store.getWorkflow(workflowId);
|
|
67
|
+
if (!workflow) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Fetch stage data
|
|
72
|
+
const events = this.store.getEvents(workflowId);
|
|
73
|
+
const stageOutputs = this.store.getStageOutputs(workflowId);
|
|
74
|
+
|
|
75
|
+
// Define stage types
|
|
76
|
+
const stages = ['dreamer', 'philosopher', 'scribe'] as const;
|
|
77
|
+
|
|
78
|
+
// Compute stage states from events
|
|
79
|
+
const stagesInfo: TrinityStageInfo[] = stages.map(stage => {
|
|
80
|
+
// Find events for this stage
|
|
81
|
+
const startEvent = events.find(e => e.event_type === `trinity_${stage}_start`);
|
|
82
|
+
const completeEvent = events.find(e => e.event_type === `trinity_${stage}_complete`);
|
|
83
|
+
const failedEvent = events.find(e => e.event_type === `trinity_${stage}_failed`);
|
|
84
|
+
|
|
85
|
+
// Determine status
|
|
86
|
+
// eslint-disable-next-line @typescript-eslint/init-declarations
|
|
87
|
+
let status: 'pending' | 'running' | 'completed' | 'failed';
|
|
88
|
+
// eslint-disable-next-line @typescript-eslint/init-declarations
|
|
89
|
+
let reason: string | undefined;
|
|
90
|
+
|
|
91
|
+
if (!startEvent) {
|
|
92
|
+
status = 'pending';
|
|
93
|
+
reason = undefined;
|
|
94
|
+
} else if (failedEvent) {
|
|
95
|
+
status = 'failed';
|
|
96
|
+
({ reason } = failedEvent);
|
|
97
|
+
} else if (completeEvent) {
|
|
98
|
+
status = 'completed';
|
|
99
|
+
reason = undefined;
|
|
100
|
+
} else {
|
|
101
|
+
status = 'running';
|
|
102
|
+
reason = undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Count outputs for this stage
|
|
106
|
+
const outputCount = stageOutputs.filter(so => so.stage === stage).length;
|
|
107
|
+
|
|
108
|
+
// Calculate duration if stage started and completed/failed
|
|
109
|
+
// eslint-disable-next-line @typescript-eslint/init-declarations
|
|
110
|
+
let duration: number | undefined;
|
|
111
|
+
if (startEvent && (completeEvent || failedEvent)) {
|
|
112
|
+
const endEvent = completeEvent || failedEvent;
|
|
113
|
+
if (endEvent) {
|
|
114
|
+
duration = endEvent.created_at - startEvent.created_at;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
stage,
|
|
120
|
+
status,
|
|
121
|
+
reason,
|
|
122
|
+
outputCount,
|
|
123
|
+
duration,
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
workflowId,
|
|
129
|
+
stages: stagesInfo,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Get aggregate health metrics for all Trinity workflows.
|
|
135
|
+
* @returns Aggregate health statistics
|
|
136
|
+
*/
|
|
137
|
+
getTrinityHealth(): TrinityHealthResponse {
|
|
138
|
+
// Get all workflows
|
|
139
|
+
const workflows = this.store.listWorkflows();
|
|
140
|
+
|
|
141
|
+
// Initialize counters
|
|
142
|
+
let totalCalls = 0;
|
|
143
|
+
let totalDuration = 0;
|
|
144
|
+
let failedCalls = 0;
|
|
145
|
+
|
|
146
|
+
// Initialize stage statistics
|
|
147
|
+
const stageStats = {
|
|
148
|
+
dreamer: { total: 0, completed: 0, failed: 0 },
|
|
149
|
+
philosopher: { total: 0, completed: 0, failed: 0 },
|
|
150
|
+
scribe: { total: 0, completed: 0, failed: 0 },
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// Iterate through workflows and aggregate
|
|
154
|
+
for (const workflow of workflows) {
|
|
155
|
+
const events = this.store.getEvents(workflow.workflow_id);
|
|
156
|
+
const isTerminal =
|
|
157
|
+
workflow.state === 'completed'
|
|
158
|
+
|| workflow.state === 'terminal_error'
|
|
159
|
+
|| workflow.state === 'expired'
|
|
160
|
+
|| workflow.state === 'cleanup_pending';
|
|
161
|
+
let workflowFailed = false;
|
|
162
|
+
|
|
163
|
+
// Aggregate stage statistics
|
|
164
|
+
for (const stage of ['dreamer', 'philosopher', 'scribe'] as const) {
|
|
165
|
+
// Check if stage started
|
|
166
|
+
const started = events.some(e => e.event_type === `trinity_${stage}_start`);
|
|
167
|
+
if (started) {
|
|
168
|
+
stageStats[stage].total++;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Check if completed
|
|
172
|
+
const completed = events.some(e => e.event_type === `trinity_${stage}_complete`);
|
|
173
|
+
if (completed) {
|
|
174
|
+
stageStats[stage].completed++;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Check if failed
|
|
178
|
+
const failed = events.some(e => e.event_type === `trinity_${stage}_failed`);
|
|
179
|
+
if (failed) {
|
|
180
|
+
stageStats[stage].failed++;
|
|
181
|
+
workflowFailed = true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Calculate duration for terminal workflows so the aggregate reflects all finished runs.
|
|
186
|
+
if (isTerminal) {
|
|
187
|
+
totalCalls++;
|
|
188
|
+
const duration = workflow.duration_ms ?? (Date.now() - workflow.created_at);
|
|
189
|
+
totalDuration += duration;
|
|
190
|
+
if (workflowFailed || workflow.state === 'terminal_error' || workflow.state === 'expired') {
|
|
191
|
+
failedCalls++;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Calculate derived metrics
|
|
197
|
+
const avgDuration = totalCalls > 0 ? totalDuration / totalCalls : 0;
|
|
198
|
+
const failureRate = totalCalls > 0 ? failedCalls / totalCalls : 0;
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
totalCalls,
|
|
202
|
+
avgDuration: Math.round(avgDuration),
|
|
203
|
+
failureRate: Number(failureRate.toFixed(4)),
|
|
204
|
+
stageStats,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function parseWorkflowMetadata(metadataJson: string): { timeoutMs?: number } {
|
|
210
|
+
try {
|
|
211
|
+
const parsed = JSON.parse(metadataJson) as { timeoutMs?: number };
|
|
212
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
213
|
+
} catch {
|
|
214
|
+
return {};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Response type for workflow listing endpoint.
|
|
220
|
+
*/
|
|
221
|
+
export interface WorkflowListResponse {
|
|
222
|
+
workflows: WorkflowInfo[];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Enriched workflow information with stuck detection.
|
|
227
|
+
*/
|
|
228
|
+
export interface WorkflowInfo {
|
|
229
|
+
workflowId: string;
|
|
230
|
+
type: string;
|
|
231
|
+
state: string;
|
|
232
|
+
duration: number;
|
|
233
|
+
createdAt: string;
|
|
234
|
+
stuckDuration: number | null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Response type for Trinity status endpoint.
|
|
239
|
+
*/
|
|
240
|
+
export interface TrinityStatusResponse {
|
|
241
|
+
workflowId: string;
|
|
242
|
+
stages: TrinityStageInfo[];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Information about a single Trinity stage.
|
|
247
|
+
*/
|
|
248
|
+
export interface TrinityStageInfo {
|
|
249
|
+
stage: 'dreamer' | 'philosopher' | 'scribe';
|
|
250
|
+
status: 'pending' | 'running' | 'completed' | 'failed';
|
|
251
|
+
reason?: string;
|
|
252
|
+
outputCount: number;
|
|
253
|
+
duration?: number;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Response type for Trinity health metrics endpoint.
|
|
258
|
+
*/
|
|
259
|
+
export interface TrinityHealthResponse {
|
|
260
|
+
totalCalls: number;
|
|
261
|
+
avgDuration: number;
|
|
262
|
+
failureRate: number;
|
|
263
|
+
stageStats: {
|
|
264
|
+
dreamer: StageStats;
|
|
265
|
+
philosopher: StageStats;
|
|
266
|
+
scribe: StageStats;
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Per-stage statistics.
|
|
272
|
+
*/
|
|
273
|
+
export interface StageStats {
|
|
274
|
+
total: number;
|
|
275
|
+
completed: number;
|
|
276
|
+
failed: number;
|
|
277
|
+
}
|
|
@@ -1174,7 +1174,15 @@ async function executeNocturnalReflectionWithAdapter(
|
|
|
1174
1174
|
quotaCheckPassed: true,
|
|
1175
1175
|
},
|
|
1176
1176
|
};
|
|
1177
|
-
diagnostics.idle = {
|
|
1177
|
+
diagnostics.idle = {
|
|
1178
|
+
isIdle: true,
|
|
1179
|
+
mostRecentActivityAt: 0,
|
|
1180
|
+
idleForMs: 0,
|
|
1181
|
+
userActiveSessions: 0,
|
|
1182
|
+
abandonedSessionIds: [],
|
|
1183
|
+
trajectoryGuardrailConfirmsIdle: true,
|
|
1184
|
+
reason: 'selector skipped (override provided)',
|
|
1185
|
+
};
|
|
1178
1186
|
} else {
|
|
1179
1187
|
// Normal Selector path
|
|
1180
1188
|
const extractor = createNocturnalTrajectoryExtractor(workspaceDir, stateDir);
|
|
@@ -234,6 +234,7 @@ export class NocturnalWorkflowManager implements WorkflowManager {
|
|
|
234
234
|
// When principleId is provided, we pass it as principleIdOverride to skip Selector.
|
|
235
235
|
// When principleId is missing, Selector will choose a principle from training store.
|
|
236
236
|
this.logger.info(`[PD:NocturnalWorkflow] Calling executeNocturnalReflectionAsync for full pipeline (principleId=${principleId ?? 'auto-select'})`);
|
|
237
|
+
const pipelineStart = Date.now();
|
|
237
238
|
|
|
238
239
|
// #213: Wrap fire-and-forget Promise with .catch() to prevent
|
|
239
240
|
// unhandled promise rejections if anything throws outside the try-catch
|
|
@@ -263,25 +264,32 @@ export class NocturnalWorkflowManager implements WorkflowManager {
|
|
|
263
264
|
);
|
|
264
265
|
|
|
265
266
|
if (result.success) {
|
|
267
|
+
this.logger.info(`[PD:NocturnalWorkflow] [${workflowId}] Pipeline step 4/4: Completed successfully, artifactId=${result.diagnostics?.persistedPath}`);
|
|
268
|
+
this.store.updateWorkflowState(workflowId, 'completed');
|
|
266
269
|
this.store.recordEvent(workflowId, 'nocturnal_completed', null, 'completed', 'Full pipeline completed via executeNocturnalReflectionAsync', {
|
|
267
270
|
artifactId: result.diagnostics?.persistedPath,
|
|
268
271
|
});
|
|
269
272
|
this.completedWorkflows.set(workflowId, Date.now());
|
|
270
273
|
} else {
|
|
271
274
|
const reason = result.noTargetSelected ? 'no_target_selected' : 'validation_failed';
|
|
275
|
+
this.logger.warn(`[PD:NocturnalWorkflow] [${workflowId}] Pipeline failed: reason=${reason}, noTargetSelected=${result.noTargetSelected}, skipReason=${result.skipReason ?? 'none'}, validationFailures=${result.validationFailures?.length ?? 0}`);
|
|
276
|
+
this.store.updateWorkflowState(workflowId, 'terminal_error');
|
|
272
277
|
this.store.recordEvent(workflowId, 'nocturnal_failed', null, 'terminal_error', reason, {
|
|
273
278
|
failures: result.validationFailures,
|
|
274
279
|
skipReason: result.skipReason,
|
|
275
280
|
});
|
|
276
281
|
}
|
|
277
282
|
} catch (err) {
|
|
278
|
-
|
|
283
|
+
const errDuration = Date.now() - pipelineStart;
|
|
284
|
+
this.logger.error(`[PD:NocturnalWorkflow] [${workflowId}] executeNocturnalReflectionAsync threw after ${errDuration}ms: ${String(err)}`);
|
|
285
|
+
this.store.updateWorkflowState(workflowId, 'terminal_error');
|
|
279
286
|
this.store.recordEvent(workflowId, 'nocturnal_failed', null, 'terminal_error', String(err), { workflowId });
|
|
280
287
|
}
|
|
281
288
|
}).catch((err) => {
|
|
282
|
-
// #213: Outer catch
|
|
289
|
+
// #213: Outer catch – catches errors from the fire-and-forget promise
|
|
283
290
|
// itself (e.g., if the async callback throws before entering try).
|
|
284
291
|
this.logger.error(`[PD:NocturnalWorkflow] Unhandled error in async pipeline for ${workflowId}: ${String(err)}`);
|
|
292
|
+
this.store.updateWorkflowState(workflowId, 'terminal_error');
|
|
285
293
|
this.store.recordEvent(workflowId, 'nocturnal_failed', null, 'terminal_error', String(err), { workflowId });
|
|
286
294
|
});
|
|
287
295
|
|
|
@@ -0,0 +1,77 @@
|
|
|
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 { needsMigration } from '../../src/core/principle-tree-migration.js';
|
|
6
|
+
import { safeRmDir } from '../test-utils.js';
|
|
7
|
+
|
|
8
|
+
function writeLedger(stateDir: string, payload: unknown): void {
|
|
9
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
10
|
+
fs.writeFileSync(
|
|
11
|
+
path.join(stateDir, 'principle_training_state.json'),
|
|
12
|
+
JSON.stringify(payload, null, 2),
|
|
13
|
+
'utf8'
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('principle-tree-migration', () => {
|
|
18
|
+
const tempDirs: string[] = [];
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
while (tempDirs.length > 0) {
|
|
22
|
+
const dir = tempDirs.pop();
|
|
23
|
+
if (dir) safeRmDir(dir);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('requires migration when trainingStore and tree.principles counts match but ids differ', () => {
|
|
28
|
+
const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-migration-'));
|
|
29
|
+
const stateDir = path.join(workspaceDir, '.state');
|
|
30
|
+
tempDirs.push(workspaceDir);
|
|
31
|
+
|
|
32
|
+
writeLedger(stateDir, {
|
|
33
|
+
P_001: {
|
|
34
|
+
principleId: 'P_001',
|
|
35
|
+
evaluability: 'manual_only',
|
|
36
|
+
applicableOpportunityCount: 0,
|
|
37
|
+
observedViolationCount: 0,
|
|
38
|
+
complianceRate: 0,
|
|
39
|
+
violationTrend: 0,
|
|
40
|
+
generatedSampleCount: 0,
|
|
41
|
+
approvedSampleCount: 0,
|
|
42
|
+
includedTrainRunIds: [],
|
|
43
|
+
deployedCheckpointIds: [],
|
|
44
|
+
internalizationStatus: 'prompt_only',
|
|
45
|
+
},
|
|
46
|
+
_tree: {
|
|
47
|
+
principles: {
|
|
48
|
+
P_999: {
|
|
49
|
+
id: 'P_999',
|
|
50
|
+
version: 1,
|
|
51
|
+
text: 'Other principle',
|
|
52
|
+
triggerPattern: '',
|
|
53
|
+
action: '',
|
|
54
|
+
status: 'candidate',
|
|
55
|
+
priority: 'P1',
|
|
56
|
+
scope: 'general',
|
|
57
|
+
evaluability: 'manual_only',
|
|
58
|
+
valueScore: 0,
|
|
59
|
+
adherenceRate: 0,
|
|
60
|
+
painPreventedCount: 0,
|
|
61
|
+
derivedFromPainIds: [],
|
|
62
|
+
ruleIds: [],
|
|
63
|
+
conflictsWithPrincipleIds: [],
|
|
64
|
+
createdAt: '2026-04-10T00:00:00.000Z',
|
|
65
|
+
updatedAt: '2026-04-10T00:00:00.000Z',
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
rules: {},
|
|
69
|
+
implementations: {},
|
|
70
|
+
metrics: {},
|
|
71
|
+
lastUpdated: '2026-04-10T00:00:00.000Z',
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(needsMigration(stateDir)).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
|
|
6
|
+
vi.mock('../../src/core/dictionary-service.js', () => ({
|
|
7
|
+
DictionaryService: {
|
|
8
|
+
get: vi.fn(() => ({ flush: vi.fn() })),
|
|
9
|
+
},
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock('../../src/core/session-tracker.js', () => ({
|
|
13
|
+
initPersistence: vi.fn(),
|
|
14
|
+
flushAllSessions: vi.fn(),
|
|
15
|
+
listSessions: vi.fn(() => []),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
const { mockStartWorkflow, mockGetWorkflowDebugSummary } = vi.hoisted(() => ({
|
|
19
|
+
mockStartWorkflow: vi.fn(),
|
|
20
|
+
mockGetWorkflowDebugSummary: vi.fn(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
vi.mock('../../src/service/subagent-workflow/nocturnal-workflow-manager.js', () => ({
|
|
24
|
+
NocturnalWorkflowManager: class {
|
|
25
|
+
startWorkflow = mockStartWorkflow;
|
|
26
|
+
getWorkflowDebugSummary = mockGetWorkflowDebugSummary;
|
|
27
|
+
},
|
|
28
|
+
nocturnalWorkflowSpec: {
|
|
29
|
+
workflowType: 'nocturnal',
|
|
30
|
+
transport: 'runtime_direct',
|
|
31
|
+
timeoutMs: 15 * 60 * 1000,
|
|
32
|
+
ttlMs: 30 * 60 * 1000,
|
|
33
|
+
},
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
const { mockGetNocturnalSessionSnapshot } = vi.hoisted(() => ({
|
|
37
|
+
mockGetNocturnalSessionSnapshot: vi.fn(),
|
|
38
|
+
}));
|
|
39
|
+
vi.mock('../../src/core/nocturnal-trajectory-extractor.js', async () => {
|
|
40
|
+
const actual = await vi.importActual<typeof import('../../src/core/nocturnal-trajectory-extractor.js')>(
|
|
41
|
+
'../../src/core/nocturnal-trajectory-extractor.js'
|
|
42
|
+
);
|
|
43
|
+
return {
|
|
44
|
+
...actual,
|
|
45
|
+
createNocturnalTrajectoryExtractor: vi.fn(() => ({
|
|
46
|
+
getNocturnalSessionSnapshot: mockGetNocturnalSessionSnapshot,
|
|
47
|
+
})),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
import { EvolutionWorkerService } from '../../src/service/evolution-worker.js';
|
|
52
|
+
import { safeRmDir } from '../test-utils.js';
|
|
53
|
+
|
|
54
|
+
function readQueue(stateDir: string) {
|
|
55
|
+
return JSON.parse(fs.readFileSync(path.join(stateDir, 'evolution_queue.json'), 'utf8'));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('EvolutionWorkerService nocturnal hardening', () => {
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
vi.useFakeTimers();
|
|
61
|
+
vi.clearAllMocks();
|
|
62
|
+
EvolutionWorkerService.api = null;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
vi.useRealTimers();
|
|
67
|
+
EvolutionWorkerService.api = null;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('does not start a nocturnal workflow when only an empty fallback snapshot is available', async () => {
|
|
71
|
+
const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-nocturnal-empty-'));
|
|
72
|
+
const stateDir = path.join(workspaceDir, '.state');
|
|
73
|
+
fs.mkdirSync(path.join(stateDir, 'sessions'), { recursive: true });
|
|
74
|
+
fs.mkdirSync(path.join(stateDir, 'logs'), { recursive: true });
|
|
75
|
+
|
|
76
|
+
mockGetNocturnalSessionSnapshot.mockReturnValue(null);
|
|
77
|
+
|
|
78
|
+
fs.writeFileSync(
|
|
79
|
+
path.join(stateDir, 'evolution_queue.json'),
|
|
80
|
+
JSON.stringify([
|
|
81
|
+
{
|
|
82
|
+
id: 'sleep-empty',
|
|
83
|
+
taskKind: 'sleep_reflection',
|
|
84
|
+
priority: 'medium',
|
|
85
|
+
score: 50,
|
|
86
|
+
source: 'nocturnal',
|
|
87
|
+
reason: 'Sleep reflection',
|
|
88
|
+
timestamp: '2026-04-10T00:00:00.000Z',
|
|
89
|
+
enqueued_at: '2026-04-10T00:00:00.000Z',
|
|
90
|
+
status: 'pending',
|
|
91
|
+
retryCount: 0,
|
|
92
|
+
maxRetries: 1,
|
|
93
|
+
recentPainContext: {
|
|
94
|
+
mostRecent: null,
|
|
95
|
+
recentPainCount: 0,
|
|
96
|
+
recentMaxPainScore: 0,
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
], null, 2),
|
|
100
|
+
'utf8'
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
EvolutionWorkerService.start({
|
|
105
|
+
workspaceDir,
|
|
106
|
+
stateDir,
|
|
107
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
108
|
+
config: {},
|
|
109
|
+
} as any);
|
|
110
|
+
|
|
111
|
+
await vi.advanceTimersByTimeAsync(6000);
|
|
112
|
+
|
|
113
|
+
const queue = readQueue(stateDir);
|
|
114
|
+
expect(queue[0].status).toBe('failed');
|
|
115
|
+
expect(queue[0].lastError).toContain('missing_usable_snapshot');
|
|
116
|
+
expect(queue[0].resultRef).toBeFalsy();
|
|
117
|
+
expect(mockStartWorkflow).not.toHaveBeenCalled();
|
|
118
|
+
} finally {
|
|
119
|
+
EvolutionWorkerService.stop({ workspaceDir, stateDir, logger: console } as any);
|
|
120
|
+
safeRmDir(workspaceDir);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('keeps gateway-only background failures as failed instead of completed stub fallback', async () => {
|
|
125
|
+
const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-nocturnal-gateway-'));
|
|
126
|
+
const stateDir = path.join(workspaceDir, '.state');
|
|
127
|
+
fs.mkdirSync(path.join(stateDir, 'sessions'), { recursive: true });
|
|
128
|
+
fs.mkdirSync(path.join(stateDir, 'logs'), { recursive: true });
|
|
129
|
+
|
|
130
|
+
mockGetNocturnalSessionSnapshot.mockReturnValue({
|
|
131
|
+
sessionId: 'sleep-gateway',
|
|
132
|
+
startedAt: '2026-04-10T00:00:00.000Z',
|
|
133
|
+
updatedAt: '2026-04-10T00:01:00.000Z',
|
|
134
|
+
assistantTurns: [],
|
|
135
|
+
userTurns: [],
|
|
136
|
+
toolCalls: [],
|
|
137
|
+
painEvents: [],
|
|
138
|
+
gateBlocks: [],
|
|
139
|
+
stats: {
|
|
140
|
+
totalAssistantTurns: 1,
|
|
141
|
+
totalToolCalls: 1,
|
|
142
|
+
totalPainEvents: 0,
|
|
143
|
+
totalGateBlocks: 0,
|
|
144
|
+
failureCount: 0,
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
mockStartWorkflow.mockResolvedValue({ workflowId: 'wf-1', childSessionKey: 'child-1', state: 'active' });
|
|
148
|
+
mockGetWorkflowDebugSummary.mockResolvedValue({
|
|
149
|
+
state: 'terminal_error',
|
|
150
|
+
metadata: {},
|
|
151
|
+
recentEvents: [
|
|
152
|
+
{
|
|
153
|
+
reason: 'Error: Plugin runtime subagent methods are only available during a gateway request.',
|
|
154
|
+
payload: {},
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
EvolutionWorkerService.api = {
|
|
160
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
161
|
+
runtime: {},
|
|
162
|
+
} as any;
|
|
163
|
+
|
|
164
|
+
fs.writeFileSync(
|
|
165
|
+
path.join(stateDir, 'evolution_queue.json'),
|
|
166
|
+
JSON.stringify([
|
|
167
|
+
{
|
|
168
|
+
id: 'sleep-gateway',
|
|
169
|
+
taskKind: 'sleep_reflection',
|
|
170
|
+
priority: 'medium',
|
|
171
|
+
score: 50,
|
|
172
|
+
source: 'nocturnal',
|
|
173
|
+
reason: 'Sleep reflection',
|
|
174
|
+
timestamp: '2026-04-10T00:00:00.000Z',
|
|
175
|
+
enqueued_at: '2026-04-10T00:00:00.000Z',
|
|
176
|
+
status: 'pending',
|
|
177
|
+
retryCount: 0,
|
|
178
|
+
maxRetries: 1,
|
|
179
|
+
recentPainContext: {
|
|
180
|
+
mostRecent: { score: 0.5, source: 'pain', reason: 'x', timestamp: '2026-04-10T00:00:00.000Z' },
|
|
181
|
+
recentPainCount: 1,
|
|
182
|
+
recentMaxPainScore: 0.5,
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
], null, 2),
|
|
186
|
+
'utf8'
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
EvolutionWorkerService.start({
|
|
191
|
+
workspaceDir,
|
|
192
|
+
stateDir,
|
|
193
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
194
|
+
config: {},
|
|
195
|
+
} as any);
|
|
196
|
+
|
|
197
|
+
await vi.advanceTimersByTimeAsync(6000);
|
|
198
|
+
|
|
199
|
+
const queue = readQueue(stateDir);
|
|
200
|
+
expect(queue[0].status).toBe('failed');
|
|
201
|
+
expect(queue[0].resolution).toBe('failed_max_retries');
|
|
202
|
+
expect(queue[0].lastError).toContain('gateway request');
|
|
203
|
+
} finally {
|
|
204
|
+
EvolutionWorkerService.stop({ workspaceDir, stateDir, logger: console } as any);
|
|
205
|
+
safeRmDir(workspaceDir);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { WorkflowEventRow, WorkflowRow } from '../../src/service/subagent-workflow/types.js';
|
|
3
|
+
|
|
4
|
+
const mockListWorkflows = vi.fn<() => WorkflowRow[]>();
|
|
5
|
+
const mockGetWorkflow = vi.fn<(workflowId: string) => WorkflowRow | null>();
|
|
6
|
+
const mockGetEvents = vi.fn<(workflowId: string) => WorkflowEventRow[]>();
|
|
7
|
+
const mockGetStageOutputs = vi.fn<(workflowId: string) => Array<{ stage: string }>>();
|
|
8
|
+
const mockDispose = vi.fn();
|
|
9
|
+
|
|
10
|
+
vi.mock('../../src/service/subagent-workflow/workflow-store.js', () => ({
|
|
11
|
+
WorkflowStore: class {
|
|
12
|
+
listWorkflows = mockListWorkflows;
|
|
13
|
+
getWorkflow = mockGetWorkflow;
|
|
14
|
+
getEvents = mockGetEvents;
|
|
15
|
+
getStageOutputs = mockGetStageOutputs;
|
|
16
|
+
dispose = mockDispose;
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
import { MonitoringQueryService } from '../../src/service/monitoring-query-service.js';
|
|
21
|
+
|
|
22
|
+
function createWorkflow(overrides: Partial<WorkflowRow> = {}): WorkflowRow {
|
|
23
|
+
return {
|
|
24
|
+
workflow_id: overrides.workflow_id ?? 'wf-1',
|
|
25
|
+
workflow_type: overrides.workflow_type ?? 'nocturnal',
|
|
26
|
+
transport: overrides.transport ?? 'runtime_direct',
|
|
27
|
+
parent_session_id: overrides.parent_session_id ?? 'parent-1',
|
|
28
|
+
child_session_key: overrides.child_session_key ?? 'child-1',
|
|
29
|
+
run_id: overrides.run_id ?? null,
|
|
30
|
+
state: overrides.state ?? 'completed',
|
|
31
|
+
cleanup_state: overrides.cleanup_state ?? 'none',
|
|
32
|
+
created_at: overrides.created_at ?? Date.UTC(2026, 3, 10, 0, 0, 0),
|
|
33
|
+
updated_at: overrides.updated_at ?? Date.UTC(2026, 3, 10, 0, 5, 0),
|
|
34
|
+
last_observed_at: overrides.last_observed_at ?? null,
|
|
35
|
+
duration_ms: overrides.duration_ms ?? 1_000,
|
|
36
|
+
metadata_json: overrides.metadata_json ?? '{}',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function createEvent(
|
|
41
|
+
workflowId: string,
|
|
42
|
+
eventType: string,
|
|
43
|
+
createdAt: number,
|
|
44
|
+
reason = ''
|
|
45
|
+
): WorkflowEventRow {
|
|
46
|
+
return {
|
|
47
|
+
workflow_id: workflowId,
|
|
48
|
+
event_type: eventType,
|
|
49
|
+
from_state: null,
|
|
50
|
+
to_state: 'completed',
|
|
51
|
+
reason,
|
|
52
|
+
payload_json: '{}',
|
|
53
|
+
created_at: createdAt,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('MonitoringQueryService', () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
vi.clearAllMocks();
|
|
60
|
+
mockListWorkflows.mockReturnValue([]);
|
|
61
|
+
mockGetWorkflow.mockReturnValue(null);
|
|
62
|
+
mockGetEvents.mockReturnValue([]);
|
|
63
|
+
mockGetStageOutputs.mockReturnValue([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('ignores malformed workflow metadata when listing workflows', () => {
|
|
67
|
+
mockListWorkflows.mockReturnValue([
|
|
68
|
+
createWorkflow({
|
|
69
|
+
workflow_id: 'wf-malformed',
|
|
70
|
+
metadata_json: '{invalid',
|
|
71
|
+
}),
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
const service = new MonitoringQueryService('/workspace');
|
|
75
|
+
const result = service.getWorkflows();
|
|
76
|
+
|
|
77
|
+
expect(result.workflows).toHaveLength(1);
|
|
78
|
+
expect(result.workflows[0]).toMatchObject({
|
|
79
|
+
workflowId: 'wf-malformed',
|
|
80
|
+
state: 'completed',
|
|
81
|
+
stuckDuration: null,
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('computes failure rate per terminal workflow instead of per failed stage', () => {
|
|
86
|
+
mockListWorkflows.mockReturnValue([
|
|
87
|
+
createWorkflow({ workflow_id: 'wf-complete', state: 'completed', duration_ms: 500 }),
|
|
88
|
+
createWorkflow({ workflow_id: 'wf-failed', state: 'terminal_error', duration_ms: 750 }),
|
|
89
|
+
]);
|
|
90
|
+
mockGetEvents.mockImplementation((workflowId: string) => {
|
|
91
|
+
if (workflowId === 'wf-failed') {
|
|
92
|
+
return [
|
|
93
|
+
createEvent(workflowId, 'trinity_dreamer_start', 1),
|
|
94
|
+
createEvent(workflowId, 'trinity_dreamer_failed', 2, 'dreamer failed'),
|
|
95
|
+
createEvent(workflowId, 'trinity_philosopher_start', 3),
|
|
96
|
+
createEvent(workflowId, 'trinity_philosopher_failed', 4, 'philosopher failed'),
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
return [
|
|
100
|
+
createEvent(workflowId, 'trinity_dreamer_start', 1),
|
|
101
|
+
createEvent(workflowId, 'trinity_dreamer_complete', 2),
|
|
102
|
+
];
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const service = new MonitoringQueryService('/workspace');
|
|
106
|
+
const result = service.getTrinityHealth();
|
|
107
|
+
|
|
108
|
+
expect(result.totalCalls).toBe(2);
|
|
109
|
+
expect(result.failureRate).toBe(0.5);
|
|
110
|
+
expect(result.stageStats.dreamer.failed).toBe(1);
|
|
111
|
+
expect(result.stageStats.philosopher.failed).toBe(1);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
|
|
6
|
+
const { mockExecuteNocturnalReflectionAsync } = vi.hoisted(() => ({
|
|
7
|
+
mockExecuteNocturnalReflectionAsync: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock('../../src/service/nocturnal-service.js', () => ({
|
|
11
|
+
executeNocturnalReflectionAsync: mockExecuteNocturnalReflectionAsync,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock('../../src/utils/subagent-probe.js', () => ({
|
|
15
|
+
isSubagentRuntimeAvailable: vi.fn(() => true),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock('../../src/core/nocturnal-paths.js', () => ({
|
|
19
|
+
resolveNocturnalDir: vi.fn((workspaceDir: string) => path.join(workspaceDir, '.state', 'nocturnal', 'samples')),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import { NocturnalWorkflowManager, nocturnalWorkflowSpec } from '../../src/service/subagent-workflow/nocturnal-workflow-manager.js';
|
|
23
|
+
import { safeRmDir } from '../test-utils.js';
|
|
24
|
+
|
|
25
|
+
describe('NocturnalWorkflowManager runtime hardening', () => {
|
|
26
|
+
let workspaceDir: string;
|
|
27
|
+
let stateDir: string;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.clearAllMocks();
|
|
31
|
+
workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-nocturnal-wf-'));
|
|
32
|
+
stateDir = path.join(workspaceDir, '.state');
|
|
33
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
safeRmDir(workspaceDir);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('marks workflow terminal_error when async pipeline throws a gateway-only runtime error', async () => {
|
|
41
|
+
mockExecuteNocturnalReflectionAsync.mockRejectedValue(
|
|
42
|
+
new Error('Plugin runtime subagent methods are only available during a gateway request.')
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const manager = new NocturnalWorkflowManager({
|
|
46
|
+
workspaceDir,
|
|
47
|
+
stateDir,
|
|
48
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any,
|
|
49
|
+
runtimeAdapter: {} as any,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const handle = await manager.startWorkflow(nocturnalWorkflowSpec, {
|
|
53
|
+
parentSessionId: 'sleep_reflection:test',
|
|
54
|
+
taskInput: {},
|
|
55
|
+
metadata: {
|
|
56
|
+
snapshot: {
|
|
57
|
+
sessionId: 'session-1',
|
|
58
|
+
startedAt: '2026-04-10T00:00:00.000Z',
|
|
59
|
+
updatedAt: '2026-04-10T00:01:00.000Z',
|
|
60
|
+
assistantTurns: [],
|
|
61
|
+
userTurns: [],
|
|
62
|
+
toolCalls: [],
|
|
63
|
+
painEvents: [],
|
|
64
|
+
gateBlocks: [],
|
|
65
|
+
stats: {
|
|
66
|
+
totalAssistantTurns: 1,
|
|
67
|
+
totalToolCalls: 1,
|
|
68
|
+
totalPainEvents: 0,
|
|
69
|
+
totalGateBlocks: 0,
|
|
70
|
+
failureCount: 0,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await Promise.resolve();
|
|
77
|
+
await Promise.resolve();
|
|
78
|
+
|
|
79
|
+
const summary = await manager.getWorkflowDebugSummary(handle.workflowId);
|
|
80
|
+
expect(summary?.state).toBe('terminal_error');
|
|
81
|
+
expect(summary?.recentEvents.some((event) => event.eventType === 'nocturnal_failed')).toBe(true);
|
|
82
|
+
|
|
83
|
+
manager.dispose();
|
|
84
|
+
});
|
|
85
|
+
});
|