principles-disciple 1.16.0 → 1.17.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.
Files changed (129) hide show
  1. package/README.md +13 -5
  2. package/openclaw.plugin.json +4 -4
  3. package/package.json +1 -1
  4. package/src/commands/archive-impl.ts +3 -3
  5. package/src/commands/capabilities.ts +1 -1
  6. package/src/commands/context.ts +3 -3
  7. package/src/commands/disable-impl.ts +1 -1
  8. package/src/commands/evolution-status.ts +2 -2
  9. package/src/commands/focus.ts +2 -2
  10. package/src/commands/nocturnal-train.ts +6 -6
  11. package/src/commands/pain.ts +4 -4
  12. package/src/commands/pd-reflect.ts +87 -0
  13. package/src/commands/rollback-impl.ts +4 -4
  14. package/src/commands/rollback.ts +2 -2
  15. package/src/commands/samples.ts +2 -2
  16. package/src/commands/workflow-debug.ts +1 -1
  17. package/src/config/errors.ts +1 -1
  18. package/src/core/adaptive-thresholds.ts +1 -1
  19. package/src/core/code-implementation-storage.ts +2 -2
  20. package/src/core/config.ts +1 -1
  21. package/src/core/diagnostician-task-store.ts +2 -2
  22. package/src/core/empathy-keyword-matcher.ts +3 -3
  23. package/src/core/event-log.ts +5 -5
  24. package/src/core/evolution-engine.ts +4 -4
  25. package/src/core/evolution-logger.ts +1 -1
  26. package/src/core/evolution-reducer.ts +3 -3
  27. package/src/core/evolution-types.ts +5 -5
  28. package/src/core/external-training-contract.ts +1 -1
  29. package/src/core/focus-history.ts +14 -14
  30. package/src/core/hygiene/tracker.ts +1 -1
  31. package/src/core/init.ts +2 -2
  32. package/src/core/model-deployment-registry.ts +2 -2
  33. package/src/core/model-training-registry.ts +2 -2
  34. package/src/core/nocturnal-arbiter.ts +1 -1
  35. package/src/core/nocturnal-artificer.ts +2 -2
  36. package/src/core/nocturnal-candidate-scoring.ts +2 -2
  37. package/src/core/nocturnal-compliance.ts +3 -3
  38. package/src/core/nocturnal-dataset.ts +3 -3
  39. package/src/core/nocturnal-export.ts +4 -4
  40. package/src/core/nocturnal-rule-implementation-validator.ts +1 -1
  41. package/src/core/nocturnal-snapshot-contract.ts +112 -0
  42. package/src/core/nocturnal-trajectory-extractor.ts +7 -5
  43. package/src/core/nocturnal-trinity.ts +27 -28
  44. package/src/core/pain-context-extractor.ts +3 -3
  45. package/src/core/pain.ts +124 -11
  46. package/src/core/path-resolver.ts +4 -4
  47. package/src/core/pd-task-reconciler.ts +10 -10
  48. package/src/core/pd-task-service.ts +1 -1
  49. package/src/core/pd-task-store.ts +1 -1
  50. package/src/core/principle-internalization/deprecated-readiness.ts +1 -1
  51. package/src/core/principle-training-state.ts +2 -2
  52. package/src/core/principle-tree-ledger.ts +7 -7
  53. package/src/core/promotion-gate.ts +9 -9
  54. package/src/core/replay-engine.ts +12 -12
  55. package/src/core/risk-calculator.ts +1 -1
  56. package/src/core/rule-host-types.ts +2 -2
  57. package/src/core/rule-host.ts +5 -5
  58. package/src/core/schema/db-types.ts +1 -1
  59. package/src/core/schema/schema-definitions.ts +1 -1
  60. package/src/core/session-tracker.ts +96 -4
  61. package/src/core/shadow-observation-registry.ts +3 -3
  62. package/src/core/system-logger.ts +2 -2
  63. package/src/core/thinking-os-parser.ts +1 -1
  64. package/src/core/training-program.ts +2 -2
  65. package/src/core/trajectory.ts +8 -8
  66. package/src/core/workspace-context.ts +2 -2
  67. package/src/core/workspace-dir-service.ts +85 -0
  68. package/src/core/workspace-dir-validation.ts +30 -107
  69. package/src/hooks/bash-risk.ts +3 -3
  70. package/src/hooks/edit-verification.ts +4 -4
  71. package/src/hooks/gate-block-helper.ts +4 -4
  72. package/src/hooks/gate.ts +10 -10
  73. package/src/hooks/gfi-gate.ts +7 -7
  74. package/src/hooks/lifecycle.ts +2 -2
  75. package/src/hooks/llm.ts +1 -1
  76. package/src/hooks/pain.ts +25 -5
  77. package/src/hooks/progressive-trust-gate.ts +7 -7
  78. package/src/hooks/prompt.ts +24 -5
  79. package/src/hooks/subagent.ts +2 -2
  80. package/src/hooks/thinking-checkpoint.ts +2 -2
  81. package/src/hooks/trajectory-collector.ts +1 -1
  82. package/src/http/principles-console-route.ts +14 -6
  83. package/src/i18n/commands.ts +4 -0
  84. package/src/index.ts +181 -185
  85. package/src/service/central-health-service.ts +1 -1
  86. package/src/service/central-overview-service.ts +3 -3
  87. package/src/service/evolution-query-service.ts +1 -1
  88. package/src/service/evolution-worker.ts +209 -104
  89. package/src/service/health-query-service.ts +27 -17
  90. package/src/service/monitoring-query-service.ts +3 -3
  91. package/src/service/nocturnal-runtime.ts +4 -4
  92. package/src/service/nocturnal-service.ts +40 -23
  93. package/src/service/nocturnal-target-selector.ts +2 -2
  94. package/src/service/runtime-summary-service.ts +1 -1
  95. package/src/service/subagent-workflow/deep-reflect-workflow-manager.ts +1 -1
  96. package/src/service/subagent-workflow/empathy-observer-workflow-manager.ts +3 -3
  97. package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +16 -13
  98. package/src/service/subagent-workflow/runtime-direct-driver.ts +10 -6
  99. package/src/service/subagent-workflow/types.ts +4 -4
  100. package/src/service/subagent-workflow/workflow-manager-base.ts +5 -5
  101. package/src/service/subagent-workflow/workflow-store.ts +2 -2
  102. package/src/tools/critique-prompt.ts +2 -3
  103. package/src/tools/deep-reflect.ts +17 -16
  104. package/src/tools/model-index.ts +1 -1
  105. package/src/utils/file-lock.ts +1 -1
  106. package/src/utils/io.ts +7 -2
  107. package/src/utils/nlp.ts +1 -1
  108. package/src/utils/plugin-logger.ts +2 -2
  109. package/src/utils/retry.ts +3 -2
  110. package/src/utils/subagent-probe.ts +20 -33
  111. package/templates/langs/en/skills/pd-pain-signal/SKILL.md +8 -7
  112. package/templates/langs/zh/skills/pd-pain-signal/SKILL.md +8 -7
  113. package/templates/pain_settings.json +1 -1
  114. package/tests/build-artifacts.test.ts +4 -58
  115. package/tests/commands/pd-reflect.test.ts +49 -0
  116. package/tests/core/nocturnal-snapshot-contract.test.ts +70 -0
  117. package/tests/core/pain-auto-repair.test.ts +96 -0
  118. package/tests/core/pain-integration.test.ts +483 -0
  119. package/tests/core/pain.test.ts +5 -4
  120. package/tests/core/workspace-dir-service.test.ts +68 -0
  121. package/tests/core/workspace-dir-validation.test.ts +56 -192
  122. package/tests/hooks/pain.test.ts +20 -0
  123. package/tests/http/principles-console-route.test.ts +42 -20
  124. package/tests/integration/empathy-workflow-integration.test.ts +1 -2
  125. package/tests/integration/tool-hooks-workspace-dir.e2e.test.ts +9 -17
  126. package/tests/service/empathy-observer-workflow-manager.test.ts +1 -2
  127. package/tests/service/evolution-worker.nocturnal.test.ts +562 -6
  128. package/tests/service/nocturnal-runtime-hardening.test.ts +33 -0
  129. package/tests/utils/subagent-probe.test.ts +32 -0
@@ -33,8 +33,9 @@ vi.mock('../../src/service/subagent-workflow/nocturnal-workflow-manager.js', ()
33
33
  },
34
34
  }));
35
35
 
36
- const { mockGetNocturnalSessionSnapshot } = vi.hoisted(() => ({
36
+ const { mockGetNocturnalSessionSnapshot, mockListRecentNocturnalCandidateSessions } = vi.hoisted(() => ({
37
37
  mockGetNocturnalSessionSnapshot: vi.fn(),
38
+ mockListRecentNocturnalCandidateSessions: vi.fn(() => []),
38
39
  }));
39
40
  vi.mock('../../src/core/nocturnal-trajectory-extractor.js', async () => {
40
41
  const actual = await vi.importActual<typeof import('../../src/core/nocturnal-trajectory-extractor.js')>(
@@ -44,11 +45,14 @@ vi.mock('../../src/core/nocturnal-trajectory-extractor.js', async () => {
44
45
  ...actual,
45
46
  createNocturnalTrajectoryExtractor: vi.fn(() => ({
46
47
  getNocturnalSessionSnapshot: mockGetNocturnalSessionSnapshot,
48
+ listRecentNocturnalCandidateSessions: mockListRecentNocturnalCandidateSessions,
47
49
  })),
48
50
  };
49
51
  });
50
52
 
51
- import { EvolutionWorkerService } from '../../src/service/evolution-worker.js';
53
+ import { EvolutionWorkerService, readRecentPainContext } from '../../src/service/evolution-worker.js';
54
+ import { WorkspaceContext } from '../../src/core/workspace-context.js';
55
+ import { handlePdReflect } from '../../src/commands/pd-reflect.js';
52
56
  import { safeRmDir } from '../test-utils.js';
53
57
 
54
58
  function readQueue(stateDir: string) {
@@ -112,7 +116,8 @@ describe('EvolutionWorkerService nocturnal hardening', () => {
112
116
 
113
117
  const queue = readQueue(stateDir);
114
118
  expect(queue[0].status).toBe('failed');
115
- expect(queue[0].lastError).toContain('missing_usable_snapshot');
119
+ expect(queue[0].lastError).toContain('invalid_snapshot_ingress');
120
+ expect(queue[0].lastError).toContain('fallback snapshot must contain at least one pain signal');
116
121
  expect(queue[0].resultRef).toBeFalsy();
117
122
  expect(mockStartWorkflow).not.toHaveBeenCalled();
118
123
  } finally {
@@ -121,7 +126,7 @@ describe('EvolutionWorkerService nocturnal hardening', () => {
121
126
  }
122
127
  });
123
128
 
124
- it('keeps gateway-only background failures as failed instead of completed stub fallback', async () => {
129
+ it('uses stub_fallback for expected gateway-only background unavailability', async () => {
125
130
  const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-nocturnal-gateway-'));
126
131
  const stateDir = path.join(workspaceDir, '.state');
127
132
  fs.mkdirSync(path.join(stateDir, 'sessions'), { recursive: true });
@@ -197,12 +202,563 @@ describe('EvolutionWorkerService nocturnal hardening', () => {
197
202
  await vi.advanceTimersByTimeAsync(6000);
198
203
 
199
204
  const queue = readQueue(stateDir);
200
- expect(queue[0].status).toBe('failed');
201
- expect(queue[0].resolution).toBe('failed_max_retries');
205
+ // #237: Expected gateway unavailability → stub_fallback (completed), not failed
206
+ // This is an environment limitation (daemon mode, cron job, etc.), not a real failure
207
+ expect(queue[0].status).toBe('completed');
208
+ expect(queue[0].resolution).toBe('stub_fallback');
202
209
  expect(queue[0].lastError).toContain('gateway request');
203
210
  } finally {
204
211
  EvolutionWorkerService.stop({ workspaceDir, stateDir, logger: console } as any);
205
212
  safeRmDir(workspaceDir);
206
213
  }
207
214
  });
215
+
216
+ it('uses stub_fallback for expected subagent runtime unavailability', async () => {
217
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-nocturnal-runtime-unavailable-'));
218
+ const stateDir = path.join(workspaceDir, '.state');
219
+ fs.mkdirSync(path.join(stateDir, 'sessions'), { recursive: true });
220
+ fs.mkdirSync(path.join(stateDir, 'logs'), { recursive: true });
221
+
222
+ mockGetNocturnalSessionSnapshot.mockReturnValue({
223
+ sessionId: 'sleep-runtime',
224
+ startedAt: '2026-04-10T00:00:00.000Z',
225
+ updatedAt: '2026-04-10T00:01:00.000Z',
226
+ assistantTurns: [],
227
+ userTurns: [],
228
+ toolCalls: [],
229
+ painEvents: [],
230
+ gateBlocks: [],
231
+ stats: {
232
+ totalAssistantTurns: 1,
233
+ totalToolCalls: 1,
234
+ totalPainEvents: 0,
235
+ totalGateBlocks: 0,
236
+ failureCount: 0,
237
+ },
238
+ });
239
+ mockStartWorkflow.mockRejectedValue(new Error('NocturnalWorkflowManager: subagent runtime unavailable'));
240
+
241
+ EvolutionWorkerService.api = {
242
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
243
+ runtime: {},
244
+ } as any;
245
+
246
+ fs.writeFileSync(
247
+ path.join(stateDir, 'evolution_queue.json'),
248
+ JSON.stringify([
249
+ {
250
+ id: 'sleep-runtime',
251
+ taskKind: 'sleep_reflection',
252
+ priority: 'medium',
253
+ score: 50,
254
+ source: 'nocturnal',
255
+ reason: 'Sleep reflection',
256
+ timestamp: '2026-04-10T00:00:00.000Z',
257
+ enqueued_at: '2026-04-10T00:00:00.000Z',
258
+ status: 'pending',
259
+ retryCount: 0,
260
+ maxRetries: 1,
261
+ recentPainContext: {
262
+ mostRecent: { score: 0.5, source: 'pain', reason: 'x', timestamp: '2026-04-10T00:00:00.000Z', sessionId: 'sleep-runtime' },
263
+ recentPainCount: 1,
264
+ recentMaxPainScore: 0.5,
265
+ },
266
+ },
267
+ ], null, 2),
268
+ 'utf8'
269
+ );
270
+
271
+ try {
272
+ EvolutionWorkerService.start({
273
+ workspaceDir,
274
+ stateDir,
275
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
276
+ config: {},
277
+ } as any);
278
+
279
+ await vi.advanceTimersByTimeAsync(6000);
280
+
281
+ const queue = readQueue(stateDir);
282
+ // #237: Expected subagent unavailability → stub_fallback (completed), not failed
283
+ // This is an environment limitation (daemon mode, process isolation, etc.), not a real failure
284
+ expect(queue[0].status).toBe('completed');
285
+ expect(queue[0].resolution).toBe('stub_fallback');
286
+ expect(queue[0].lastError).toContain('subagent runtime unavailable');
287
+ } finally {
288
+ EvolutionWorkerService.stop({ workspaceDir, stateDir, logger: console } as any);
289
+ safeRmDir(workspaceDir);
290
+ }
291
+ });
292
+
293
+ it('extracts session_id from .pain_flag file correctly', async () => {
294
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-pain-session-'));
295
+ const stateDir = path.join(workspaceDir, '.state');
296
+ fs.mkdirSync(stateDir, { recursive: true });
297
+
298
+ // Write a pain flag WITH session_id
299
+ fs.writeFileSync(
300
+ path.join(stateDir, '.pain_flag'),
301
+ `source: test_pain
302
+ score: 80
303
+ reason: test reason
304
+ time: 2026-04-10T00:00:00.000Z
305
+ session_id: explicit-session-from-pain
306
+ `,
307
+ 'utf8'
308
+ );
309
+
310
+ // Create a WorkspaceContext to test the function
311
+ const wctx = WorkspaceContext.fromHookContext({ workspaceDir, stateDir, logger: console } as any);
312
+
313
+ try {
314
+ const context = readRecentPainContext(wctx);
315
+
316
+ // Verify the session_id was extracted from the pain flag file
317
+ expect(context.mostRecent).toBeDefined();
318
+ expect(context.mostRecent.sessionId).toBe('explicit-session-from-pain');
319
+ expect(context.mostRecent.score).toBe(80);
320
+ expect(context.recentPainCount).toBe(1);
321
+ } finally {
322
+ safeRmDir(workspaceDir);
323
+ }
324
+ });
325
+
326
+ it('treats malformed pain flag data as unusable context', async () => {
327
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-pain-invalid-'));
328
+ const stateDir = path.join(workspaceDir, '.state');
329
+ fs.mkdirSync(stateDir, { recursive: true });
330
+
331
+ fs.writeFileSync(
332
+ path.join(stateDir, '.pain_flag'),
333
+ `source: test_pain
334
+ score: 80`,
335
+ 'utf8'
336
+ );
337
+
338
+ const wctx = WorkspaceContext.fromHookContext({ workspaceDir, stateDir, logger: console } as any);
339
+
340
+ try {
341
+ const context = readRecentPainContext(wctx);
342
+ expect(context.mostRecent).toBeNull();
343
+ expect(context.recentPainCount).toBe(0);
344
+ } finally {
345
+ safeRmDir(workspaceDir);
346
+ }
347
+ });
348
+
349
+ it('prioritizes pain signal session ID for snapshot extraction', async () => {
350
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-pain-priority-'));
351
+ const stateDir = path.join(workspaceDir, '.state');
352
+ fs.mkdirSync(stateDir, { recursive: true });
353
+ fs.mkdirSync(path.join(stateDir, 'sessions'), { recursive: true });
354
+ fs.mkdirSync(path.join(stateDir, 'logs'), { recursive: true });
355
+
356
+ // Mock extractor to succeed ONLY for the pain session ID
357
+ mockGetNocturnalSessionSnapshot.mockImplementation((sessionId: string) => {
358
+ if (sessionId === 'pain-session-id') {
359
+ return {
360
+ sessionId: 'pain-session-id',
361
+ startedAt: '2026-04-10T00:00:00.000Z',
362
+ updatedAt: '2026-04-10T00:01:00.000Z',
363
+ assistantTurns: [],
364
+ userTurns: [],
365
+ toolCalls: [],
366
+ painEvents: [],
367
+ gateBlocks: [],
368
+ stats: { totalToolCalls: 10, totalAssistantTurns: 5, failureCount: 2 },
369
+ stats: {
370
+ totalAssistantTurns: 5,
371
+ totalToolCalls: 10,
372
+ totalPainEvents: 0,
373
+ totalGateBlocks: 0,
374
+ failureCount: 2,
375
+ },
376
+ };
377
+ }
378
+ return null;
379
+ });
380
+
381
+ mockStartWorkflow.mockResolvedValue({ workflowId: 'wf-1', childSessionKey: 'child-1', state: 'active' });
382
+ mockGetWorkflowDebugSummary.mockResolvedValue({ state: 'active', metadata: {} });
383
+
384
+ EvolutionWorkerService.api = {
385
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
386
+ runtime: {},
387
+ } as any;
388
+
389
+ // Create a queue with a task that HAS a pain session ID
390
+ const taskWithPainSession = {
391
+ id: 'task-with-pain',
392
+ taskKind: 'sleep_reflection',
393
+ priority: 'medium',
394
+ score: 50,
395
+ source: 'nocturnal',
396
+ reason: 'Sleep reflection',
397
+ timestamp: '2026-04-10T00:00:00.000Z',
398
+ enqueued_at: '2026-04-10T00:00:00.000Z',
399
+ status: 'pending',
400
+ retryCount: 0,
401
+ maxRetries: 1,
402
+ recentPainContext: {
403
+ mostRecent: { sessionId: 'pain-session-id', score: 80, source: 'test', reason: 'r', timestamp: 't' },
404
+ recentPainCount: 1,
405
+ recentMaxPainScore: 80,
406
+ },
407
+ };
408
+
409
+ fs.writeFileSync(
410
+ path.join(stateDir, 'evolution_queue.json'),
411
+ JSON.stringify([taskWithPainSession]),
412
+ 'utf8'
413
+ );
414
+
415
+ try {
416
+ EvolutionWorkerService.start({
417
+ workspaceDir,
418
+ stateDir,
419
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
420
+ config: { get: () => 15000 },
421
+ } as any);
422
+
423
+ // Advance time to process the pending task
424
+ await vi.advanceTimersByTimeAsync(6000);
425
+
426
+ // Verify the extractor was called with the pain session ID first
427
+ expect(mockGetNocturnalSessionSnapshot).toHaveBeenCalledWith('pain-session-id');
428
+
429
+ // Verify workflow started (meaning snapshot was found via pain session ID)
430
+ expect(mockStartWorkflow).toHaveBeenCalled();
431
+ const workflowStartInput = mockStartWorkflow.mock.calls[0][1];
432
+ expect(workflowStartInput.metadata.snapshot.startedAt).toBe('2026-04-10T00:00:00.000Z');
433
+ expect(Array.isArray(workflowStartInput.metadata.snapshot.assistantTurns)).toBe(true);
434
+ expect(Array.isArray(workflowStartInput.metadata.snapshot.toolCalls)).toBe(true);
435
+
436
+ // Verify task status updated
437
+ const queue = readQueue(stateDir);
438
+ expect(queue[0].status).toBe('in_progress');
439
+ expect(queue[0].resultRef).toBe('wf-1');
440
+
441
+ } finally {
442
+ EvolutionWorkerService.stop({ workspaceDir, stateDir, logger: console } as any);
443
+ safeRmDir(workspaceDir);
444
+ }
445
+ });
446
+
447
+ it('does not select fallback sessions newer than the triggering task timestamp', async () => {
448
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-nocturnal-bounded-'));
449
+ const stateDir = path.join(workspaceDir, '.state');
450
+ fs.mkdirSync(stateDir, { recursive: true });
451
+ fs.mkdirSync(path.join(stateDir, 'sessions'), { recursive: true });
452
+ fs.mkdirSync(path.join(stateDir, 'logs'), { recursive: true });
453
+
454
+ mockGetNocturnalSessionSnapshot.mockImplementation((sessionId: string) => {
455
+ if (sessionId === 'older-session') {
456
+ return {
457
+ sessionId: 'older-session',
458
+ startedAt: '2026-04-09T23:00:00.000Z',
459
+ updatedAt: '2026-04-09T23:10:00.000Z',
460
+ assistantTurns: [],
461
+ userTurns: [],
462
+ toolCalls: [],
463
+ painEvents: [],
464
+ gateBlocks: [],
465
+ stats: {
466
+ totalAssistantTurns: 1,
467
+ totalToolCalls: 1,
468
+ totalPainEvents: 0,
469
+ totalGateBlocks: 0,
470
+ failureCount: 1,
471
+ },
472
+ };
473
+ }
474
+ return null;
475
+ });
476
+ mockListRecentNocturnalCandidateSessions.mockReturnValue([
477
+ {
478
+ sessionId: 'newer-session',
479
+ startedAt: '2026-04-10T01:00:00.000Z',
480
+ updatedAt: '2026-04-10T01:10:00.000Z',
481
+ assistantTurnCount: 1,
482
+ toolCallCount: 2,
483
+ painEventCount: 1,
484
+ gateBlockCount: 0,
485
+ failureCount: 1,
486
+ },
487
+ {
488
+ sessionId: 'older-session',
489
+ startedAt: '2026-04-09T23:00:00.000Z',
490
+ updatedAt: '2026-04-09T23:10:00.000Z',
491
+ assistantTurnCount: 1,
492
+ toolCallCount: 2,
493
+ painEventCount: 1,
494
+ gateBlockCount: 0,
495
+ failureCount: 1,
496
+ },
497
+ ]);
498
+ mockStartWorkflow.mockResolvedValue({ workflowId: 'wf-bounded', childSessionKey: 'child-bounded', state: 'active' });
499
+ mockGetWorkflowDebugSummary.mockResolvedValue({ state: 'active', metadata: {} });
500
+
501
+ EvolutionWorkerService.api = {
502
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
503
+ runtime: {},
504
+ } as any;
505
+
506
+ fs.writeFileSync(
507
+ path.join(stateDir, 'evolution_queue.json'),
508
+ JSON.stringify([
509
+ {
510
+ id: 'sleep-bounded',
511
+ taskKind: 'sleep_reflection',
512
+ priority: 'medium',
513
+ score: 50,
514
+ source: 'nocturnal',
515
+ reason: 'Sleep reflection',
516
+ timestamp: '2026-04-10T00:00:00.000Z',
517
+ enqueued_at: '2026-04-10T00:00:00.000Z',
518
+ status: 'pending',
519
+ retryCount: 0,
520
+ maxRetries: 1,
521
+ recentPainContext: {
522
+ mostRecent: null,
523
+ recentPainCount: 0,
524
+ recentMaxPainScore: 0,
525
+ },
526
+ },
527
+ ], null, 2),
528
+ 'utf8'
529
+ );
530
+
531
+ try {
532
+ EvolutionWorkerService.start({
533
+ workspaceDir,
534
+ stateDir,
535
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
536
+ config: { get: () => 15000 },
537
+ } as any);
538
+
539
+ await vi.advanceTimersByTimeAsync(6000);
540
+
541
+ expect(mockListRecentNocturnalCandidateSessions).toHaveBeenCalledWith(
542
+ expect.objectContaining({
543
+ limit: 20,
544
+ minToolCalls: 1,
545
+ dateTo: '2026-04-10T00:00:00.000Z',
546
+ })
547
+ );
548
+ expect(mockGetNocturnalSessionSnapshot).not.toHaveBeenCalledWith('newer-session');
549
+ expect(mockGetNocturnalSessionSnapshot).toHaveBeenCalledWith('older-session');
550
+ } finally {
551
+ EvolutionWorkerService.stop({ workspaceDir, stateDir, logger: console } as any);
552
+ safeRmDir(workspaceDir);
553
+ }
554
+ });
555
+
556
+ // === End-to-End Contract Tests ===
557
+
558
+ it('e2e: pain flag → worker enqueue → session_id is correctly attached to queued task', async () => {
559
+ // This test verifies the contract: when a pain flag with session_id exists,
560
+ // any sleep_reflection task created by the worker MUST carry that session_id
561
+ // in its recentPainContext.mostRecent.sessionId field.
562
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-e2e-pain-enqueue-'));
563
+ const stateDir = path.join(workspaceDir, '.state');
564
+ fs.mkdirSync(stateDir, { recursive: true });
565
+
566
+ // Write a pain flag WITH session_id
567
+ fs.writeFileSync(
568
+ path.join(stateDir, '.pain_flag'),
569
+ `source: tool_failure
570
+ score: 70
571
+ reason: Test pain with session
572
+ time: 2026-04-10T00:00:00.000Z
573
+ session_id: pain-session-abc
574
+ `,
575
+ 'utf8'
576
+ );
577
+
578
+ // Verify the worker's readRecentPainContext extracts the session_id correctly
579
+ const wctx = WorkspaceContext.fromHookContext({ workspaceDir, stateDir, logger: console } as any);
580
+ const painContext = readRecentPainContext(wctx);
581
+
582
+ // Contract: session_id must be extracted from the pain flag
583
+ expect(painContext.mostRecent).toBeDefined();
584
+ expect(painContext.mostRecent.sessionId).toBe('pain-session-abc');
585
+ expect(painContext.mostRecent.score).toBe(70);
586
+ expect(painContext.mostRecent.source).toBe('tool_failure');
587
+
588
+ // Now simulate what the worker does: attach this context to a queued task
589
+ const simulatedTask = {
590
+ id: 'simulated-task',
591
+ taskKind: 'sleep_reflection',
592
+ recentPainContext: painContext,
593
+ };
594
+
595
+ // Verify the contract holds end-to-end
596
+ expect(simulatedTask.recentPainContext.mostRecent.sessionId).toBe('pain-session-abc');
597
+ });
598
+
599
+ it('e2e: /pd-reflect command writes to workspace/.state, never to HOME/.state', async () => {
600
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-e2e-command-writes-'));
601
+ const stateDir = path.join(workspaceDir, '.state');
602
+ fs.mkdirSync(stateDir, { recursive: true });
603
+ fs.mkdirSync(path.join(stateDir, 'sessions'), { recursive: true });
604
+
605
+ // Ensure HOME/.state does NOT have the queue file
606
+ const homeState = path.join(os.homedir(), '.state');
607
+ const homeQueue = path.join(homeState, 'evolution_queue.json');
608
+ const homeExistedBefore = fs.existsSync(homeQueue);
609
+
610
+ try {
611
+ // Execute the command with explicit workspaceDir
612
+ const result = await handlePdReflect.handler({
613
+ workspaceDir,
614
+ channel: 'test',
615
+ isAuthorizedSender: true,
616
+ commandBody: '',
617
+ config: {},
618
+ api: { logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } } as any,
619
+ } as any);
620
+
621
+ // Command should succeed
622
+ expect(result.isError).toBeFalsy();
623
+ expect(result.text).toContain('enqueued');
624
+
625
+ // Queue file should exist in workspace
626
+ const workspaceQueue = path.join(stateDir, 'evolution_queue.json');
627
+ expect(fs.existsSync(workspaceQueue)).toBe(true);
628
+
629
+ // Verify the task is in the workspace queue
630
+ const queue = readQueue(stateDir);
631
+ const manualTasks = queue.filter((t: any) => t.id.startsWith('manual_'));
632
+ expect(manualTasks.length).toBe(1);
633
+ expect(manualTasks[0].taskKind).toBe('sleep_reflection');
634
+
635
+ // HOME/.state/evolution_queue.json should NOT have been created/modified by this command
636
+ if (!homeExistedBefore) {
637
+ expect(fs.existsSync(homeQueue)).toBe(false);
638
+ }
639
+ } finally {
640
+ safeRmDir(workspaceDir);
641
+ }
642
+ });
643
+
644
+ it('e2e: bounded session selection — never picks a session newer than the triggering task', async () => {
645
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-e2e-bounded-'));
646
+ const stateDir = path.join(workspaceDir, '.state');
647
+ fs.mkdirSync(stateDir, { recursive: true });
648
+ fs.mkdirSync(path.join(stateDir, 'sessions'), { recursive: true });
649
+ fs.mkdirSync(path.join(stateDir, 'logs'), { recursive: true });
650
+
651
+ // Mock: only the older session snapshot is available
652
+ mockGetNocturnalSessionSnapshot.mockImplementation((sessionId: string) => {
653
+ if (sessionId === 'older-session-bounded') {
654
+ return {
655
+ sessionId: 'older-session-bounded',
656
+ startedAt: '2026-04-09T23:00:00.000Z',
657
+ updatedAt: '2026-04-09T23:10:00.000Z',
658
+ assistantTurns: [],
659
+ userTurns: [],
660
+ toolCalls: [],
661
+ painEvents: [{ source: 'tool_failure', score: 60 }],
662
+ gateBlocks: [],
663
+ stats: {
664
+ totalAssistantTurns: 3,
665
+ totalToolCalls: 5,
666
+ totalPainEvents: 1,
667
+ totalGateBlocks: 0,
668
+ failureCount: 1,
669
+ },
670
+ };
671
+ }
672
+ return null;
673
+ });
674
+
675
+ // Candidate sessions — one newer, one older than the task trigger time
676
+ mockListRecentNocturnalCandidateSessions.mockReturnValue([
677
+ {
678
+ sessionId: 'newer-unrelated',
679
+ startedAt: '2026-04-10T01:00:00.000Z',
680
+ updatedAt: '2026-04-10T01:10:00.000Z',
681
+ assistantTurnCount: 2,
682
+ toolCallCount: 5,
683
+ painEventCount: 2,
684
+ gateBlockCount: 0,
685
+ failureCount: 1,
686
+ },
687
+ {
688
+ sessionId: 'older-session-bounded',
689
+ startedAt: '2026-04-09T23:00:00.000Z',
690
+ updatedAt: '2026-04-09T23:10:00.000Z',
691
+ assistantTurnCount: 3,
692
+ toolCallCount: 5,
693
+ painEventCount: 1,
694
+ gateBlockCount: 0,
695
+ failureCount: 1,
696
+ },
697
+ ]);
698
+
699
+ mockStartWorkflow.mockResolvedValue({ workflowId: 'wf-bounded', childSessionKey: 'child-bounded', state: 'active' });
700
+ mockGetWorkflowDebugSummary.mockResolvedValue({ state: 'active', metadata: {} });
701
+
702
+ EvolutionWorkerService.api = {
703
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
704
+ runtime: {},
705
+ } as any;
706
+
707
+ // Task triggered at 2026-04-10T00:00:00.000Z — no pain session ID, so it falls back to candidate selection
708
+ fs.writeFileSync(
709
+ path.join(stateDir, 'evolution_queue.json'),
710
+ JSON.stringify([
711
+ {
712
+ id: 'sleep-bounded',
713
+ taskKind: 'sleep_reflection',
714
+ priority: 'medium',
715
+ score: 50,
716
+ source: 'nocturnal',
717
+ reason: 'Sleep reflection',
718
+ timestamp: '2026-04-10T00:00:00.000Z',
719
+ enqueued_at: '2026-04-10T00:00:00.000Z',
720
+ status: 'pending',
721
+ retryCount: 0,
722
+ maxRetries: 1,
723
+ recentPainContext: {
724
+ mostRecent: null,
725
+ recentPainCount: 0,
726
+ recentMaxPainScore: 0,
727
+ },
728
+ },
729
+ ], null, 2),
730
+ 'utf8'
731
+ );
732
+
733
+ try {
734
+ EvolutionWorkerService.start({
735
+ workspaceDir,
736
+ stateDir,
737
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
738
+ config: { get: () => 15000 },
739
+ } as any);
740
+
741
+ await vi.advanceTimersByTimeAsync(6000);
742
+
743
+ // Verify dateTo boundary was passed
744
+ expect(mockListRecentNocturnalCandidateSessions).toHaveBeenCalledWith(
745
+ expect.objectContaining({
746
+ limit: 20,
747
+ minToolCalls: 1,
748
+ dateTo: '2026-04-10T00:00:00.000Z',
749
+ })
750
+ );
751
+
752
+ // Should NOT query the newer session
753
+ expect(mockGetNocturnalSessionSnapshot).not.toHaveBeenCalledWith('newer-unrelated');
754
+ // Should use the older session that passes the time boundary
755
+ expect(mockGetNocturnalSessionSnapshot).toHaveBeenCalledWith('older-session-bounded');
756
+
757
+ // Workflow should have started
758
+ expect(mockStartWorkflow).toHaveBeenCalled();
759
+ } finally {
760
+ EvolutionWorkerService.stop({ workspaceDir, stateDir, logger: console } as any);
761
+ safeRmDir(workspaceDir);
762
+ }
763
+ });
208
764
  });
@@ -82,4 +82,37 @@ describe('NocturnalWorkflowManager runtime hardening', () => {
82
82
 
83
83
  manager.dispose();
84
84
  });
85
+
86
+ it('rejects malformed snapshot ingress before starting the async pipeline', async () => {
87
+ const manager = new NocturnalWorkflowManager({
88
+ workspaceDir,
89
+ stateDir,
90
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any,
91
+ runtimeAdapter: {} as any,
92
+ });
93
+
94
+ const handle = await manager.startWorkflow(nocturnalWorkflowSpec, {
95
+ parentSessionId: 'sleep_reflection:test',
96
+ taskInput: {},
97
+ metadata: {
98
+ snapshot: {
99
+ sessionId: 'session-1',
100
+ stats: {
101
+ totalAssistantTurns: 1,
102
+ totalToolCalls: 1,
103
+ totalPainEvents: 0,
104
+ totalGateBlocks: 0,
105
+ failureCount: 0,
106
+ },
107
+ },
108
+ },
109
+ });
110
+
111
+ const summary = await manager.getWorkflowDebugSummary(handle.workflowId);
112
+ expect(summary?.state).toBe('terminal_error');
113
+ expect(summary?.recentEvents.some((event) => event.eventType === 'nocturnal_failed')).toBe(true);
114
+ expect(mockExecuteNocturnalReflectionAsync).not.toHaveBeenCalled();
115
+
116
+ manager.dispose();
117
+ });
85
118
  });
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ getSubagentRuntimeAvailability,
4
+ isSubagentRuntimeAvailable,
5
+ } from '../../src/utils/subagent-probe.js';
6
+
7
+ describe('subagent-probe', () => {
8
+ it('treats any callable run entrypoint as available', () => {
9
+ const runtime = {
10
+ run() {
11
+ return Promise.resolve({ runId: 'run-1' });
12
+ },
13
+ };
14
+
15
+ expect(getSubagentRuntimeAvailability(runtime)).toEqual({
16
+ available: true,
17
+ reason: 'callable',
18
+ });
19
+ expect(isSubagentRuntimeAvailable(runtime)).toBe(true);
20
+ });
21
+
22
+ it('reports missing runtime and missing run distinctly', () => {
23
+ expect(getSubagentRuntimeAvailability(undefined)).toEqual({
24
+ available: false,
25
+ reason: 'missing_runtime',
26
+ });
27
+ expect(getSubagentRuntimeAvailability({})).toEqual({
28
+ available: false,
29
+ reason: 'missing_run',
30
+ });
31
+ });
32
+ });