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.
- package/openclaw.plugin.json +4 -4
- package/package.json +1 -1
- package/src/core/correction-cue-learner.ts +203 -0
- package/src/core/correction-types.ts +88 -0
- package/src/core/evolution-logger.ts +3 -3
- package/src/core/init.ts +67 -0
- package/src/service/correction-observer-types.ts +58 -0
- package/src/service/correction-observer-workflow-manager.ts +218 -0
- package/src/service/evolution-worker.ts +172 -146
- package/src/service/nocturnal-service.ts +4 -1
- package/src/service/subagent-workflow/index.ts +14 -0
- package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +3 -1
- package/tests/service/evolution-worker.nocturnal.test.ts +14 -1
- package/tests/service/evolution-worker.timeout.test.ts +350 -0
- package/tests/commands/implementation-lifecycle.test.ts +0 -362
- package/tests/core/detection-funnel.test.ts +0 -63
- package/tests/core/evolution-e2e.test.ts +0 -58
- package/tests/core/evolution-engine-gate-integration.test.ts +0 -543
- package/tests/core/evolution-engine.test.ts +0 -562
- package/tests/core/evolution-reducer.test.ts +0 -180
- package/tests/core/evolution-user-stories.e2e.test.ts +0 -249
- package/tests/core/local-worker-routing.test.ts +0 -757
- package/tests/core/rule-host.test.ts +0 -389
- package/tests/core/trajectory-correction-pain.test.ts +0 -180
- package/tests/hooks/gate-edit-verification.test.ts +0 -435
- package/tests/hooks/llm.test.ts +0 -308
- package/tests/hooks/progressive-trust-gate.test.ts +0 -277
- package/tests/hooks/prompt.test.ts +0 -1473
- package/tests/index.integration.test.ts +0 -179
- package/tests/index.shadow-routing.integration.test.ts +0 -140
- package/tests/service/evolution-worker.test.ts +0 -462
- package/tests/service/nocturnal-service.test.ts +0 -577
- package/tests/service/nocturnal-workflow-manager.test.ts +0 -441
- package/tests/tools/critique-prompt.test.ts +0 -260
- package/tests/tools/deep-reflect.test.ts +0 -232
- package/tests/tools/model-index.test.ts +0 -246
- 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: {
|
|
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
|
+
});
|