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.
@@ -2,7 +2,7 @@
2
2
  "id": "principles-disciple",
3
3
  "name": "Principles Disciple",
4
4
  "description": "Evolutionary programming agent framework with strategic guardrails and reflection loops.",
5
- "version": "1.14.0",
5
+ "version": "1.15.0",
6
6
  "skills": [
7
7
  "./skills"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "principles-disciple",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -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
- // NOC-14: Use NocturnalWorkflowManager for sleep_reflection tasks
1371
- // Lazy-create manager (needs runtimeAdapter from api)
1372
- // eslint-disable-next-line @typescript-eslint/init-declarations -- assigned in if block, else block continues
1373
- let nocturnalManager: NocturnalWorkflowManager | undefined;
1374
- if (api) {
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
- // Poll-only path: skip workflow start, use existing workflowId
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), // last 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
- // #205: Remove hardcoded 'default' - let NocturnalTargetSelector choose
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
- // #202: Expected subagent unavailability use stub fallback
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.status = 'completed';
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 = { isIdle: true, mostRecentActivityAt: 0, idleForMs: 0, userActiveSessions: 0, abandonedSessionIds: [], trajectoryGuardrailConfirmsIdle: true, reason: 'selector skipped (override provided)' };
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
- this.logger.error(`[PD:NocturnalWorkflow] executeNocturnalReflectionAsync threw: ${String(err)}`);
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 catches errors from the fire-and-forget promise
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
+ });