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.
- package/README.md +13 -5
- package/openclaw.plugin.json +4 -4
- package/package.json +1 -1
- package/src/commands/archive-impl.ts +3 -3
- package/src/commands/capabilities.ts +1 -1
- package/src/commands/context.ts +3 -3
- package/src/commands/disable-impl.ts +1 -1
- package/src/commands/evolution-status.ts +2 -2
- package/src/commands/focus.ts +2 -2
- package/src/commands/nocturnal-train.ts +6 -6
- package/src/commands/pain.ts +4 -4
- package/src/commands/pd-reflect.ts +87 -0
- package/src/commands/rollback-impl.ts +4 -4
- package/src/commands/rollback.ts +2 -2
- package/src/commands/samples.ts +2 -2
- package/src/commands/workflow-debug.ts +1 -1
- package/src/config/errors.ts +1 -1
- package/src/core/adaptive-thresholds.ts +1 -1
- package/src/core/code-implementation-storage.ts +2 -2
- package/src/core/config.ts +1 -1
- package/src/core/diagnostician-task-store.ts +2 -2
- package/src/core/empathy-keyword-matcher.ts +3 -3
- package/src/core/event-log.ts +5 -5
- package/src/core/evolution-engine.ts +4 -4
- package/src/core/evolution-logger.ts +1 -1
- package/src/core/evolution-reducer.ts +3 -3
- package/src/core/evolution-types.ts +5 -5
- package/src/core/external-training-contract.ts +1 -1
- package/src/core/focus-history.ts +14 -14
- package/src/core/hygiene/tracker.ts +1 -1
- package/src/core/init.ts +2 -2
- package/src/core/model-deployment-registry.ts +2 -2
- package/src/core/model-training-registry.ts +2 -2
- package/src/core/nocturnal-arbiter.ts +1 -1
- package/src/core/nocturnal-artificer.ts +2 -2
- package/src/core/nocturnal-candidate-scoring.ts +2 -2
- package/src/core/nocturnal-compliance.ts +3 -3
- package/src/core/nocturnal-dataset.ts +3 -3
- package/src/core/nocturnal-export.ts +4 -4
- package/src/core/nocturnal-rule-implementation-validator.ts +1 -1
- package/src/core/nocturnal-snapshot-contract.ts +112 -0
- package/src/core/nocturnal-trajectory-extractor.ts +7 -5
- package/src/core/nocturnal-trinity.ts +27 -28
- package/src/core/pain-context-extractor.ts +3 -3
- package/src/core/pain.ts +124 -11
- package/src/core/path-resolver.ts +4 -4
- package/src/core/pd-task-reconciler.ts +10 -10
- package/src/core/pd-task-service.ts +1 -1
- package/src/core/pd-task-store.ts +1 -1
- package/src/core/principle-internalization/deprecated-readiness.ts +1 -1
- package/src/core/principle-training-state.ts +2 -2
- package/src/core/principle-tree-ledger.ts +7 -7
- package/src/core/promotion-gate.ts +9 -9
- package/src/core/replay-engine.ts +12 -12
- package/src/core/risk-calculator.ts +1 -1
- package/src/core/rule-host-types.ts +2 -2
- package/src/core/rule-host.ts +5 -5
- package/src/core/schema/db-types.ts +1 -1
- package/src/core/schema/schema-definitions.ts +1 -1
- package/src/core/session-tracker.ts +96 -4
- package/src/core/shadow-observation-registry.ts +3 -3
- package/src/core/system-logger.ts +2 -2
- package/src/core/thinking-os-parser.ts +1 -1
- package/src/core/training-program.ts +2 -2
- package/src/core/trajectory.ts +8 -8
- package/src/core/workspace-context.ts +2 -2
- package/src/core/workspace-dir-service.ts +85 -0
- package/src/core/workspace-dir-validation.ts +30 -107
- package/src/hooks/bash-risk.ts +3 -3
- package/src/hooks/edit-verification.ts +4 -4
- package/src/hooks/gate-block-helper.ts +4 -4
- package/src/hooks/gate.ts +10 -10
- package/src/hooks/gfi-gate.ts +7 -7
- package/src/hooks/lifecycle.ts +2 -2
- package/src/hooks/llm.ts +1 -1
- package/src/hooks/pain.ts +25 -5
- package/src/hooks/progressive-trust-gate.ts +7 -7
- package/src/hooks/prompt.ts +24 -5
- package/src/hooks/subagent.ts +2 -2
- package/src/hooks/thinking-checkpoint.ts +2 -2
- package/src/hooks/trajectory-collector.ts +1 -1
- package/src/http/principles-console-route.ts +14 -6
- package/src/i18n/commands.ts +4 -0
- package/src/index.ts +181 -185
- package/src/service/central-health-service.ts +1 -1
- package/src/service/central-overview-service.ts +3 -3
- package/src/service/evolution-query-service.ts +1 -1
- package/src/service/evolution-worker.ts +209 -104
- package/src/service/health-query-service.ts +27 -17
- package/src/service/monitoring-query-service.ts +3 -3
- package/src/service/nocturnal-runtime.ts +4 -4
- package/src/service/nocturnal-service.ts +40 -23
- package/src/service/nocturnal-target-selector.ts +2 -2
- package/src/service/runtime-summary-service.ts +1 -1
- package/src/service/subagent-workflow/deep-reflect-workflow-manager.ts +1 -1
- package/src/service/subagent-workflow/empathy-observer-workflow-manager.ts +3 -3
- package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +16 -13
- package/src/service/subagent-workflow/runtime-direct-driver.ts +10 -6
- package/src/service/subagent-workflow/types.ts +4 -4
- package/src/service/subagent-workflow/workflow-manager-base.ts +5 -5
- package/src/service/subagent-workflow/workflow-store.ts +2 -2
- package/src/tools/critique-prompt.ts +2 -3
- package/src/tools/deep-reflect.ts +17 -16
- package/src/tools/model-index.ts +1 -1
- package/src/utils/file-lock.ts +1 -1
- package/src/utils/io.ts +7 -2
- package/src/utils/nlp.ts +1 -1
- package/src/utils/plugin-logger.ts +2 -2
- package/src/utils/retry.ts +3 -2
- package/src/utils/subagent-probe.ts +20 -33
- package/templates/langs/en/skills/pd-pain-signal/SKILL.md +8 -7
- package/templates/langs/zh/skills/pd-pain-signal/SKILL.md +8 -7
- package/templates/pain_settings.json +1 -1
- package/tests/build-artifacts.test.ts +4 -58
- package/tests/commands/pd-reflect.test.ts +49 -0
- package/tests/core/nocturnal-snapshot-contract.test.ts +70 -0
- package/tests/core/pain-auto-repair.test.ts +96 -0
- package/tests/core/pain-integration.test.ts +483 -0
- package/tests/core/pain.test.ts +5 -4
- package/tests/core/workspace-dir-service.test.ts +68 -0
- package/tests/core/workspace-dir-validation.test.ts +56 -192
- package/tests/hooks/pain.test.ts +20 -0
- package/tests/http/principles-console-route.test.ts +42 -20
- package/tests/integration/empathy-workflow-integration.test.ts +1 -2
- package/tests/integration/tool-hooks-workspace-dir.e2e.test.ts +9 -17
- package/tests/service/empathy-observer-workflow-manager.test.ts +1 -2
- package/tests/service/evolution-worker.nocturnal.test.ts +562 -6
- package/tests/service/nocturnal-runtime-hardening.test.ts +33 -0
- 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('
|
|
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('
|
|
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
|
-
|
|
201
|
-
|
|
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
|
+
});
|