principles-disciple 1.32.0 → 1.34.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 (37) hide show
  1. package/openclaw.plugin.json +4 -4
  2. package/package.json +1 -1
  3. package/src/core/correction-cue-learner.ts +203 -0
  4. package/src/core/correction-types.ts +88 -0
  5. package/src/core/evolution-logger.ts +3 -3
  6. package/src/core/init.ts +67 -0
  7. package/src/service/correction-observer-types.ts +58 -0
  8. package/src/service/correction-observer-workflow-manager.ts +218 -0
  9. package/src/service/evolution-worker.ts +172 -146
  10. package/src/service/nocturnal-service.ts +4 -1
  11. package/src/service/subagent-workflow/index.ts +14 -0
  12. package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +3 -1
  13. package/tests/service/evolution-worker.nocturnal.test.ts +14 -1
  14. package/tests/service/evolution-worker.timeout.test.ts +350 -0
  15. package/tests/commands/implementation-lifecycle.test.ts +0 -362
  16. package/tests/core/detection-funnel.test.ts +0 -63
  17. package/tests/core/evolution-e2e.test.ts +0 -58
  18. package/tests/core/evolution-engine-gate-integration.test.ts +0 -543
  19. package/tests/core/evolution-engine.test.ts +0 -562
  20. package/tests/core/evolution-reducer.test.ts +0 -180
  21. package/tests/core/evolution-user-stories.e2e.test.ts +0 -249
  22. package/tests/core/local-worker-routing.test.ts +0 -757
  23. package/tests/core/rule-host.test.ts +0 -389
  24. package/tests/core/trajectory-correction-pain.test.ts +0 -180
  25. package/tests/hooks/gate-edit-verification.test.ts +0 -435
  26. package/tests/hooks/llm.test.ts +0 -308
  27. package/tests/hooks/progressive-trust-gate.test.ts +0 -277
  28. package/tests/hooks/prompt.test.ts +0 -1473
  29. package/tests/index.integration.test.ts +0 -179
  30. package/tests/index.shadow-routing.integration.test.ts +0 -140
  31. package/tests/service/evolution-worker.test.ts +0 -462
  32. package/tests/service/nocturnal-service.test.ts +0 -577
  33. package/tests/service/nocturnal-workflow-manager.test.ts +0 -441
  34. package/tests/tools/critique-prompt.test.ts +0 -260
  35. package/tests/tools/deep-reflect.test.ts +0 -232
  36. package/tests/tools/model-index.test.ts +0 -246
  37. package/tests/ui/app.test.tsx +0 -114
@@ -58,12 +58,19 @@ import { EvolutionWorkerService, readRecentPainContext } from '../../src/service
58
58
  import { WorkspaceContext } from '../../src/core/workspace-context.js';
59
59
  import { handlePdReflect } from '../../src/commands/pd-reflect.js';
60
60
  import { safeRmDir } from '../test-utils.js';
61
+ import * as diagnosticianStore from '../../src/core/diagnostician-task-store.js';
61
62
 
62
63
  // Helper to create a mock API for E2E tests
63
64
  function createMockApi() {
64
65
  return {
65
66
  logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
66
- runtime: { agent: { runEmbeddedPiAgent: vi.fn() } },
67
+ runtime: {
68
+ agent: { runEmbeddedPiAgent: vi.fn() },
69
+ system: {
70
+ requestHeartbeatNow: vi.fn(),
71
+ runHeartbeatOnce: vi.fn()
72
+ }
73
+ },
67
74
  } as any;
68
75
  }
69
76
 
@@ -584,4 +591,10 @@ session_id: pain-session-abc
584
591
  safeRmDir(workspaceDir);
585
592
  }
586
593
  });
594
+
595
+ // === PR #307 Fixes: Pain Diagnosis Timeout & Heartbeat Retry ===
596
+
597
+ // Note: Testing requestHeartbeatNow call directly is complex due to
598
+ // the async nature of checkPainFlag → doEnqueuePainTask → requestHeartbeatNow.
599
+ // The fix is verified via E2E monitoring (PR #307 production verification).
587
600
  });
@@ -0,0 +1,350 @@
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, mockListRecentNocturnalCandidateSessions } = vi.hoisted(() => ({
37
+ mockGetNocturnalSessionSnapshot: vi.fn(),
38
+ mockListRecentNocturnalCandidateSessions: vi.fn(() => []),
39
+ }));
40
+
41
+ vi.mock('../../src/core/nocturnal-trajectory-extractor.js', async () => {
42
+ const actual = await vi.importActual<typeof import('../../src/core/nocturnal-trajectory-extractor.js')>(
43
+ '../../src/core/nocturnal-trajectory-extractor.js'
44
+ );
45
+ return {
46
+ ...actual,
47
+ createNocturnalTrajectoryExtractor: vi.fn(() => ({
48
+ getNocturnalSessionSnapshot: mockGetNocturnalSessionSnapshot,
49
+ listRecentNocturnalCandidateSessions: mockListRecentNocturnalCandidateSessions,
50
+ })),
51
+ };
52
+ });
53
+
54
+ import { EvolutionWorkerService } from '../../src/service/evolution-worker.js';
55
+ import { safeRmDir } from '../test-utils.js';
56
+
57
+ function createMockApi() {
58
+ return {
59
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
60
+ runtime: {
61
+ agent: { runEmbeddedPiAgent: vi.fn() },
62
+ system: {
63
+ requestHeartbeatNow: vi.fn(),
64
+ runHeartbeatOnce: vi.fn(),
65
+ },
66
+ },
67
+ } as any;
68
+ }
69
+
70
+ // Poll every 100ms for fast test execution
71
+ const fastPollConfig = { get: (k: string) => k === 'intervals.worker_poll_ms' ? 100 : undefined };
72
+
73
+ function readQueue(stateDir: string) {
74
+ return JSON.parse(fs.readFileSync(path.join(stateDir, 'evolution_queue.json'), 'utf8'));
75
+ }
76
+
77
+ describe('EvolutionWorkerService timeout mechanisms', () => {
78
+ beforeEach(() => {
79
+ vi.useFakeTimers();
80
+ vi.clearAllMocks();
81
+ EvolutionWorkerService.api = null;
82
+ });
83
+
84
+ afterEach(() => {
85
+ vi.useRealTimers();
86
+ EvolutionWorkerService.api = null;
87
+ });
88
+
89
+ // ── Pain diagnosis timeout (30 min) ──
90
+
91
+ it('times out pain_diagnosis task after 30 minutes → resolution = diagnostician_timeout', async () => {
92
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-timeout-pain-'));
93
+ const stateDir = path.join(workspaceDir, '.state');
94
+ fs.mkdirSync(stateDir, { recursive: true });
95
+
96
+ // Create an in_progress pain_diagnosis task that started 31 minutes ago
97
+ const startedAt = new Date(Date.now() - 31 * 60 * 1000).toISOString();
98
+ fs.writeFileSync(
99
+ path.join(stateDir, 'evolution_queue.json'),
100
+ JSON.stringify([
101
+ {
102
+ id: 'timeout-test-30min',
103
+ taskKind: 'pain_diagnosis',
104
+ priority: 'high',
105
+ score: 90,
106
+ source: 'tool_failure',
107
+ reason: 'Test timeout mechanism',
108
+ timestamp: startedAt,
109
+ enqueued_at: startedAt,
110
+ status: 'in_progress',
111
+ session_id: 'test',
112
+ agent_id: 'main',
113
+ started_at: startedAt,
114
+ assigned_session_key: 'heartbeat:diagnostician:timeout-test-30min',
115
+ retryCount: 0,
116
+ maxRetries: 3,
117
+ task: 'Diagnose systemic pain [ID: timeout-test-30min]',
118
+ },
119
+ ], null, 2),
120
+ 'utf8'
121
+ );
122
+
123
+ const mockApi = createMockApi();
124
+ EvolutionWorkerService.api = mockApi;
125
+
126
+ try {
127
+ EvolutionWorkerService.start({
128
+ workspaceDir,
129
+ stateDir,
130
+ logger: mockApi.logger,
131
+ config: fastPollConfig,
132
+ api: mockApi,
133
+ } as any);
134
+
135
+ // Wait for the worker to process
136
+ await vi.advanceTimersByTimeAsync(5000);
137
+
138
+ const queue = readQueue(stateDir);
139
+ const task = queue.find((t: any) => t.id === 'timeout-test-30min');
140
+
141
+ expect(task.status).toBe('completed');
142
+ expect(task.resolution).toBe('diagnostician_timeout');
143
+ expect(task.completed_at).toBeDefined();
144
+ } finally {
145
+ EvolutionWorkerService.stop!({ workspaceDir, stateDir, logger: console } as any);
146
+ safeRmDir(workspaceDir);
147
+ }
148
+ });
149
+
150
+ it('does not timeout pain_diagnosis task under 30 minutes', async () => {
151
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-no-timeout-'));
152
+ const stateDir = path.join(workspaceDir, '.state');
153
+ fs.mkdirSync(stateDir, { recursive: true });
154
+
155
+ // Create an in_progress pain_diagnosis task that started 10 minutes ago
156
+ const startedAt = new Date(Date.now() - 10 * 60 * 1000).toISOString();
157
+ fs.writeFileSync(
158
+ path.join(stateDir, 'evolution_queue.json'),
159
+ JSON.stringify([
160
+ {
161
+ id: 'no-timeout-10min',
162
+ taskKind: 'pain_diagnosis',
163
+ priority: 'high',
164
+ score: 80,
165
+ source: 'human_intervention',
166
+ reason: 'Should not timeout yet',
167
+ timestamp: startedAt,
168
+ enqueued_at: startedAt,
169
+ status: 'in_progress',
170
+ session_id: 'test',
171
+ agent_id: 'main',
172
+ started_at: startedAt,
173
+ assigned_session_key: 'heartbeat:diagnostician:no-timeout-10min',
174
+ retryCount: 0,
175
+ maxRetries: 3,
176
+ task: 'Diagnose systemic pain [ID: no-timeout-10min]',
177
+ },
178
+ ], null, 2),
179
+ 'utf8'
180
+ );
181
+
182
+ const mockApi = createMockApi();
183
+ EvolutionWorkerService.api = mockApi;
184
+
185
+ try {
186
+ EvolutionWorkerService.start({
187
+ workspaceDir,
188
+ stateDir,
189
+ logger: mockApi.logger,
190
+ config: fastPollConfig,
191
+ api: mockApi,
192
+ } as any);
193
+
194
+ await vi.advanceTimersByTimeAsync(5000);
195
+
196
+ const queue = readQueue(stateDir);
197
+ const task = queue.find((t: any) => t.id === 'no-timeout-10min');
198
+
199
+ // Task should still be in_progress — not yet timed out
200
+ expect(task.status).toBe('in_progress');
201
+ expect(task.resolution).toBeUndefined();
202
+ } finally {
203
+ EvolutionWorkerService.stop!({ workspaceDir, stateDir, logger: console } as any);
204
+ safeRmDir(workspaceDir);
205
+ }
206
+ });
207
+
208
+ // ── Sleep reflection timeout (60 min default) ──
209
+
210
+ it('times out sleep_reflection task after 60 minutes → resolution = failed_max_retries', async () => {
211
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-timeout-sleep-'));
212
+ const stateDir = path.join(workspaceDir, '.state');
213
+ fs.mkdirSync(path.join(stateDir, 'sessions'), { recursive: true });
214
+ fs.mkdirSync(path.join(stateDir, 'logs'), { recursive: true });
215
+
216
+ // Create an in_progress sleep_reflection task that started 61 minutes ago
217
+ const startedAt = new Date(Date.now() - 61 * 60 * 1000).toISOString();
218
+ fs.writeFileSync(
219
+ path.join(stateDir, 'evolution_queue.json'),
220
+ JSON.stringify([
221
+ {
222
+ id: 'sleep-timeout-60min',
223
+ taskKind: 'sleep_reflection',
224
+ priority: 'medium',
225
+ score: 50,
226
+ source: 'nocturnal',
227
+ reason: 'Test sleep reflection timeout',
228
+ timestamp: startedAt,
229
+ enqueued_at: startedAt,
230
+ status: 'in_progress',
231
+ session_id: 'test',
232
+ agent_id: 'main',
233
+ started_at: startedAt,
234
+ resultRef: 'wf-sleep-timeout',
235
+ retryCount: 0,
236
+ maxRetries: 1,
237
+ recentPainContext: {
238
+ mostRecent: null,
239
+ recentPainCount: 0,
240
+ recentMaxPainScore: 0,
241
+ },
242
+ },
243
+ ], null, 2),
244
+ 'utf8'
245
+ );
246
+
247
+ mockStartWorkflow.mockResolvedValue({
248
+ workflowId: 'wf-sleep-timeout',
249
+ childSessionKey: 'child-sleep',
250
+ state: 'terminal_error',
251
+ });
252
+ mockGetWorkflowDebugSummary.mockResolvedValue({
253
+ state: 'terminal_error',
254
+ metadata: {},
255
+ recentEvents: [{ reason: 'Test: simulating stuck sleep reflection', payload: {} }],
256
+ });
257
+
258
+ const mockApi = createMockApi();
259
+ EvolutionWorkerService.api = mockApi;
260
+
261
+ try {
262
+ EvolutionWorkerService.start({
263
+ workspaceDir,
264
+ stateDir,
265
+ logger: mockApi.logger,
266
+ config: fastPollConfig,
267
+ api: mockApi,
268
+ } as any);
269
+
270
+ await vi.advanceTimersByTimeAsync(5000);
271
+
272
+ const queue = readQueue(stateDir);
273
+ const task = queue.find((t: any) => t.id === 'sleep-timeout-60min');
274
+
275
+ expect(task.status).toBe('failed');
276
+ expect(task.resolution).toBe('failed_max_retries');
277
+ expect(task.completed_at).toBeDefined();
278
+ } finally {
279
+ EvolutionWorkerService.stop!({ workspaceDir, stateDir, logger: console } as any);
280
+ safeRmDir(workspaceDir);
281
+ }
282
+ });
283
+
284
+ // ── Report file cleanup on timeout ──
285
+
286
+ it('cleans up .diagnostician_report_*.json file on pain_diagnosis timeout', async () => {
287
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-timeout-cleanup-'));
288
+ const stateDir = path.join(workspaceDir, '.state');
289
+ fs.mkdirSync(stateDir, { recursive: true });
290
+
291
+ // Create a stale diagnostician report file
292
+ const reportPath = path.join(stateDir, '.diagnostician_report_timeout-cleanup.json');
293
+ fs.writeFileSync(reportPath, JSON.stringify({ test: 'stale report' }), 'utf8');
294
+ expect(fs.existsSync(reportPath)).toBe(true);
295
+
296
+ // Create an in_progress pain_diagnosis task that started 31 minutes ago
297
+ const startedAt = new Date(Date.now() - 31 * 60 * 1000).toISOString();
298
+ fs.writeFileSync(
299
+ path.join(stateDir, 'evolution_queue.json'),
300
+ JSON.stringify([
301
+ {
302
+ id: 'timeout-cleanup',
303
+ taskKind: 'pain_diagnosis',
304
+ priority: 'high',
305
+ score: 70,
306
+ source: 'tool_failure',
307
+ reason: 'Test report cleanup on timeout',
308
+ timestamp: startedAt,
309
+ enqueued_at: startedAt,
310
+ status: 'in_progress',
311
+ session_id: 'test',
312
+ agent_id: 'main',
313
+ started_at: startedAt,
314
+ assigned_session_key: 'heartbeat:diagnostician:timeout-cleanup',
315
+ retryCount: 0,
316
+ maxRetries: 3,
317
+ task: 'Diagnose systemic pain [ID: timeout-cleanup]',
318
+ },
319
+ ], null, 2),
320
+ 'utf8'
321
+ );
322
+
323
+ const mockApi = createMockApi();
324
+ EvolutionWorkerService.api = mockApi;
325
+
326
+ try {
327
+ EvolutionWorkerService.start({
328
+ workspaceDir,
329
+ stateDir,
330
+ logger: mockApi.logger,
331
+ config: fastPollConfig,
332
+ api: mockApi,
333
+ } as any);
334
+
335
+ await vi.advanceTimersByTimeAsync(5000);
336
+
337
+ // Verify the report file was cleaned up
338
+ expect(fs.existsSync(reportPath)).toBe(false);
339
+
340
+ // Verify the task was marked as completed with timeout resolution
341
+ const queue = readQueue(stateDir);
342
+ const task = queue.find((t: any) => t.id === 'timeout-cleanup');
343
+ expect(task.status).toBe('completed');
344
+ expect(task.resolution).toBe('diagnostician_timeout');
345
+ } finally {
346
+ EvolutionWorkerService.stop!({ workspaceDir, stateDir, logger: console } as any);
347
+ safeRmDir(workspaceDir);
348
+ }
349
+ });
350
+ });