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.
- package/.planning/codebase/ARCHITECTURE.md +157 -0
- package/.planning/codebase/CONCERNS.md +145 -0
- package/.planning/codebase/CONVENTIONS.md +148 -0
- package/.planning/codebase/INTEGRATIONS.md +81 -0
- package/.planning/codebase/STACK.md +87 -0
- package/.planning/codebase/STRUCTURE.md +193 -0
- package/.planning/codebase/TESTING.md +243 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/commands/archive-impl.ts +5 -3
- package/src/commands/context.ts +1 -0
- package/src/commands/disable-impl.ts +1 -1
- package/src/commands/evolution-status.ts +2 -2
- package/src/commands/pain.ts +12 -5
- package/src/commands/principle-rollback.ts +1 -1
- package/src/commands/promote-impl.ts +13 -7
- package/src/commands/rollback.ts +10 -4
- package/src/commands/samples.ts +1 -1
- package/src/commands/thinking-os.ts +1 -0
- package/src/commands/workflow-debug.ts +1 -1
- package/src/core/config.ts +1 -0
- package/src/core/dictionary.ts +1 -0
- package/src/core/event-log.ts +8 -6
- package/src/core/evolution-types.ts +33 -1
- package/src/core/external-training-contract.ts +1 -1
- package/src/core/merge-gate-audit.ts +3 -3
- package/src/core/nocturnal-arbiter.ts +1 -1
- package/src/core/nocturnal-compliance.ts +21 -21
- package/src/core/nocturnal-executability.ts +1 -1
- package/src/core/nocturnal-reasoning-deriver.ts +4 -4
- package/src/core/nocturnal-rule-implementation-validator.ts +1 -1
- package/src/core/nocturnal-snapshot-contract.ts +1 -1
- package/src/core/pain-context-extractor.ts +2 -2
- package/src/core/path-resolver.ts +1 -0
- package/src/core/pd-task-reconciler.ts +1 -0
- package/src/core/pd-task-service.ts +1 -1
- package/src/core/pd-task-store.ts +1 -0
- package/src/core/principle-internalization/deprecated-readiness.ts +1 -1
- package/src/core/principle-internalization/principle-lifecycle-service.ts +1 -1
- package/src/core/principle-training-state.ts +2 -2
- package/src/core/principle-tree-migration.ts +1 -1
- package/src/core/replay-engine.ts +1 -0
- package/src/core/risk-calculator.ts +2 -1
- package/src/core/rule-host.ts +1 -1
- package/src/core/session-tracker.ts +1 -0
- package/src/core/shadow-observation-registry.ts +1 -1
- package/src/core/thinking-models.ts +1 -1
- package/src/core/thinking-os-parser.ts +1 -1
- package/src/core/trajectory.ts +2 -0
- package/src/hooks/bash-risk.ts +2 -2
- package/src/hooks/edit-verification.ts +3 -3
- package/src/hooks/gate.ts +8 -8
- package/src/hooks/gfi-gate.ts +2 -2
- package/src/hooks/lifecycle-routing.ts +1 -1
- package/src/hooks/message-sanitize.ts +18 -5
- package/src/hooks/pain.ts +2 -2
- package/src/hooks/progressive-trust-gate.ts +3 -3
- package/src/hooks/prompt.ts +17 -4
- package/src/hooks/subagent.ts +2 -3
- package/src/hooks/thinking-checkpoint.ts +1 -1
- package/src/http/principles-console-route.ts +21 -4
- package/src/service/central-database.ts +3 -2
- package/src/service/central-health-service.ts +2 -1
- package/src/service/central-overview-service.ts +3 -2
- package/src/service/control-ui-query-service.ts +2 -2
- package/src/service/event-log-auditor.ts +2 -2
- package/src/service/evolution-query-service.ts +1 -1
- package/src/service/evolution-worker.ts +96 -370
- package/src/service/health-query-service.ts +11 -10
- package/src/service/monitoring-query-service.ts +4 -4
- package/src/service/nocturnal-target-selector.ts +2 -2
- package/src/service/queue-io.ts +375 -0
- package/src/service/queue-migration.ts +122 -0
- package/src/service/runtime-summary-service.ts +1 -1
- package/src/service/sleep-cycle.ts +157 -0
- package/src/service/subagent-workflow/empathy-observer-workflow-manager.ts +1 -0
- package/src/service/subagent-workflow/runtime-direct-driver.ts +1 -1
- package/src/service/subagent-workflow/subagent-error-utils.ts +1 -1
- package/src/service/subagent-workflow/workflow-store.ts +3 -2
- package/src/service/workflow-watchdog.ts +168 -0
- package/src/tools/critique-prompt.ts +1 -1
- package/src/tools/deep-reflect.ts +22 -11
- package/src/tools/model-index.ts +1 -1
- package/src/types/event-payload.ts +80 -0
- package/src/types/queue.ts +70 -0
- package/src/utils/file-lock.ts +2 -2
- package/src/utils/io.ts +11 -3
- package/tests/core/evolution-migration.test.ts +325 -1
- package/tests/core/queue-purge.test.ts +337 -0
- package/tests/fixtures/legacy-queue-v1.json +74 -0
- package/tests/queue/async-lock.test.ts +200 -0
- package/tests/service/evolution-worker.queue.test.ts +296 -0
- package/tests/service/queue-io.test.ts +229 -0
- package/tests/service/queue-migration.test.ts +147 -0
- package/tests/service/workflow-watchdog.test.ts +372 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "legacy-pain-001",
|
|
4
|
+
"task": "Diagnose tool_failure pain from write operation",
|
|
5
|
+
"score": 78,
|
|
6
|
+
"source": "tool_failure",
|
|
7
|
+
"reason": "Tool write failed on test.md. Error: EACCES permission denied",
|
|
8
|
+
"timestamp": "2026-04-10T08:30:00.000Z",
|
|
9
|
+
"enqueued_at": "2026-04-10T08:30:05.000Z",
|
|
10
|
+
"started_at": null,
|
|
11
|
+
"completed_at": null,
|
|
12
|
+
"assigned_session_key": null,
|
|
13
|
+
"trigger_text_preview": "Tool write failed on test.md",
|
|
14
|
+
"status": "pending",
|
|
15
|
+
"resolution": null,
|
|
16
|
+
"session_id": null,
|
|
17
|
+
"agent_id": null,
|
|
18
|
+
"traceId": "trace-legacy-001"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "legacy-pain-002",
|
|
22
|
+
"task": "Diagnose runtime_unavailable pain from agent session",
|
|
23
|
+
"score": 65,
|
|
24
|
+
"source": "runtime_unavailable",
|
|
25
|
+
"reason": "Agent session timed out after 300s inactivity window",
|
|
26
|
+
"timestamp": "2026-04-11T14:22:00.000Z",
|
|
27
|
+
"enqueued_at": "2026-04-11T14:22:10.000Z",
|
|
28
|
+
"started_at": "2026-04-11T14:25:00.000Z",
|
|
29
|
+
"completed_at": null,
|
|
30
|
+
"assigned_session_key": "session-key-abc123",
|
|
31
|
+
"trigger_text_preview": "Agent session timed out",
|
|
32
|
+
"status": "in_progress",
|
|
33
|
+
"resolution": null,
|
|
34
|
+
"session_id": "session-abc123",
|
|
35
|
+
"agent_id": "main",
|
|
36
|
+
"traceId": "trace-legacy-002"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "legacy-pain-003",
|
|
40
|
+
"task": "Diagnose gate_block pain from scoring validation",
|
|
41
|
+
"score": 90,
|
|
42
|
+
"source": "gate_block",
|
|
43
|
+
"reason": "Principle score 0.12 below threshold 0.70 for late_marker_no_principle",
|
|
44
|
+
"timestamp": "2026-04-12T09:15:00.000Z",
|
|
45
|
+
"enqueued_at": "2026-04-12T09:15:30.000Z",
|
|
46
|
+
"started_at": "2026-04-12T09:16:00.000Z",
|
|
47
|
+
"completed_at": "2026-04-12T09:20:45.000Z",
|
|
48
|
+
"assigned_session_key": "session-key-def456",
|
|
49
|
+
"trigger_text_preview": "Principle score below threshold",
|
|
50
|
+
"status": "completed",
|
|
51
|
+
"resolution": "late_marker_no_principle",
|
|
52
|
+
"session_id": "session-def456",
|
|
53
|
+
"agent_id": "main",
|
|
54
|
+
"traceId": "trace-legacy-003"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"id": "legacy-pain-004",
|
|
58
|
+
"task": "Diagnose thin_violation pain from rule scoring",
|
|
59
|
+
"score": 55,
|
|
60
|
+
"source": "thin_violation",
|
|
61
|
+
"reason": "Rule 'no-bare-throw' triggered but violation was benign; investigate scoring bias",
|
|
62
|
+
"timestamp": "2026-04-13T16:45:00.000Z",
|
|
63
|
+
"enqueued_at": "2026-04-13T16:45:15.000Z",
|
|
64
|
+
"started_at": "2026-04-13T16:46:00.000Z",
|
|
65
|
+
"completed_at": "2026-04-13T16:50:30.000Z",
|
|
66
|
+
"assigned_session_key": "session-key-ghi789",
|
|
67
|
+
"trigger_text_preview": "Rule scoring bias investigation",
|
|
68
|
+
"status": "failed",
|
|
69
|
+
"resolution": "failed_max_retries",
|
|
70
|
+
"session_id": "session-ghi789",
|
|
71
|
+
"agent_id": "main",
|
|
72
|
+
"traceId": "trace-legacy-004"
|
|
73
|
+
}
|
|
74
|
+
]
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for asyncLockQueues concurrency with Promise.all race detection.
|
|
3
|
+
* Tests file-level async lock serialization and Map state cleanup per D-05.
|
|
4
|
+
* Uses vi.useFakeTimers() in beforeEach/afterEach per D-10,
|
|
5
|
+
* but concurrency tests run with real timers to avoid Promise.all + fake timer issues.
|
|
6
|
+
* Uses os.tmpdir() per D-11.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import * as fs from 'fs';
|
|
11
|
+
import * as os from 'os';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import { withAsyncLock, asyncLockQueues } from '../../src/utils/file-lock.js';
|
|
14
|
+
|
|
15
|
+
describe('asyncLockQueues', () => {
|
|
16
|
+
let tempDir: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
// D-10: Use fake timers in beforeEach (afterEach restores real timers)
|
|
20
|
+
vi.useFakeTimers();
|
|
21
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-async-lock-test-'));
|
|
22
|
+
// D-05: clear Map state between tests
|
|
23
|
+
asyncLockQueues.clear();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
vi.useRealTimers();
|
|
28
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('serializes concurrent operations on same file (Promise.all race detection)', async () => {
|
|
32
|
+
const filePath = path.join(tempDir, 'serialized-test.json');
|
|
33
|
+
fs.writeFileSync(filePath, '{}', 'utf8');
|
|
34
|
+
|
|
35
|
+
const results: number[] = [];
|
|
36
|
+
|
|
37
|
+
// Use real timers for this test (fake timers + Promise.all + setTimeout don't mix well)
|
|
38
|
+
vi.useRealTimers();
|
|
39
|
+
|
|
40
|
+
const promises = [
|
|
41
|
+
withAsyncLock(filePath, async () => {
|
|
42
|
+
results.push(1);
|
|
43
|
+
await new Promise(r => setTimeout(r, 50));
|
|
44
|
+
results.push(2);
|
|
45
|
+
}),
|
|
46
|
+
withAsyncLock(filePath, async () => {
|
|
47
|
+
results.push(3);
|
|
48
|
+
await new Promise(r => setTimeout(r, 50));
|
|
49
|
+
results.push(4);
|
|
50
|
+
}),
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
await Promise.all(promises);
|
|
54
|
+
|
|
55
|
+
// Both operations completed
|
|
56
|
+
expect(results).toHaveLength(4);
|
|
57
|
+
|
|
58
|
+
// Non-interleaved: first operation completes before second starts
|
|
59
|
+
// [1, 2, 3, 4] means first ran fully, then second ran fully
|
|
60
|
+
// [3, 4, 1, 2] means second ran fully, then first ran fully
|
|
61
|
+
// [1, 3, 2, 4] would mean interleaving (bad) — but lock prevents this
|
|
62
|
+
const isNonInterleaved =
|
|
63
|
+
(results[0] === 1 && results[1] === 2) ||
|
|
64
|
+
(results[0] === 3 && results[1] === 4);
|
|
65
|
+
|
|
66
|
+
expect(isNonInterleaved).toBe(true);
|
|
67
|
+
|
|
68
|
+
// Restore fake timers for afterEach cleanup
|
|
69
|
+
vi.useFakeTimers();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('allows concurrent operations on different files', async () => {
|
|
73
|
+
const fileA = path.join(tempDir, 'file-a.json');
|
|
74
|
+
const fileB = path.join(tempDir, 'file-b.json');
|
|
75
|
+
fs.writeFileSync(fileA, '{}', 'utf8');
|
|
76
|
+
fs.writeFileSync(fileB, '{}', 'utf8');
|
|
77
|
+
|
|
78
|
+
const results: string[] = [];
|
|
79
|
+
|
|
80
|
+
// Use real timers for this test
|
|
81
|
+
vi.useRealTimers();
|
|
82
|
+
|
|
83
|
+
const promiseA = withAsyncLock(fileA, async () => {
|
|
84
|
+
results.push('A-start');
|
|
85
|
+
await new Promise(r => setTimeout(r, 20));
|
|
86
|
+
results.push('A-end');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const promiseB = withAsyncLock(fileB, async () => {
|
|
90
|
+
results.push('B-start');
|
|
91
|
+
await new Promise(r => setTimeout(r, 20));
|
|
92
|
+
results.push('B-end');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await Promise.all([promiseA, promiseB]);
|
|
96
|
+
|
|
97
|
+
// Both operations completed
|
|
98
|
+
expect(results).toHaveLength(4);
|
|
99
|
+
// Operations on different files should interleave (both run concurrently)
|
|
100
|
+
expect(results).toContain('A-start');
|
|
101
|
+
expect(results).toContain('A-end');
|
|
102
|
+
expect(results).toContain('B-start');
|
|
103
|
+
expect(results).toContain('B-end');
|
|
104
|
+
|
|
105
|
+
// Restore fake timers for afterEach cleanup
|
|
106
|
+
vi.useFakeTimers();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('clears Map state between tests (beforeEach clearing)', async () => {
|
|
110
|
+
// Verify Map is empty at start of test (beforeEach cleared it)
|
|
111
|
+
expect(asyncLockQueues.size).toBe(0);
|
|
112
|
+
|
|
113
|
+
const filePath = path.join(tempDir, 'map-state-test.json');
|
|
114
|
+
fs.writeFileSync(filePath, '{}', 'utf8');
|
|
115
|
+
|
|
116
|
+
// Use real timers
|
|
117
|
+
vi.useRealTimers();
|
|
118
|
+
|
|
119
|
+
// Add entries to the Map via withAsyncLock (starts operations but doesn't wait)
|
|
120
|
+
const p1 = withAsyncLock(filePath, async () => {
|
|
121
|
+
await new Promise(r => setTimeout(r, 100));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Wait a bit for the operation to start
|
|
125
|
+
await new Promise(r => setTimeout(r, 10));
|
|
126
|
+
|
|
127
|
+
// Map should have entries (lock for filePath is queued)
|
|
128
|
+
expect(asyncLockQueues.size).toBeGreaterThanOrEqual(1);
|
|
129
|
+
|
|
130
|
+
// Wait for operation to complete to avoid affecting other tests
|
|
131
|
+
await p1;
|
|
132
|
+
|
|
133
|
+
// Restore fake timers for afterEach cleanup
|
|
134
|
+
vi.useFakeTimers();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('releases lock after function throws', async () => {
|
|
138
|
+
const filePath = path.join(tempDir, 'error-test.json');
|
|
139
|
+
fs.writeFileSync(filePath, '{}', 'utf8');
|
|
140
|
+
|
|
141
|
+
// Use real timers
|
|
142
|
+
vi.useRealTimers();
|
|
143
|
+
|
|
144
|
+
// First call throws
|
|
145
|
+
await expect(
|
|
146
|
+
withAsyncLock(filePath, async () => {
|
|
147
|
+
throw new Error('intentional failure');
|
|
148
|
+
})
|
|
149
|
+
).rejects.toThrow('intentional failure');
|
|
150
|
+
|
|
151
|
+
// Second call should succeed (lock was released)
|
|
152
|
+
const result = await withAsyncLock(filePath, async () => 'success-after-error');
|
|
153
|
+
|
|
154
|
+
expect(result).toBe('success-after-error');
|
|
155
|
+
|
|
156
|
+
// Restore fake timers for afterEach cleanup
|
|
157
|
+
vi.useFakeTimers();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('returns correct value from withAsyncLock', async () => {
|
|
161
|
+
const filePath = path.join(tempDir, 'return-value-test.json');
|
|
162
|
+
fs.writeFileSync(filePath, '{}', 'utf8');
|
|
163
|
+
|
|
164
|
+
// Use real timers
|
|
165
|
+
vi.useRealTimers();
|
|
166
|
+
|
|
167
|
+
const result = await withAsyncLock(filePath, async () => {
|
|
168
|
+
return { success: true, value: 42 };
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(result).toEqual({ success: true, value: 42 });
|
|
172
|
+
|
|
173
|
+
// Restore fake timers for afterEach cleanup
|
|
174
|
+
vi.useFakeTimers();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('handles multiple sequential operations on same file', async () => {
|
|
178
|
+
const filePath = path.join(tempDir, 'sequential-test.json');
|
|
179
|
+
fs.writeFileSync(filePath, '{}', 'utf8');
|
|
180
|
+
|
|
181
|
+
// Use real timers
|
|
182
|
+
vi.useRealTimers();
|
|
183
|
+
|
|
184
|
+
const results: number[] = [];
|
|
185
|
+
|
|
186
|
+
for (let i = 0; i < 3; i++) {
|
|
187
|
+
const idx = i;
|
|
188
|
+
await withAsyncLock(filePath, async () => {
|
|
189
|
+
results.push(idx);
|
|
190
|
+
await new Promise(r => setTimeout(r, 10));
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// All three sequential operations completed
|
|
195
|
+
expect(results).toEqual([0, 1, 2]);
|
|
196
|
+
|
|
197
|
+
// Restore fake timers for afterEach cleanup
|
|
198
|
+
vi.useFakeTimers();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for loadEvolutionQueue queue loading and migration.
|
|
3
|
+
* Uses vi.useFakeTimers() per D-10.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as os from 'os';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import { loadEvolutionQueue, validateQueueEventPayload } from '../../src/service/evolution-worker.js';
|
|
11
|
+
|
|
12
|
+
describe('loadEvolutionQueue', () => {
|
|
13
|
+
let tempDir: string;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.useFakeTimers();
|
|
17
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-queue-test-'));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
vi.useRealTimers();
|
|
22
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('loads and migrates legacy queue from file', () => {
|
|
26
|
+
// Write legacy v1 format (no taskKind, no priority)
|
|
27
|
+
const legacyQueue = [
|
|
28
|
+
{
|
|
29
|
+
id: 'legacy-001',
|
|
30
|
+
task: 'Diagnose tool_failure pain',
|
|
31
|
+
score: 78,
|
|
32
|
+
source: 'tool_failure',
|
|
33
|
+
reason: 'Tool write failed',
|
|
34
|
+
timestamp: '2026-04-10T08:30:00.000Z',
|
|
35
|
+
enqueued_at: '2026-04-10T08:30:05.000Z',
|
|
36
|
+
started_at: null,
|
|
37
|
+
completed_at: null,
|
|
38
|
+
assigned_session_key: null,
|
|
39
|
+
trigger_text_preview: 'Tool write failed',
|
|
40
|
+
status: 'pending',
|
|
41
|
+
resolution: null,
|
|
42
|
+
session_id: null,
|
|
43
|
+
agent_id: null,
|
|
44
|
+
traceId: 'trace-001',
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
const queuePath = path.join(tempDir, 'evolution_queue.json');
|
|
48
|
+
fs.writeFileSync(queuePath, JSON.stringify(legacyQueue), 'utf8');
|
|
49
|
+
|
|
50
|
+
const result = loadEvolutionQueue(queuePath);
|
|
51
|
+
|
|
52
|
+
expect(result).toHaveLength(1);
|
|
53
|
+
expect(result[0].id).toBe('legacy-001');
|
|
54
|
+
expect(result[0].taskKind).toBe('pain_diagnosis'); // DEFAULT_TASK_KIND
|
|
55
|
+
expect(result[0].priority).toBe('medium'); // DEFAULT_PRIORITY
|
|
56
|
+
expect(result[0].retryCount).toBe(0);
|
|
57
|
+
expect(result[0].maxRetries).toBe(3);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('loads empty array when file does not exist', () => {
|
|
61
|
+
const nonExistentPath = path.join(tempDir, 'does-not-exist.json');
|
|
62
|
+
const result = loadEvolutionQueue(nonExistentPath);
|
|
63
|
+
|
|
64
|
+
expect(result).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('loads existing V2 queue unchanged', () => {
|
|
68
|
+
// Write V2 format with taskKind and priority
|
|
69
|
+
const v2Queue = [
|
|
70
|
+
{
|
|
71
|
+
id: 'v2-001',
|
|
72
|
+
taskKind: 'sleep_reflection',
|
|
73
|
+
priority: 'high',
|
|
74
|
+
score: 55,
|
|
75
|
+
source: 'nocturnal',
|
|
76
|
+
reason: 'Idle workspace',
|
|
77
|
+
timestamp: '2026-04-13T00:00:00.000Z',
|
|
78
|
+
enqueued_at: '2026-04-13T00:00:05.000Z',
|
|
79
|
+
started_at: null,
|
|
80
|
+
completed_at: null,
|
|
81
|
+
assigned_session_key: null,
|
|
82
|
+
trigger_text_preview: 'Idle workspace detected',
|
|
83
|
+
status: 'pending',
|
|
84
|
+
resolution: undefined,
|
|
85
|
+
session_id: null,
|
|
86
|
+
agent_id: null,
|
|
87
|
+
traceId: 'trace-v2-001',
|
|
88
|
+
retryCount: 0,
|
|
89
|
+
maxRetries: 1,
|
|
90
|
+
lastError: undefined,
|
|
91
|
+
resultRef: undefined,
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
const queuePath = path.join(tempDir, 'evolution_queue_v2.json');
|
|
95
|
+
fs.writeFileSync(queuePath, JSON.stringify(v2Queue), 'utf8');
|
|
96
|
+
|
|
97
|
+
const result = loadEvolutionQueue(queuePath);
|
|
98
|
+
|
|
99
|
+
expect(result).toHaveLength(1);
|
|
100
|
+
expect(result[0].id).toBe('v2-001');
|
|
101
|
+
expect(result[0].taskKind).toBe('sleep_reflection'); // unchanged
|
|
102
|
+
expect(result[0].priority).toBe('high'); // unchanged
|
|
103
|
+
expect(result[0].maxRetries).toBe(1); // unchanged
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('respects timestamp ordering in loaded queue', () => {
|
|
107
|
+
const orderedQueue = [
|
|
108
|
+
{
|
|
109
|
+
id: 'first',
|
|
110
|
+
taskKind: 'pain_diagnosis',
|
|
111
|
+
priority: 'low',
|
|
112
|
+
score: 30,
|
|
113
|
+
source: 'tool_failure',
|
|
114
|
+
reason: 'First task',
|
|
115
|
+
timestamp: '2026-04-10T08:00:00.000Z',
|
|
116
|
+
enqueued_at: '2026-04-10T08:00:00.000Z',
|
|
117
|
+
started_at: null,
|
|
118
|
+
completed_at: null,
|
|
119
|
+
assigned_session_key: null,
|
|
120
|
+
trigger_text_preview: 'First',
|
|
121
|
+
status: 'pending',
|
|
122
|
+
resolution: undefined,
|
|
123
|
+
session_id: null,
|
|
124
|
+
agent_id: null,
|
|
125
|
+
traceId: 'trace-first',
|
|
126
|
+
retryCount: 0,
|
|
127
|
+
maxRetries: 3,
|
|
128
|
+
lastError: undefined,
|
|
129
|
+
resultRef: undefined,
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: 'second',
|
|
133
|
+
taskKind: 'pain_diagnosis',
|
|
134
|
+
priority: 'medium',
|
|
135
|
+
score: 60,
|
|
136
|
+
source: 'tool_failure',
|
|
137
|
+
reason: 'Second task',
|
|
138
|
+
timestamp: '2026-04-11T08:00:00.000Z',
|
|
139
|
+
enqueued_at: '2026-04-11T08:00:00.000Z',
|
|
140
|
+
started_at: null,
|
|
141
|
+
completed_at: null,
|
|
142
|
+
assigned_session_key: null,
|
|
143
|
+
trigger_text_preview: 'Second',
|
|
144
|
+
status: 'pending',
|
|
145
|
+
resolution: undefined,
|
|
146
|
+
session_id: null,
|
|
147
|
+
agent_id: null,
|
|
148
|
+
traceId: 'trace-second',
|
|
149
|
+
retryCount: 0,
|
|
150
|
+
maxRetries: 3,
|
|
151
|
+
lastError: undefined,
|
|
152
|
+
resultRef: undefined,
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: 'third',
|
|
156
|
+
taskKind: 'pain_diagnosis',
|
|
157
|
+
priority: 'high',
|
|
158
|
+
score: 90,
|
|
159
|
+
source: 'tool_failure',
|
|
160
|
+
reason: 'Third task',
|
|
161
|
+
timestamp: '2026-04-12T08:00:00.000Z',
|
|
162
|
+
enqueued_at: '2026-04-12T08:00:00.000Z',
|
|
163
|
+
started_at: null,
|
|
164
|
+
completed_at: null,
|
|
165
|
+
assigned_session_key: null,
|
|
166
|
+
trigger_text_preview: 'Third',
|
|
167
|
+
status: 'pending',
|
|
168
|
+
resolution: undefined,
|
|
169
|
+
session_id: null,
|
|
170
|
+
agent_id: null,
|
|
171
|
+
traceId: 'trace-third',
|
|
172
|
+
retryCount: 0,
|
|
173
|
+
maxRetries: 3,
|
|
174
|
+
lastError: undefined,
|
|
175
|
+
resultRef: undefined,
|
|
176
|
+
},
|
|
177
|
+
];
|
|
178
|
+
const queuePath = path.join(tempDir, 'ordered_queue.json');
|
|
179
|
+
fs.writeFileSync(queuePath, JSON.stringify(orderedQueue), 'utf8');
|
|
180
|
+
|
|
181
|
+
const result = loadEvolutionQueue(queuePath);
|
|
182
|
+
|
|
183
|
+
expect(result[0].id).toBe('first');
|
|
184
|
+
expect(result[1].id).toBe('second');
|
|
185
|
+
expect(result[2].id).toBe('third');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('handles file with trailing newline', () => {
|
|
189
|
+
const queueWithNewline = [
|
|
190
|
+
{
|
|
191
|
+
id: 'newline-test',
|
|
192
|
+
taskKind: 'pain_diagnosis',
|
|
193
|
+
priority: 'medium',
|
|
194
|
+
score: 50,
|
|
195
|
+
source: 'tool_failure',
|
|
196
|
+
reason: 'Trailing newline test',
|
|
197
|
+
timestamp: '2026-04-10T10:00:00.000Z',
|
|
198
|
+
enqueued_at: '2026-04-10T10:00:00.000Z',
|
|
199
|
+
started_at: null,
|
|
200
|
+
completed_at: null,
|
|
201
|
+
assigned_session_key: null,
|
|
202
|
+
trigger_text_preview: 'Trailing newline',
|
|
203
|
+
status: 'pending',
|
|
204
|
+
resolution: undefined,
|
|
205
|
+
session_id: null,
|
|
206
|
+
agent_id: null,
|
|
207
|
+
traceId: 'trace-newline',
|
|
208
|
+
retryCount: 0,
|
|
209
|
+
maxRetries: 3,
|
|
210
|
+
lastError: undefined,
|
|
211
|
+
resultRef: undefined,
|
|
212
|
+
},
|
|
213
|
+
];
|
|
214
|
+
const queuePath = path.join(tempDir, 'newline_queue.json');
|
|
215
|
+
fs.writeFileSync(queuePath, JSON.stringify(queueWithNewline) + '\n', 'utf8');
|
|
216
|
+
|
|
217
|
+
const result = loadEvolutionQueue(queuePath);
|
|
218
|
+
|
|
219
|
+
expect(result).toHaveLength(1);
|
|
220
|
+
expect(result[0].id).toBe('newline-test');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('round-trip: load returns same data that was written', () => {
|
|
224
|
+
// Write a V2-format queue, load it, verify data integrity
|
|
225
|
+
const originalQueue = [
|
|
226
|
+
{
|
|
227
|
+
id: 'roundtrip-001',
|
|
228
|
+
taskKind: 'sleep_reflection',
|
|
229
|
+
priority: 'high',
|
|
230
|
+
score: 88,
|
|
231
|
+
source: 'nocturnal',
|
|
232
|
+
reason: 'Round-trip test',
|
|
233
|
+
timestamp: '2026-04-14T02:00:00.000Z',
|
|
234
|
+
enqueued_at: '2026-04-14T02:00:00.000Z',
|
|
235
|
+
started_at: null,
|
|
236
|
+
completed_at: null,
|
|
237
|
+
assigned_session_key: null,
|
|
238
|
+
trigger_text_preview: 'Round-trip',
|
|
239
|
+
status: 'pending',
|
|
240
|
+
resolution: undefined,
|
|
241
|
+
session_id: null,
|
|
242
|
+
agent_id: null,
|
|
243
|
+
traceId: 'trace-roundtrip',
|
|
244
|
+
retryCount: 0,
|
|
245
|
+
maxRetries: 1,
|
|
246
|
+
lastError: undefined,
|
|
247
|
+
resultRef: undefined,
|
|
248
|
+
},
|
|
249
|
+
];
|
|
250
|
+
const queuePath = path.join(tempDir, 'roundtrip_queue.json');
|
|
251
|
+
fs.writeFileSync(queuePath, JSON.stringify(originalQueue), 'utf8');
|
|
252
|
+
|
|
253
|
+
const loaded = loadEvolutionQueue(queuePath);
|
|
254
|
+
|
|
255
|
+
expect(loaded).toHaveLength(1);
|
|
256
|
+
expect(loaded[0].id).toBe(originalQueue[0].id);
|
|
257
|
+
expect(loaded[0].taskKind).toBe(originalQueue[0].taskKind);
|
|
258
|
+
expect(loaded[0].priority).toBe(originalQueue[0].priority);
|
|
259
|
+
expect(loaded[0].score).toBe(originalQueue[0].score);
|
|
260
|
+
expect(loaded[0].source).toBe(originalQueue[0].source);
|
|
261
|
+
expect(loaded[0].status).toBe(originalQueue[0].status);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('validateQueueEventPayload', () => {
|
|
266
|
+
it('returns empty object for null/undefined', () => {
|
|
267
|
+
expect(validateQueueEventPayload(null)).toEqual({});
|
|
268
|
+
expect(validateQueueEventPayload(undefined)).toEqual({});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('throws for non-string input', () => {
|
|
272
|
+
expect(() => (validateQueueEventPayload as any)(123)).toThrow('must be a string');
|
|
273
|
+
expect(() => (validateQueueEventPayload as any)({})).toThrow('must be a string');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('throws for JSON that is not an object', () => {
|
|
277
|
+
// Primitive JSON values pass typeof check but fail the object/null guard
|
|
278
|
+
expect(() => validateQueueEventPayload('"string"')).toThrow('must be a JSON object');
|
|
279
|
+
// Arrays pass typeof === 'object' check so they reach required fields check first
|
|
280
|
+
expect(() => validateQueueEventPayload('[1,2,3]')).toThrow('missing required fields');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('throws for object missing required fields', () => {
|
|
284
|
+
expect(() => validateQueueEventPayload('{"type":"x"}')).toThrow('missing required fields');
|
|
285
|
+
expect(() => validateQueueEventPayload('{"workspaceId":"x"}')).toThrow('missing required fields');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('returns parsed object for valid payload', () => {
|
|
289
|
+
const valid = '{"type":"test","workspaceId":"ws-001"}';
|
|
290
|
+
expect(validateQueueEventPayload(valid)).toEqual({ type: 'test', workspaceId: 'ws-001' });
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('throws wrapped SyntaxError for invalid JSON', () => {
|
|
294
|
+
expect(() => validateQueueEventPayload('not json')).toThrow('Invalid JSON');
|
|
295
|
+
});
|
|
296
|
+
});
|