principles-disciple 1.41.0 → 1.43.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 (95) hide show
  1. package/.planning/codebase/ARCHITECTURE.md +157 -0
  2. package/.planning/codebase/CONCERNS.md +145 -0
  3. package/.planning/codebase/CONVENTIONS.md +148 -0
  4. package/.planning/codebase/INTEGRATIONS.md +81 -0
  5. package/.planning/codebase/STACK.md +87 -0
  6. package/.planning/codebase/STRUCTURE.md +193 -0
  7. package/.planning/codebase/TESTING.md +243 -0
  8. package/openclaw.plugin.json +1 -1
  9. package/package.json +1 -1
  10. package/src/commands/archive-impl.ts +5 -3
  11. package/src/commands/context.ts +1 -0
  12. package/src/commands/disable-impl.ts +1 -1
  13. package/src/commands/evolution-status.ts +2 -2
  14. package/src/commands/pain.ts +12 -5
  15. package/src/commands/principle-rollback.ts +1 -1
  16. package/src/commands/promote-impl.ts +13 -7
  17. package/src/commands/rollback.ts +10 -4
  18. package/src/commands/samples.ts +1 -1
  19. package/src/commands/thinking-os.ts +1 -0
  20. package/src/commands/workflow-debug.ts +1 -1
  21. package/src/core/config.ts +1 -0
  22. package/src/core/dictionary.ts +1 -0
  23. package/src/core/event-log.ts +8 -6
  24. package/src/core/evolution-types.ts +33 -1
  25. package/src/core/external-training-contract.ts +1 -1
  26. package/src/core/merge-gate-audit.ts +3 -3
  27. package/src/core/nocturnal-arbiter.ts +1 -1
  28. package/src/core/nocturnal-compliance.ts +21 -21
  29. package/src/core/nocturnal-executability.ts +1 -1
  30. package/src/core/nocturnal-reasoning-deriver.ts +4 -4
  31. package/src/core/nocturnal-rule-implementation-validator.ts +1 -1
  32. package/src/core/nocturnal-snapshot-contract.ts +1 -1
  33. package/src/core/pain-context-extractor.ts +2 -2
  34. package/src/core/path-resolver.ts +1 -0
  35. package/src/core/pd-task-reconciler.ts +1 -0
  36. package/src/core/pd-task-service.ts +1 -1
  37. package/src/core/pd-task-store.ts +1 -0
  38. package/src/core/principle-internalization/deprecated-readiness.ts +1 -1
  39. package/src/core/principle-internalization/principle-lifecycle-service.ts +1 -1
  40. package/src/core/principle-training-state.ts +2 -2
  41. package/src/core/principle-tree-migration.ts +1 -1
  42. package/src/core/replay-engine.ts +1 -0
  43. package/src/core/risk-calculator.ts +2 -1
  44. package/src/core/rule-host.ts +1 -1
  45. package/src/core/session-tracker.ts +1 -0
  46. package/src/core/shadow-observation-registry.ts +1 -1
  47. package/src/core/thinking-models.ts +1 -1
  48. package/src/core/thinking-os-parser.ts +1 -1
  49. package/src/core/trajectory.ts +2 -0
  50. package/src/hooks/bash-risk.ts +2 -2
  51. package/src/hooks/edit-verification.ts +3 -3
  52. package/src/hooks/gate.ts +8 -8
  53. package/src/hooks/gfi-gate.ts +2 -2
  54. package/src/hooks/lifecycle-routing.ts +1 -1
  55. package/src/hooks/message-sanitize.ts +18 -5
  56. package/src/hooks/pain.ts +2 -2
  57. package/src/hooks/progressive-trust-gate.ts +3 -3
  58. package/src/hooks/prompt.ts +17 -4
  59. package/src/hooks/subagent.ts +2 -3
  60. package/src/hooks/thinking-checkpoint.ts +1 -1
  61. package/src/http/principles-console-route.ts +21 -4
  62. package/src/service/central-database.ts +3 -2
  63. package/src/service/central-health-service.ts +2 -1
  64. package/src/service/central-overview-service.ts +3 -2
  65. package/src/service/control-ui-query-service.ts +2 -2
  66. package/src/service/event-log-auditor.ts +2 -2
  67. package/src/service/evolution-query-service.ts +1 -1
  68. package/src/service/evolution-worker.ts +96 -370
  69. package/src/service/health-query-service.ts +11 -10
  70. package/src/service/monitoring-query-service.ts +4 -4
  71. package/src/service/nocturnal-target-selector.ts +2 -2
  72. package/src/service/queue-io.ts +375 -0
  73. package/src/service/queue-migration.ts +122 -0
  74. package/src/service/runtime-summary-service.ts +1 -1
  75. package/src/service/sleep-cycle.ts +157 -0
  76. package/src/service/subagent-workflow/empathy-observer-workflow-manager.ts +1 -0
  77. package/src/service/subagent-workflow/runtime-direct-driver.ts +1 -1
  78. package/src/service/subagent-workflow/subagent-error-utils.ts +1 -1
  79. package/src/service/subagent-workflow/workflow-store.ts +3 -2
  80. package/src/service/workflow-watchdog.ts +168 -0
  81. package/src/tools/critique-prompt.ts +1 -1
  82. package/src/tools/deep-reflect.ts +22 -11
  83. package/src/tools/model-index.ts +1 -1
  84. package/src/types/event-payload.ts +80 -0
  85. package/src/types/queue.ts +70 -0
  86. package/src/utils/file-lock.ts +2 -2
  87. package/src/utils/io.ts +11 -3
  88. package/tests/core/evolution-migration.test.ts +325 -1
  89. package/tests/core/queue-purge.test.ts +337 -0
  90. package/tests/fixtures/legacy-queue-v1.json +74 -0
  91. package/tests/queue/async-lock.test.ts +200 -0
  92. package/tests/service/evolution-worker.queue.test.ts +296 -0
  93. package/tests/service/queue-io.test.ts +229 -0
  94. package/tests/service/queue-migration.test.ts +147 -0
  95. package/tests/service/workflow-watchdog.test.ts +372 -0
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Unit tests for workflow-watchdog.ts
3
+ *
4
+ * Tests BUG-01, BUG-02, and BUG-03 behavior.
5
+ */
6
+
7
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import type { WorkflowEventRow, WorkflowRow } from '../../src/service/subagent-workflow/types.js';
9
+ import { runWorkflowWatchdog } from '../../src/service/workflow-watchdog.js';
10
+
11
+ const mockListWorkflows = vi.fn<() => WorkflowRow[]>();
12
+ const mockGetEvents = vi.fn<() => WorkflowEventRow[]>();
13
+ const mockUpdateWorkflowState = vi.fn();
14
+ const mockRecordEvent = vi.fn();
15
+ const mockDispose = vi.fn();
16
+
17
+ vi.mock('../../src/service/subagent-workflow/workflow-store.js', () => ({
18
+ WorkflowStore: class {
19
+ listWorkflows = mockListWorkflows;
20
+ getEvents = mockGetEvents;
21
+ updateWorkflowState = mockUpdateWorkflowState;
22
+ recordEvent = mockRecordEvent;
23
+ dispose = mockDispose;
24
+ },
25
+ }));
26
+
27
+ vi.mock('../../src/service/subagent-workflow/subagent-error-utils.js', () => ({
28
+ isExpectedSubagentError: vi.fn(),
29
+ }));
30
+
31
+ vi.mock('../../src/config/defaults/runtime.js', () => ({
32
+ WORKFLOW_TTL_MS: 5 * 60 * 1000, // 5 minutes
33
+ }));
34
+
35
+ import { isExpectedSubagentError } from '../../src/service/subagent-workflow/subagent-error-utils.js';
36
+
37
+ function createWorkflow(overrides: Partial<WorkflowRow> = {}): WorkflowRow {
38
+ return {
39
+ workflow_id: overrides.workflow_id ?? 'wf-1',
40
+ workflow_type: overrides.workflow_type ?? 'empathy-observer',
41
+ transport: overrides.transport ?? 'runtime_direct',
42
+ parent_session_id: overrides.parent_session_id ?? 'parent-1',
43
+ child_session_key: overrides.child_session_key ?? 'child-session-1',
44
+ run_id: overrides.run_id ?? null,
45
+ state: overrides.state ?? 'active',
46
+ cleanup_state: overrides.cleanup_state ?? 'none',
47
+ created_at: overrides.created_at ?? Date.now() - (1 * 60 * 1000),
48
+ updated_at: overrides.updated_at ?? Date.now(),
49
+ last_observed_at: overrides.last_observed_at ?? null,
50
+ duration_ms: overrides.duration_ms ?? null,
51
+ metadata_json: overrides.metadata_json ?? '{}',
52
+ };
53
+ }
54
+
55
+ function createEvent(workflowId: string, reason = 'unknown'): WorkflowEventRow {
56
+ return {
57
+ workflow_id: workflowId,
58
+ event_type: 'state_change',
59
+ from_state: null,
60
+ to_state: 'active',
61
+ reason,
62
+ payload_json: '{}',
63
+ created_at: Date.now() - (1 * 60 * 1000),
64
+ };
65
+ }
66
+
67
+ describe('runWorkflowWatchdog', () => {
68
+ let mockLogger: { debug?: ReturnType<typeof vi.fn>; info?: ReturnType<typeof vi.fn>; warn?: ReturnType<typeof vi.fn> };
69
+ let mockApi: Parameters<typeof runWorkflowWatchdog>[1];
70
+
71
+ beforeEach(() => {
72
+ vi.clearAllMocks();
73
+ mockListWorkflows.mockReturnValue([]);
74
+ mockGetEvents.mockReturnValue([]);
75
+ mockUpdateWorkflowState.mockReturnValue(undefined);
76
+ mockRecordEvent.mockReturnValue(undefined);
77
+ mockDispose.mockReturnValue(undefined);
78
+ isExpectedSubagentError.mockReturnValue(false);
79
+
80
+ mockLogger = {
81
+ debug: vi.fn(),
82
+ info: vi.fn(),
83
+ warn: vi.fn(),
84
+ };
85
+
86
+ mockApi = {
87
+ runtime: {
88
+ subagent: {
89
+ deleteSession: vi.fn().mockResolvedValue(undefined),
90
+ },
91
+ agent: {
92
+ session: {
93
+ resolveStorePath: vi.fn().mockReturnValue('/tmp/sessions.json'),
94
+ loadSessionStore: vi.fn().mockReturnValue({}),
95
+ saveSessionStore: vi.fn().mockResolvedValue(undefined),
96
+ },
97
+ },
98
+ },
99
+ } as unknown as Parameters<typeof runWorkflowWatchdog>[1];
100
+ });
101
+
102
+ // ── BUG-01: isExpectedSubagentError guard ───────────────────────────────
103
+
104
+ describe('BUG-01: isExpectedSubagentError guard', () => {
105
+ it('skips marking stale workflow as terminal_error when last event is expected subagent error', async () => {
106
+ const staleWorkflowId = 'wf-stale-001';
107
+ const now = Date.now();
108
+
109
+ mockListWorkflows.mockReturnValue([
110
+ createWorkflow({
111
+ workflow_id: staleWorkflowId,
112
+ state: 'active',
113
+ created_at: now - (15 * 60 * 1000), // 15 minutes old (> 2x 5min TTL)
114
+ }),
115
+ ]);
116
+ mockGetEvents.mockReturnValue([
117
+ createEvent(staleWorkflowId, 'subagent_not_available'),
118
+ ]);
119
+ isExpectedSubagentError.mockReturnValue(true);
120
+
121
+ const result = await runWorkflowWatchdog(
122
+ { workspaceDir: '/tmp', stateDir: '/tmp/.state' } as any,
123
+ mockApi,
124
+ mockLogger,
125
+ );
126
+
127
+ expect(mockUpdateWorkflowState).not.toHaveBeenCalled();
128
+ expect(mockLogger.debug).toHaveBeenCalledWith(
129
+ expect.stringContaining('Skipping stale active workflow'),
130
+ );
131
+ expect(result.anomalies).toBe(1);
132
+ });
133
+
134
+ it('marks stale workflow as terminal_error when last event is unexpected error', async () => {
135
+ const staleWorkflowId = 'wf-stale-002';
136
+ const now = Date.now();
137
+
138
+ mockListWorkflows.mockReturnValue([
139
+ createWorkflow({
140
+ workflow_id: staleWorkflowId,
141
+ state: 'active',
142
+ created_at: now - (15 * 60 * 1000),
143
+ }),
144
+ ]);
145
+ mockGetEvents.mockReturnValue([
146
+ createEvent(staleWorkflowId, 'unexpected_crash'),
147
+ ]);
148
+ isExpectedSubagentError.mockReturnValue(false);
149
+
150
+ const result = await runWorkflowWatchdog(
151
+ { workspaceDir: '/tmp', stateDir: '/tmp/.state' } as any,
152
+ mockApi,
153
+ mockLogger,
154
+ );
155
+
156
+ expect(mockUpdateWorkflowState).toHaveBeenCalledWith(staleWorkflowId, 'terminal_error');
157
+ expect(mockRecordEvent).toHaveBeenCalledWith(
158
+ staleWorkflowId,
159
+ 'watchdog_timeout',
160
+ 'active',
161
+ 'terminal_error',
162
+ expect.stringContaining('Stale active'),
163
+ expect.any(Object),
164
+ );
165
+ expect(result.anomalies).toBe(1);
166
+ });
167
+ });
168
+
169
+ // ── BUG-02: Gateway fallback for child session cleanup ──────────────────
170
+
171
+ describe('BUG-02: gateway fallback for child session cleanup', () => {
172
+ it('cleans up child session via agentSession fallback when subagentRuntime is unavailable', async () => {
173
+ const staleWorkflowId = 'wf-stale-003';
174
+ const childSessionKey = 'child-session-003';
175
+ const now = Date.now();
176
+
177
+ mockListWorkflows.mockReturnValue([
178
+ createWorkflow({
179
+ workflow_id: staleWorkflowId,
180
+ child_session_key: childSessionKey,
181
+ state: 'active',
182
+ created_at: now - (15 * 60 * 1000),
183
+ }),
184
+ ]);
185
+ mockGetEvents.mockReturnValue([
186
+ createEvent(staleWorkflowId, 'unexpected_crash'),
187
+ ]);
188
+ isExpectedSubagentError.mockReturnValue(false);
189
+
190
+ const apiWithNoSubagentRuntime = {
191
+ runtime: {
192
+ subagent: null,
193
+ agent: {
194
+ session: {
195
+ resolveStorePath: vi.fn().mockReturnValue('/tmp/sessions.json'),
196
+ loadSessionStore: vi.fn().mockReturnValue({ [childSessionKey.toLowerCase()]: { data: true } }),
197
+ saveSessionStore: vi.fn().mockResolvedValue(undefined),
198
+ },
199
+ },
200
+ },
201
+ } as unknown as Parameters<typeof runWorkflowWatchdog>[1];
202
+
203
+ const result = await runWorkflowWatchdog(
204
+ { workspaceDir: '/tmp', stateDir: '/tmp/.state' } as any,
205
+ apiWithNoSubagentRuntime,
206
+ mockLogger,
207
+ );
208
+
209
+ expect(apiWithNoSubagentRuntime.runtime!.agent!.session!.saveSessionStore).toHaveBeenCalled();
210
+ expect(result.anomalies).toBe(1);
211
+ });
212
+
213
+ it('retries with agentSession fallback after gateway request error', async () => {
214
+ const staleWorkflowId = 'wf-stale-004';
215
+ const childSessionKey = 'child-session-004';
216
+ const now = Date.now();
217
+
218
+ mockListWorkflows.mockReturnValue([
219
+ createWorkflow({
220
+ workflow_id: staleWorkflowId,
221
+ child_session_key: childSessionKey,
222
+ state: 'active',
223
+ created_at: now - (15 * 60 * 1000),
224
+ }),
225
+ ]);
226
+ mockGetEvents.mockReturnValue([
227
+ createEvent(staleWorkflowId, 'unexpected_crash'),
228
+ ]);
229
+ isExpectedSubagentError.mockReturnValue(false);
230
+
231
+ const apiWithFailingSubagent = {
232
+ runtime: {
233
+ subagent: {
234
+ deleteSession: vi.fn().mockRejectedValue(new Error('gateway request failed')),
235
+ },
236
+ agent: {
237
+ session: {
238
+ resolveStorePath: vi.fn().mockReturnValue('/tmp/sessions.json'),
239
+ loadSessionStore: vi.fn().mockReturnValue({ [childSessionKey.toLowerCase()]: { data: true } }),
240
+ saveSessionStore: vi.fn().mockResolvedValue(undefined),
241
+ },
242
+ },
243
+ },
244
+ } as unknown as Parameters<typeof runWorkflowWatchdog>[1];
245
+
246
+ const result = await runWorkflowWatchdog(
247
+ { workspaceDir: '/tmp', stateDir: '/tmp/.state' } as any,
248
+ apiWithFailingSubagent,
249
+ mockLogger,
250
+ );
251
+
252
+ expect(apiWithFailingSubagent.runtime!.agent!.session!.saveSessionStore).toHaveBeenCalled();
253
+ expect(result.anomalies).toBe(1);
254
+ });
255
+ });
256
+
257
+ // ── BUG-03: Nocturnal snapshot validation ─────────────────────────────
258
+
259
+ describe('BUG-03: nocturnal snapshot validation', () => {
260
+ it('detects fallback_snapshot when nocturnal workflow uses pain_context_fallback', async () => {
261
+ const now = Date.now();
262
+
263
+ mockListWorkflows.mockReturnValue([
264
+ createWorkflow({
265
+ workflow_id: 'wf-nocturnal-001',
266
+ workflow_type: 'nocturnal',
267
+ state: 'completed',
268
+ created_at: now - (60 * 60 * 1000),
269
+ metadata_json: JSON.stringify({
270
+ snapshot: {
271
+ _dataSource: 'pain_context_fallback',
272
+ stats: { totalToolCalls: 0, totalGateBlocks: 0, failureCount: 0 },
273
+ },
274
+ }),
275
+ }),
276
+ ]);
277
+
278
+ const result = await runWorkflowWatchdog(
279
+ { workspaceDir: '/tmp', stateDir: '/tmp/.state' } as any,
280
+ mockApi,
281
+ mockLogger,
282
+ );
283
+
284
+ expect(result.details).toContainEqual(
285
+ expect.stringContaining('fallback_snapshot: nocturnal workflow wf-nocturnal-001 uses pain-context fallback'),
286
+ );
287
+ expect(result.details).toContainEqual(
288
+ expect.stringContaining('fallback_snapshot_stats: nocturnal workflow wf-nocturnal-001 has empty fallback stats'),
289
+ );
290
+ });
291
+
292
+ it('does not flag fallback_snapshot_stats when nocturnal workflow has real stats', async () => {
293
+ const now = Date.now();
294
+
295
+ mockListWorkflows.mockReturnValue([
296
+ createWorkflow({
297
+ workflow_id: 'wf-nocturnal-002',
298
+ workflow_type: 'nocturnal',
299
+ state: 'completed',
300
+ created_at: now - (60 * 60 * 1000),
301
+ metadata_json: JSON.stringify({
302
+ snapshot: {
303
+ _dataSource: 'pain_context_fallback',
304
+ stats: { totalToolCalls: 5, totalGateBlocks: 2, failureCount: 1 },
305
+ },
306
+ }),
307
+ }),
308
+ ]);
309
+
310
+ const result = await runWorkflowWatchdog(
311
+ { workspaceDir: '/tmp', stateDir: '/tmp/.state' } as any,
312
+ mockApi,
313
+ mockLogger,
314
+ );
315
+
316
+ expect(result.details).toContainEqual(
317
+ expect.stringContaining('fallback_snapshot: nocturnal workflow wf-nocturnal-002 uses pain-context fallback'),
318
+ );
319
+ expect(result.details).not.toContainEqual(
320
+ expect.stringContaining('fallback_snapshot_stats'),
321
+ );
322
+ });
323
+ });
324
+
325
+ // ── General behavior ───────────────────────────────────────────────────
326
+
327
+ describe('general behavior', () => {
328
+ it('returns anomalies=0 and no details when all workflows are healthy', async () => {
329
+ const now = Date.now();
330
+
331
+ mockListWorkflows.mockReturnValue([
332
+ createWorkflow({
333
+ workflow_id: 'wf-healthy-001',
334
+ state: 'active',
335
+ created_at: now - (1 * 60 * 1000), // 1 minute old — healthy
336
+ }),
337
+ ]);
338
+
339
+ const result = await runWorkflowWatchdog(
340
+ { workspaceDir: '/tmp', stateDir: '/tmp/.state' } as any,
341
+ mockApi,
342
+ mockLogger,
343
+ );
344
+
345
+ expect(result.anomalies).toBe(0);
346
+ expect(result.details).toHaveLength(0);
347
+ });
348
+
349
+ it('handles malformed metadata_json gracefully', async () => {
350
+ const now = Date.now();
351
+
352
+ mockListWorkflows.mockReturnValue([
353
+ createWorkflow({
354
+ workflow_id: 'wf-malformed-001',
355
+ workflow_type: 'nocturnal',
356
+ state: 'completed',
357
+ created_at: now - (60 * 60 * 1000),
358
+ metadata_json: 'not valid json {{{',
359
+ }),
360
+ ]);
361
+
362
+ const result = await runWorkflowWatchdog(
363
+ { workspaceDir: '/tmp', stateDir: '/tmp/.state' } as any,
364
+ mockApi,
365
+ mockLogger,
366
+ );
367
+
368
+ expect(result.anomalies).toBe(1);
369
+ expect(result.details.some((d: string) => d.includes('malformed_metadata'))).toBe(true);
370
+ });
371
+ });
372
+ });