principles-disciple 1.33.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
CHANGED
|
@@ -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.
|
|
5
|
+
"version": "1.34.0",
|
|
6
6
|
"skills": [
|
|
7
7
|
"./skills"
|
|
8
8
|
],
|
|
@@ -76,8 +76,8 @@
|
|
|
76
76
|
}
|
|
77
77
|
},
|
|
78
78
|
"buildFingerprint": {
|
|
79
|
-
"gitSha": "
|
|
80
|
-
"bundleMd5": "
|
|
81
|
-
"builtAt": "2026-04-
|
|
79
|
+
"gitSha": "cab0dbd8e6e7",
|
|
80
|
+
"bundleMd5": "1505a7119addd2ee24059f2473cdb1ca",
|
|
81
|
+
"builtAt": "2026-04-14T10:58:07.896Z"
|
|
82
82
|
}
|
|
83
83
|
}
|
package/package.json
CHANGED
|
@@ -260,16 +260,16 @@ export class EvolutionLogger {
|
|
|
260
260
|
logCompleted(params: {
|
|
261
261
|
traceId: string;
|
|
262
262
|
taskId: string;
|
|
263
|
-
resolution: 'marker_detected' | 'auto_completed_timeout' | 'manual' | 'late_marker_principle_created' | 'late_marker_no_principle';
|
|
263
|
+
resolution: 'marker_detected' | 'auto_completed_timeout' | 'manual' | 'late_marker_principle_created' | 'late_marker_no_principle' | 'diagnostician_timeout';
|
|
264
264
|
durationMs?: number;
|
|
265
265
|
principlesGenerated?: number;
|
|
266
266
|
}): void {
|
|
267
267
|
|
|
268
|
-
|
|
268
|
+
|
|
269
269
|
let summary: string;
|
|
270
270
|
if (params.resolution === 'marker_detected' || params.resolution === 'late_marker_principle_created') {
|
|
271
271
|
summary = `任务 ${params.taskId} 完成,已生成 ${params.principlesGenerated || 0} 条原则`;
|
|
272
|
-
} else if (params.resolution === 'auto_completed_timeout' || params.resolution === 'late_marker_no_principle') {
|
|
272
|
+
} else if (params.resolution === 'auto_completed_timeout' || params.resolution === 'diagnostician_timeout' || params.resolution === 'late_marker_no_principle') {
|
|
273
273
|
summary = `任务 ${params.taskId} 超时自动完成`;
|
|
274
274
|
} else {
|
|
275
275
|
summary = `任务 ${params.taskId} 已完成`;
|
|
@@ -56,7 +56,7 @@ interface WatchdogResult {
|
|
|
56
56
|
details: string[];
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
|
|
60
60
|
async function runWorkflowWatchdog(
|
|
61
61
|
wctx: WorkspaceContext,
|
|
62
62
|
api: OpenClawPluginApi | null,
|
|
@@ -98,7 +98,7 @@ async function runWorkflowWatchdog(
|
|
|
98
98
|
|
|
99
99
|
// ── Watchdog helpers (extracted from runWorkflowWatchdog for complexity) ──
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
|
|
102
102
|
async function cleanupStaleWorkflowSession(
|
|
103
103
|
wf: WorkflowRow,
|
|
104
104
|
subagentRuntime: { deleteSession: (opts: { sessionKey: string; deleteTranscript: boolean }) => Promise<void> } | undefined,
|
|
@@ -174,7 +174,7 @@ function runWorkflowWatchdogCheckUncleared(allWorkflows: WorkflowRow[], details:
|
|
|
174
174
|
}
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
|
|
177
|
+
|
|
178
178
|
function runWorkflowWatchdogCheckNocturnal(allWorkflows: WorkflowRow[], details: string[]): void {
|
|
179
179
|
for (const wf of allWorkflows) {
|
|
180
180
|
if (wf.workflow_type !== 'nocturnal' || wf.state !== 'completed') continue;
|
|
@@ -208,7 +208,12 @@ let timeoutId: NodeJS.Timeout | null = null;
|
|
|
208
208
|
* Old queue items (without taskKind) are migrated to pain_diagnosis for compatibility.
|
|
209
209
|
*/
|
|
210
210
|
export type QueueStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'canceled';
|
|
211
|
-
export type TaskResolution = 'marker_detected' | 'auto_completed_timeout' | 'failed_max_retries' | 'runtime_unavailable' | 'canceled' | 'late_marker_principle_created' | 'late_marker_no_principle' | 'stub_fallback' | 'skipped_thin_violation';
|
|
211
|
+
export type TaskResolution = 'marker_detected' | 'auto_completed_timeout' | 'failed_max_retries' | 'runtime_unavailable' | 'canceled' | 'late_marker_principle_created' | 'late_marker_no_principle' | 'stub_fallback' | 'skipped_thin_violation' | 'diagnostician_timeout';
|
|
212
|
+
|
|
213
|
+
/** Timeout for pain_diagnosis tasks (30 min) — separate from sleep_reflection timeout.
|
|
214
|
+
* Pain diagnostics run via HEARTBEAT (main session LLM), not as a subagent.
|
|
215
|
+
* If the agent is persistently busy, we don't want the task to starve indefinitely. */
|
|
216
|
+
const PAIN_DIAGNOSIS_TIMEOUT_MS = 30 * 60 * 1000;
|
|
212
217
|
|
|
213
218
|
/**
|
|
214
219
|
* Recent pain context attached to sleep_reflection tasks.
|
|
@@ -370,7 +375,7 @@ function isSessionAtOrBeforeTriggerTime(
|
|
|
370
375
|
return true;
|
|
371
376
|
}
|
|
372
377
|
|
|
373
|
-
|
|
378
|
+
|
|
374
379
|
function buildFallbackNocturnalSnapshot(
|
|
375
380
|
sleepTask: EvolutionQueueItem,
|
|
376
381
|
extractor?: ReturnType<typeof createNocturnalTrajectoryExtractor> | null,
|
|
@@ -782,7 +787,7 @@ interface ParsedPainValues {
|
|
|
782
787
|
|
|
783
788
|
|
|
784
789
|
|
|
785
|
-
|
|
790
|
+
|
|
786
791
|
async function doEnqueuePainTask(
|
|
787
792
|
wctx: WorkspaceContext, logger: PluginLogger, painFlagPath: string,
|
|
788
793
|
result: WorkerStatusReport['pain_flag'], v: ParsedPainValues,
|
|
@@ -856,7 +861,7 @@ async function doEnqueuePainTask(
|
|
|
856
861
|
return result;
|
|
857
862
|
}
|
|
858
863
|
|
|
859
|
-
|
|
864
|
+
|
|
860
865
|
async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Promise<WorkerStatusReport['pain_flag']> {
|
|
861
866
|
const result: WorkerStatusReport['pain_flag'] = { exists: false, score: null, source: null, enqueued: false, skipped_reason: null };
|
|
862
867
|
try {
|
|
@@ -1031,7 +1036,7 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
|
|
|
1031
1036
|
|
|
1032
1037
|
|
|
1033
1038
|
|
|
1034
|
-
|
|
1039
|
+
|
|
1035
1040
|
async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogger, eventLog: EventLog, api?: OpenClawPluginApi) {
|
|
1036
1041
|
const queuePath = wctx.resolve('EVOLUTION_QUEUE');
|
|
1037
1042
|
if (!fs.existsSync(queuePath)) {
|
|
@@ -1309,8 +1314,8 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1309
1314
|
}
|
|
1310
1315
|
|
|
1311
1316
|
const age = Date.now() - startedAt.getTime();
|
|
1312
|
-
if (age >
|
|
1313
|
-
const timeoutMinutes = Math.round(
|
|
1317
|
+
if (age > PAIN_DIAGNOSIS_TIMEOUT_MS) {
|
|
1318
|
+
const timeoutMinutes = Math.round(PAIN_DIAGNOSIS_TIMEOUT_MS / 60000);
|
|
1314
1319
|
|
|
1315
1320
|
const timeoutCompleteMarker = path.join(wctx.stateDir, `.evolution_complete_${task.id}`);
|
|
1316
1321
|
const timeoutReportPath = path.join(wctx.stateDir, `.diagnostician_report_${task.id}.json`);
|
|
@@ -1358,13 +1363,13 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1358
1363
|
} catch { /* report may not exist, not critical */ }
|
|
1359
1364
|
task.resolution = principleCreated ? 'late_marker_principle_created' : 'late_marker_no_principle';
|
|
1360
1365
|
} else {
|
|
1361
|
-
if (logger) logger.info(`[PD:EvolutionWorker]
|
|
1366
|
+
if (logger) logger.info(`[PD:EvolutionWorker] Pain diagnosis task ${task.id} timed out after ${timeoutMinutes} minutes`);
|
|
1362
1367
|
// #190: Clean up diagnostician report file even on timeout (may have been written late)
|
|
1363
1368
|
try {
|
|
1364
1369
|
const autoTimeoutReportPath = path.join(wctx.stateDir, `.diagnostician_report_${task.id}.json`);
|
|
1365
1370
|
if (fs.existsSync(autoTimeoutReportPath)) fs.unlinkSync(autoTimeoutReportPath);
|
|
1366
1371
|
} catch { /* report may not exist, not critical */ }
|
|
1367
|
-
task.resolution = '
|
|
1372
|
+
task.resolution = 'diagnostician_timeout';
|
|
1368
1373
|
}
|
|
1369
1374
|
|
|
1370
1375
|
// Critical: mark task as completed so it doesn't get re-processed
|
|
@@ -1390,7 +1395,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1390
1395
|
sessionId: task.assigned_session_key || 'heartbeat:diagnostician',
|
|
1391
1396
|
taskId: task.id,
|
|
1392
1397
|
outcome: 'timeout',
|
|
1393
|
-
summary: `
|
|
1398
|
+
summary: `Pain diagnosis task ${task.id} timed out after ${timeoutMinutes} minutes.`
|
|
1394
1399
|
});
|
|
1395
1400
|
queueChanged = true;
|
|
1396
1401
|
}
|
|
@@ -1925,7 +1930,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1925
1930
|
}
|
|
1926
1931
|
|
|
1927
1932
|
|
|
1928
|
-
|
|
1933
|
+
|
|
1929
1934
|
async function processDetectionQueue(wctx: WorkspaceContext, api: OpenClawPluginApi, eventLog: EventLog) {
|
|
1930
1935
|
const {logger} = api;
|
|
1931
1936
|
try {
|
|
@@ -2113,7 +2118,7 @@ export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
|
|
|
2113
2118
|
api: null,
|
|
2114
2119
|
_startedWorkspaces: new Set<string>(),
|
|
2115
2120
|
|
|
2116
|
-
|
|
2121
|
+
|
|
2117
2122
|
start(ctx: OpenClawPluginServiceContext): void {
|
|
2118
2123
|
const workspaceDir = ctx?.workspaceDir;
|
|
2119
2124
|
const logger = ctx?.logger || console;
|
|
@@ -2150,7 +2155,7 @@ export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
|
|
|
2150
2155
|
// Periodic trigger tracking
|
|
2151
2156
|
let heartbeatCounter = 0;
|
|
2152
2157
|
|
|
2153
|
-
|
|
2158
|
+
|
|
2154
2159
|
async function runCycle(): Promise<void> {
|
|
2155
2160
|
const cycleStart = Date.now();
|
|
2156
2161
|
heartbeatCounter++;
|
|
@@ -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
|
+
});
|