principles-disciple 1.80.0 → 1.81.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 +1 -1
- package/package.json +1 -1
- package/src/index.ts +43 -0
- package/src/service/correction-observer-service.ts +200 -0
- package/src/service/evolution-worker.ts +2 -123
- package/tests/service/correction-observer-service.test.ts +331 -0
- package/tests/service/evolution-worker.correction-observer.test.ts +41 -164
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -49,6 +49,7 @@ import { handleExportCommand } from './commands/export.js';
|
|
|
49
49
|
import { handleSamplesCommand } from './commands/samples.js';
|
|
50
50
|
import { handleWorkflowDebugCommand } from './commands/workflow-debug.js';
|
|
51
51
|
import { EvolutionWorkerService } from './service/evolution-worker.js';
|
|
52
|
+
import { CorrectionObserverService } from './service/correction-observer-service.js';
|
|
52
53
|
import { TrajectoryService } from './service/trajectory-service.js';
|
|
53
54
|
import { PDTaskService } from './core/pd-task-service.js';
|
|
54
55
|
import { CentralSyncService } from './service/central-sync-service.js';
|
|
@@ -168,6 +169,30 @@ export function shouldStartEvolutionWorker(
|
|
|
168
169
|
return { shouldStart: false, flagSource: flag.source, disabledInfo };
|
|
169
170
|
}
|
|
170
171
|
|
|
172
|
+
export interface CorrectionObserverGateResult {
|
|
173
|
+
shouldStart: boolean;
|
|
174
|
+
flagSource: string;
|
|
175
|
+
disabledInfo: string | null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function shouldStartCorrectionObserver(
|
|
179
|
+
workspaceDir: string,
|
|
180
|
+
logger: { info?: (msg: string) => void; warn?: (msg: string) => void },
|
|
181
|
+
): CorrectionObserverGateResult {
|
|
182
|
+
const flag = loadFeatureFlagFromWorkspace(workspaceDir, 'correction_observer', logger);
|
|
183
|
+
if (flag.enabled) {
|
|
184
|
+
return { shouldStart: true, flagSource: flag.source, disabledInfo: null };
|
|
185
|
+
}
|
|
186
|
+
const disabledInfo = JSON.stringify({
|
|
187
|
+
reason: 'correction_observer_disabled',
|
|
188
|
+
nextAction: 'set correction_observer.enabled=true in .pd/feature-flags.yaml to enable',
|
|
189
|
+
featureFlag: 'correction_observer',
|
|
190
|
+
boundedContext: 'correction_observer_service',
|
|
191
|
+
flagSource: flag.source,
|
|
192
|
+
});
|
|
193
|
+
return { shouldStart: false, flagSource: flag.source, disabledInfo };
|
|
194
|
+
}
|
|
195
|
+
|
|
171
196
|
const plugin = {
|
|
172
197
|
name: "Principles Disciple",
|
|
173
198
|
description: "Evolutionary programming agent framework with strategic guardrails and reflection loops.",
|
|
@@ -246,6 +271,22 @@ const plugin = {
|
|
|
246
271
|
api.logger.info(`[PD] EvolutionWorker NOT started for workspace: ${workspaceDir}. ${gate.disabledInfo}`);
|
|
247
272
|
SystemLogger.log(workspaceDir, 'EVOLUTION_WORKER_DISABLED', gate.disabledInfo ?? '');
|
|
248
273
|
}
|
|
274
|
+
|
|
275
|
+
// ── Start CorrectionObserver for THIS workspace ──
|
|
276
|
+
// MVP-Core per ADR-0014 amendment, independently owned (PRI-293).
|
|
277
|
+
const corrGate = shouldStartCorrectionObserver(workspaceDir, api.logger);
|
|
278
|
+
if (corrGate.shouldStart) {
|
|
279
|
+
CorrectionObserverService.start({
|
|
280
|
+
config: api.config,
|
|
281
|
+
workspaceDir,
|
|
282
|
+
stateDir: path.join(workspaceDir, '.state'),
|
|
283
|
+
logger: api.logger,
|
|
284
|
+
});
|
|
285
|
+
api.logger.info(`[PD] CorrectionObserver started for workspace: ${workspaceDir} (flag source: ${corrGate.flagSource})`);
|
|
286
|
+
} else {
|
|
287
|
+
api.logger.info(`[PD] CorrectionObserver NOT started for workspace: ${workspaceDir}. ${corrGate.disabledInfo}`);
|
|
288
|
+
SystemLogger.log(workspaceDir, 'CORRECTION_OBSERVER_DISABLED', corrGate.disabledInfo ?? '');
|
|
289
|
+
}
|
|
249
290
|
}
|
|
250
291
|
|
|
251
292
|
const result = await handleBeforePromptBuild(event, { ...ctx, api: api as Parameters<typeof handleBeforePromptBuild>[1]['api'], workspaceDir });
|
|
@@ -527,6 +568,8 @@ const plugin = {
|
|
|
527
568
|
EvolutionWorkerService.api = api;
|
|
528
569
|
const guardedEvolutionWorker = guardService('service:evolution-worker', EvolutionWorkerService, api.logger);
|
|
529
570
|
if (guardedEvolutionWorker) api.registerService(guardedEvolutionWorker);
|
|
571
|
+
const guardedCorrectionObserver = guardService('service:correction-observer', CorrectionObserverService, api.logger);
|
|
572
|
+
if (guardedCorrectionObserver) api.registerService(guardedCorrectionObserver);
|
|
530
573
|
const guardedTrajectory = guardService('service:trajectory', TrajectoryService, api.logger);
|
|
531
574
|
if (guardedTrajectory) api.registerService(guardedTrajectory);
|
|
532
575
|
const guardedPdTask = guardService('service:pd-task', PDTaskService, api.logger);
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type { OpenClawPluginServiceContext, PluginLogger } from '../openclaw-sdk.js';
|
|
2
|
+
import { WorkspaceContext } from '../core/workspace-context.js';
|
|
3
|
+
import { TrajectoryRegistry } from '../core/trajectory.js';
|
|
4
|
+
import { CorrectionCueLearner } from '../core/correction-cue-learner.js';
|
|
5
|
+
import {
|
|
6
|
+
WorkflowFunnelLoader,
|
|
7
|
+
PiAiRuntimeAdapter,
|
|
8
|
+
CorrectionObserver,
|
|
9
|
+
AgentScheduler,
|
|
10
|
+
} from '@principles/core/runtime-v2';
|
|
11
|
+
import { KeywordOptimizationService } from './keyword-optimization-service.js';
|
|
12
|
+
import { SystemLogger } from '../core/system-logger.js';
|
|
13
|
+
|
|
14
|
+
export interface CorrectionObserverServiceShape {
|
|
15
|
+
id: string;
|
|
16
|
+
start: (ctx: OpenClawPluginServiceContext) => void;
|
|
17
|
+
stop?: (ctx: OpenClawPluginServiceContext) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let correctionObserverTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
21
|
+
let correctionObserverStopped = false;
|
|
22
|
+
const startedWorkspaces = new Set<string>();
|
|
23
|
+
|
|
24
|
+
const CORRECTION_OBSERVER_INTERVAL_MS = 15 * 60 * 1000;
|
|
25
|
+
const CORRECTION_OBSERVER_INITIAL_DELAY_MS = 10_000;
|
|
26
|
+
const CORRECTION_OBSERVER_MAX_RECENT_SESSIONS = 20;
|
|
27
|
+
const CORRECTION_OBSERVER_MAX_PAYLOAD_SESSIONS = 5;
|
|
28
|
+
|
|
29
|
+
export function resolveCorrectionObserver(wctx: WorkspaceContext, logger?: Pick<PluginLogger, 'info' | 'warn' | 'error' | 'debug'>): CorrectionObserver | null {
|
|
30
|
+
try {
|
|
31
|
+
const loader = new WorkflowFunnelLoader(wctx.stateDir);
|
|
32
|
+
const funnel = loader.getFunnel('pd-correction-observer');
|
|
33
|
+
const policy = funnel?.policy;
|
|
34
|
+
if (!policy || policy.runtimeKind !== 'pi-ai') {
|
|
35
|
+
logger?.debug?.('[PD:CorrectionObserver] workflows.yaml pd-correction-observer policy not found. Falling back to environment variables.');
|
|
36
|
+
const provider = process.env.PD_CORRECTION_PROVIDER || 'anthropic';
|
|
37
|
+
const model = process.env.PD_CORRECTION_MODEL || 'anthropic/claude-3-5-sonnet';
|
|
38
|
+
const apiKeyEnv = process.env.PD_CORRECTION_API_KEY_ENV || 'ANTHROPIC_API_KEY';
|
|
39
|
+
const baseUrl = process.env.PD_CORRECTION_BASE_URL;
|
|
40
|
+
|
|
41
|
+
if (!process.env[apiKeyEnv]) {
|
|
42
|
+
logger?.debug?.(`[PD:CorrectionObserver] API key env ${apiKeyEnv} is not set. Periodic optimization disabled.`);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const adapter = new PiAiRuntimeAdapter({
|
|
47
|
+
provider,
|
|
48
|
+
model,
|
|
49
|
+
apiKeyEnv,
|
|
50
|
+
baseUrl,
|
|
51
|
+
workspace: wctx.workspaceDir,
|
|
52
|
+
});
|
|
53
|
+
return new CorrectionObserver({ runtimeAdapter: adapter });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const adapter = new PiAiRuntimeAdapter({
|
|
57
|
+
provider: String(policy.provider),
|
|
58
|
+
model: String(policy.model),
|
|
59
|
+
apiKeyEnv: String(policy.apiKeyEnv),
|
|
60
|
+
maxRetries: policy.maxRetries,
|
|
61
|
+
timeoutMs: policy.timeoutMs ?? 30_000,
|
|
62
|
+
baseUrl: policy.baseUrl,
|
|
63
|
+
workspace: wctx.workspaceDir,
|
|
64
|
+
});
|
|
65
|
+
return new CorrectionObserver({ runtimeAdapter: adapter }, { timeoutMs: policy.timeoutMs });
|
|
66
|
+
} catch (err) {
|
|
67
|
+
logger?.warn?.(`[PD:CorrectionObserver] Failed to resolve CorrectionObserver: ${String(err)}`);
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function runCorrectionObserverCycle(wctx: WorkspaceContext, logger: PluginLogger): Promise<void> {
|
|
73
|
+
try {
|
|
74
|
+
const observer = resolveCorrectionObserver(wctx, logger);
|
|
75
|
+
if (!observer) {
|
|
76
|
+
logger?.info?.('[PD:CorrectionObserver] Observer not resolved (no API key or config). Skipping cycle.');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
logger?.info?.('[PD:CorrectionObserver] Observer resolved. Initiating periodic optimization...');
|
|
81
|
+
|
|
82
|
+
const db = TrajectoryRegistry.get(wctx.workspaceDir);
|
|
83
|
+
const recentSessions = db.listRecentSessions({ limit: CORRECTION_OBSERVER_MAX_RECENT_SESSIONS });
|
|
84
|
+
const recentSessionIds = recentSessions.map(s => s.sessionId);
|
|
85
|
+
|
|
86
|
+
if (recentSessionIds.length === 0) {
|
|
87
|
+
logger?.info?.('[PD:CorrectionObserver] No recent sessions found. Skipping correction optimization.');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const recentMessages: string[] = [];
|
|
92
|
+
for (const sId of recentSessionIds.slice(0, CORRECTION_OBSERVER_MAX_PAYLOAD_SESSIONS)) {
|
|
93
|
+
try {
|
|
94
|
+
const turns = db.listUserTurnsForSession(sId);
|
|
95
|
+
for (const t of turns) {
|
|
96
|
+
if (t.rawExcerpt) {
|
|
97
|
+
recentMessages.push(t.rawExcerpt);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch (turnErr) {
|
|
101
|
+
logger?.warn?.(`[PD:CorrectionObserver] Failed to load user turns for session ${sId}: ${String(turnErr)}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const learner = CorrectionCueLearner.get(wctx.stateDir);
|
|
106
|
+
const keywords = learner.getStore().keywords;
|
|
107
|
+
const keywordStoreSummary = {
|
|
108
|
+
totalKeywords: keywords.length,
|
|
109
|
+
terms: keywords.map(k => ({
|
|
110
|
+
term: k.term,
|
|
111
|
+
weight: k.weight,
|
|
112
|
+
hitCount: k.hitCount ?? 0,
|
|
113
|
+
truePositiveCount: k.truePositiveCount ?? 0,
|
|
114
|
+
falsePositiveCount: k.falsePositiveCount ?? 0,
|
|
115
|
+
})),
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const optimizationService = KeywordOptimizationService.get(wctx.stateDir, wctx.workspaceDir, logger);
|
|
119
|
+
const trajectoryHistory = await optimizationService.buildTrajectoryHistory(recentSessionIds);
|
|
120
|
+
|
|
121
|
+
const payload = {
|
|
122
|
+
parentSessionId: 'correction-observer-service',
|
|
123
|
+
workspaceDir: wctx.workspaceDir,
|
|
124
|
+
keywordStoreSummary,
|
|
125
|
+
recentMessages,
|
|
126
|
+
trajectoryHistory,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const scheduler = new AgentScheduler();
|
|
130
|
+
scheduler.register({
|
|
131
|
+
agentId: 'correction-observer',
|
|
132
|
+
mode: 'realtime',
|
|
133
|
+
runner: observer,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
logger?.info?.(`[PD:CorrectionObserver] Dispatching with ${trajectoryHistory.length} trajectory events, ${recentMessages.length} recent messages.`);
|
|
137
|
+
const result = await scheduler.dispatch('correction-observer', payload);
|
|
138
|
+
logger?.info?.(`[PD:CorrectionObserver] Completed: updated=${result.updated}, summary="${result.summary}"`);
|
|
139
|
+
|
|
140
|
+
if (result.updated) {
|
|
141
|
+
optimizationService.applyResult(result);
|
|
142
|
+
}
|
|
143
|
+
} catch (err) {
|
|
144
|
+
const errMsg = `Correction observer cycle failed: ${String(err)}`;
|
|
145
|
+
logger?.warn?.(`[PD:CorrectionObserver] ${errMsg}`);
|
|
146
|
+
SystemLogger.log(wctx.workspaceDir, 'CORRECTION_OBSERVER_CYCLE_FAILED', errMsg);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const CorrectionObserverService: CorrectionObserverServiceShape = {
|
|
151
|
+
id: 'principles-correction-observer',
|
|
152
|
+
|
|
153
|
+
start(ctx: OpenClawPluginServiceContext): void {
|
|
154
|
+
const workspaceDir = ctx?.workspaceDir;
|
|
155
|
+
const logger = ctx?.logger || console;
|
|
156
|
+
|
|
157
|
+
if (!workspaceDir) {
|
|
158
|
+
if (logger) logger.warn('[PD:CorrectionObserver] workspaceDir not found in service config. Correction observer disabled.');
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (startedWorkspaces.has(workspaceDir)) {
|
|
163
|
+
if (logger) logger.info(`[PD:CorrectionObserver] Already started for workspace: ${workspaceDir}. Skipping duplicate start.`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
startedWorkspaces.add(workspaceDir);
|
|
167
|
+
|
|
168
|
+
correctionObserverStopped = false;
|
|
169
|
+
|
|
170
|
+
const wctx = WorkspaceContext.fromHookContext({ workspaceDir, ...ctx.config });
|
|
171
|
+
if (logger) logger.info(`[PD:CorrectionObserver] Starting with workspaceDir=${wctx.workspaceDir}, stateDir=${wctx.stateDir}`);
|
|
172
|
+
|
|
173
|
+
const interval = CORRECTION_OBSERVER_INTERVAL_MS;
|
|
174
|
+
|
|
175
|
+
async function runCycle(): Promise<void> {
|
|
176
|
+
if (correctionObserverStopped) return;
|
|
177
|
+
await runCorrectionObserverCycle(wctx, logger);
|
|
178
|
+
if (correctionObserverStopped) return;
|
|
179
|
+
correctionObserverTimeoutId = setTimeout(runCycle, interval);
|
|
180
|
+
correctionObserverTimeoutId.unref();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
correctionObserverTimeoutId = setTimeout(() => {
|
|
184
|
+
void runCycle().catch((err) => {
|
|
185
|
+
if (logger) logger.error(`[PD:CorrectionObserver] Startup cycle failed: ${String(err)}`);
|
|
186
|
+
if (correctionObserverStopped) return;
|
|
187
|
+
correctionObserverTimeoutId = setTimeout(runCycle, interval);
|
|
188
|
+
correctionObserverTimeoutId.unref();
|
|
189
|
+
});
|
|
190
|
+
}, CORRECTION_OBSERVER_INITIAL_DELAY_MS);
|
|
191
|
+
correctionObserverTimeoutId.unref();
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
stop(_ctx: OpenClawPluginServiceContext): void {
|
|
195
|
+
correctionObserverStopped = true;
|
|
196
|
+
startedWorkspaces.clear();
|
|
197
|
+
if (correctionObserverTimeoutId) clearTimeout(correctionObserverTimeoutId);
|
|
198
|
+
correctionObserverTimeoutId = null;
|
|
199
|
+
},
|
|
200
|
+
};
|
|
@@ -22,14 +22,7 @@ export { loadEvolutionQueue, saveEvolutionQueue, withQueueLock, acquireQueueLock
|
|
|
22
22
|
export { EVOLUTION_QUEUE_LOCK_SUFFIX, LOCK_MAX_RETRIES, LOCK_RETRY_DELAY_MS, LOCK_STALE_MS } from './queue-io.js';
|
|
23
23
|
import { saveEvolutionQueue, requireQueueLock } from './queue-io.js';
|
|
24
24
|
import { WorkflowStore } from './subagent-workflow/workflow-store.js';
|
|
25
|
-
|
|
26
|
-
WorkflowFunnelLoader,
|
|
27
|
-
PiAiRuntimeAdapter,
|
|
28
|
-
CorrectionObserver,
|
|
29
|
-
AgentScheduler,
|
|
30
|
-
} from '@principles/core/runtime-v2';
|
|
31
|
-
import { KeywordOptimizationService } from './keyword-optimization-service.js';
|
|
32
|
-
import { CorrectionCueLearner } from '../core/correction-cue-learner.js';
|
|
25
|
+
|
|
33
26
|
|
|
34
27
|
import { PrincipleCompiler } from '../core/principle-compiler/index.js';
|
|
35
28
|
import { loadLedger, updatePrinciple } from '../core/principle-tree-ledger.js';
|
|
@@ -632,49 +625,6 @@ async function processEvolutionQueueWithResult(
|
|
|
632
625
|
return { queue: queueResult, errors };
|
|
633
626
|
}
|
|
634
627
|
|
|
635
|
-
function resolveCorrectionObserver(wctx: WorkspaceContext, logger?: Pick<PluginLogger, 'info' | 'warn' | 'error' | 'debug'>): CorrectionObserver | null {
|
|
636
|
-
try {
|
|
637
|
-
const loader = new WorkflowFunnelLoader(wctx.stateDir);
|
|
638
|
-
const funnel = loader.getFunnel('pd-correction-observer');
|
|
639
|
-
const policy = funnel?.policy;
|
|
640
|
-
if (!policy || policy.runtimeKind !== 'pi-ai') {
|
|
641
|
-
logger?.debug?.('[PD:Correction] workflows.yaml pd-correction-observer policy not found. Falling back to environment variables.');
|
|
642
|
-
const provider = process.env.PD_CORRECTION_PROVIDER || 'anthropic';
|
|
643
|
-
const model = process.env.PD_CORRECTION_MODEL || 'anthropic/claude-3-5-sonnet';
|
|
644
|
-
const apiKeyEnv = process.env.PD_CORRECTION_API_KEY_ENV || 'ANTHROPIC_API_KEY';
|
|
645
|
-
const baseUrl = process.env.PD_CORRECTION_BASE_URL;
|
|
646
|
-
|
|
647
|
-
if (!process.env[apiKeyEnv]) {
|
|
648
|
-
logger?.debug?.(`[PD:Correction] Correction observer API key env ${apiKeyEnv} is not set. Periodic optimization disabled.`);
|
|
649
|
-
return null;
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
const adapter = new PiAiRuntimeAdapter({
|
|
653
|
-
provider,
|
|
654
|
-
model,
|
|
655
|
-
apiKeyEnv,
|
|
656
|
-
baseUrl,
|
|
657
|
-
workspace: wctx.workspaceDir,
|
|
658
|
-
});
|
|
659
|
-
return new CorrectionObserver({ runtimeAdapter: adapter });
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
const adapter = new PiAiRuntimeAdapter({
|
|
663
|
-
provider: String(policy.provider),
|
|
664
|
-
model: String(policy.model),
|
|
665
|
-
apiKeyEnv: String(policy.apiKeyEnv),
|
|
666
|
-
maxRetries: policy.maxRetries,
|
|
667
|
-
timeoutMs: policy.timeoutMs ?? 30_000,
|
|
668
|
-
baseUrl: policy.baseUrl,
|
|
669
|
-
workspace: wctx.workspaceDir,
|
|
670
|
-
});
|
|
671
|
-
return new CorrectionObserver({ runtimeAdapter: adapter }, { timeoutMs: policy.timeoutMs });
|
|
672
|
-
} catch (err) {
|
|
673
|
-
logger?.warn?.(`[PD:Correction] Failed to resolve CorrectionObserver: ${String(err)}`);
|
|
674
|
-
return null;
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
|
|
678
628
|
export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
|
|
679
629
|
id: 'principles-evolution-worker',
|
|
680
630
|
api: null,
|
|
@@ -759,78 +709,7 @@ export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
|
|
|
759
709
|
await processDetectionQueue(wctx, api, eventLog);
|
|
760
710
|
}
|
|
761
711
|
// processPromotion removed (D-06) — promotion via PAIN_CANDIDATES no longer needed
|
|
762
|
-
|
|
763
|
-
// ── Correction Observer: periodic keyword optimization (D-40-08 / H-1) ──
|
|
764
|
-
try {
|
|
765
|
-
const observer = resolveCorrectionObserver(wctx, logger);
|
|
766
|
-
if (observer) {
|
|
767
|
-
logger?.info?.('[PD:EvolutionWorker] Correction Observer resolved. Initiating periodic optimization...');
|
|
768
|
-
const db = TrajectoryRegistry.get(wctx.workspaceDir);
|
|
769
|
-
const recentSessions = db.listRecentSessions({ limit: 20 });
|
|
770
|
-
const recentSessionIds = recentSessions.map(s => s.sessionId);
|
|
771
|
-
|
|
772
|
-
if (recentSessionIds.length > 0) {
|
|
773
|
-
const recentMessages: string[] = [];
|
|
774
|
-
for (const sId of recentSessionIds.slice(0, 5)) {
|
|
775
|
-
try {
|
|
776
|
-
const turns = db.listUserTurnsForSession(sId);
|
|
777
|
-
for (const t of turns) {
|
|
778
|
-
if (t.rawExcerpt) {
|
|
779
|
-
recentMessages.push(t.rawExcerpt);
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
} catch (turnErr) {
|
|
783
|
-
logger?.warn?.(`[PD:EvolutionWorker] Failed to load user turns for session ${sId}: ${String(turnErr)}`);
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
const learner = CorrectionCueLearner.get(wctx.stateDir);
|
|
788
|
-
const keywords = learner.getStore().keywords;
|
|
789
|
-
const keywordStoreSummary = {
|
|
790
|
-
totalKeywords: keywords.length,
|
|
791
|
-
terms: keywords.map(k => ({
|
|
792
|
-
term: k.term,
|
|
793
|
-
weight: k.weight,
|
|
794
|
-
hitCount: k.hitCount ?? 0,
|
|
795
|
-
truePositiveCount: k.truePositiveCount ?? 0,
|
|
796
|
-
falsePositiveCount: k.falsePositiveCount ?? 0,
|
|
797
|
-
})),
|
|
798
|
-
};
|
|
799
|
-
|
|
800
|
-
const optimizationService = KeywordOptimizationService.get(wctx.stateDir, wctx.workspaceDir, logger);
|
|
801
|
-
const trajectoryHistory = await optimizationService.buildTrajectoryHistory(recentSessionIds);
|
|
802
|
-
|
|
803
|
-
const payload = {
|
|
804
|
-
parentSessionId: 'evolution-worker',
|
|
805
|
-
workspaceDir: wctx.workspaceDir,
|
|
806
|
-
keywordStoreSummary,
|
|
807
|
-
recentMessages,
|
|
808
|
-
trajectoryHistory,
|
|
809
|
-
};
|
|
810
|
-
|
|
811
|
-
const scheduler = new AgentScheduler();
|
|
812
|
-
scheduler.register({
|
|
813
|
-
agentId: 'correction-observer',
|
|
814
|
-
mode: 'realtime',
|
|
815
|
-
runner: observer,
|
|
816
|
-
});
|
|
817
|
-
|
|
818
|
-
logger?.info?.(`[PD:EvolutionWorker] Dispatching correction-observer with ${trajectoryHistory.length} trajectory events, ${recentMessages.length} recent messages.`);
|
|
819
|
-
const result = await scheduler.dispatch('correction-observer', payload);
|
|
820
|
-
logger?.info?.(`[PD:EvolutionWorker] Correction-observer completed: updated=${result.updated}, summary="${result.summary}"`);
|
|
821
|
-
|
|
822
|
-
if (result.updated) {
|
|
823
|
-
optimizationService.applyResult(result);
|
|
824
|
-
}
|
|
825
|
-
} else {
|
|
826
|
-
logger?.info?.('[PD:EvolutionWorker] No recent sessions found. Skipping correction optimization.');
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
} catch (corrErr) {
|
|
830
|
-
const corrErrMsg = `Correction observer execution failed: ${String(corrErr)}`;
|
|
831
|
-
cycleResult.errors.push(corrErrMsg);
|
|
832
|
-
logger?.warn?.(`[PD:EvolutionWorker] ${corrErrMsg}`);
|
|
833
|
-
}
|
|
712
|
+
// Correction Observer extracted to independent service (PRI-293) — no longer runs on EvolutionWorker heartbeat
|
|
834
713
|
|
|
835
714
|
try {
|
|
836
715
|
const subagentRuntime = api?.runtime?.subagent;
|
|
@@ -0,0 +1,331 @@
|
|
|
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
|
+
import { WorkspaceContext } from '../../src/core/workspace-context.js';
|
|
6
|
+
|
|
7
|
+
const mockLearner = {
|
|
8
|
+
getStore: vi.fn(() => ({ keywords: [{ term: 'wrong', weight: 0.5, hitCount: 3, truePositiveCount: 1, falsePositiveCount: 2 }] })),
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const mockDb = {
|
|
12
|
+
listRecentSessions: vi.fn(() => [{ sessionId: 'session-1' }]),
|
|
13
|
+
listUserTurnsForSession: vi.fn(() => [{ rawExcerpt: 'User said wrong input', correctionDetected: true, correctionCue: 'wrong' }]),
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const mockOptimizationService = {
|
|
17
|
+
buildTrajectoryHistory: vi.fn(async () => [
|
|
18
|
+
{ sessionId: 'session-1', timestamp: 'now', term: 'wrong', userMessage: '' }
|
|
19
|
+
]),
|
|
20
|
+
applyResult: vi.fn(),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
vi.mock('../../src/core/correction-cue-learner.js', () => ({
|
|
24
|
+
CorrectionCueLearner: { get: vi.fn(() => mockLearner) },
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock('../../src/core/trajectory.js', () => ({
|
|
28
|
+
TrajectoryRegistry: {
|
|
29
|
+
get: vi.fn(() => mockDb),
|
|
30
|
+
clear: vi.fn(),
|
|
31
|
+
},
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
vi.mock('../../src/service/keyword-optimization-service.js', () => ({
|
|
35
|
+
KeywordOptimizationService: { get: vi.fn(() => mockOptimizationService) },
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
const mockDispatch = vi.fn().mockResolvedValue({
|
|
39
|
+
updated: true,
|
|
40
|
+
summary: 'Keyword store optimized',
|
|
41
|
+
updates: { wrong: { action: 'update', weight: 0.4, reasoning: 'slightly high FP' } }
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const mockRegister = vi.fn();
|
|
45
|
+
|
|
46
|
+
vi.mock('@principles/core/runtime-v2', () => {
|
|
47
|
+
return {
|
|
48
|
+
WorkflowFunnelLoader: class {
|
|
49
|
+
getFunnel = vi.fn(() => ({
|
|
50
|
+
policy: {
|
|
51
|
+
runtimeKind: 'pi-ai',
|
|
52
|
+
provider: 'anthropic',
|
|
53
|
+
model: 'anthropic/claude-3-5-sonnet',
|
|
54
|
+
apiKeyEnv: 'ANTHROPIC_API_KEY',
|
|
55
|
+
timeoutMs: 30000,
|
|
56
|
+
}
|
|
57
|
+
}));
|
|
58
|
+
},
|
|
59
|
+
PiAiRuntimeAdapter: class {},
|
|
60
|
+
CorrectionObserver: class {},
|
|
61
|
+
AgentScheduler: class {
|
|
62
|
+
register = mockRegister;
|
|
63
|
+
dispatch = mockDispatch;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
import { CorrectionObserverService, runCorrectionObserverCycle } from '../../src/service/correction-observer-service.js';
|
|
69
|
+
import { safeRmDir } from '../test-utils.js';
|
|
70
|
+
|
|
71
|
+
describe('CorrectionObserverService — Independent Service (PRI-293)', () => {
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
vi.useFakeTimers();
|
|
74
|
+
vi.clearAllMocks();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
vi.useRealTimers();
|
|
79
|
+
CorrectionObserverService.stop?.({} as any);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('has correct service id', () => {
|
|
83
|
+
expect(CorrectionObserverService.id).toBe('principles-correction-observer');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('starts and schedules periodic cycles', async () => {
|
|
87
|
+
const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-corr-obs-'));
|
|
88
|
+
const stateDir = path.join(workspaceDir, '.state');
|
|
89
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
90
|
+
|
|
91
|
+
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
CorrectionObserverService.start({
|
|
95
|
+
workspaceDir,
|
|
96
|
+
stateDir,
|
|
97
|
+
logger,
|
|
98
|
+
config: { get: () => undefined },
|
|
99
|
+
} as any);
|
|
100
|
+
|
|
101
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
102
|
+
expect.stringContaining('[PD:CorrectionObserver] Starting')
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
106
|
+
for (let i = 0; i < 20; i++) {
|
|
107
|
+
await Promise.resolve();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
expect(mockRegister).toHaveBeenCalled();
|
|
111
|
+
expect(mockDispatch).toHaveBeenCalledWith('correction-observer', expect.objectContaining({
|
|
112
|
+
parentSessionId: 'correction-observer-service',
|
|
113
|
+
workspaceDir,
|
|
114
|
+
recentMessages: ['User said wrong input'],
|
|
115
|
+
}));
|
|
116
|
+
|
|
117
|
+
expect(mockOptimizationService.applyResult).toHaveBeenCalledWith(expect.objectContaining({
|
|
118
|
+
updated: true,
|
|
119
|
+
summary: 'Keyword store optimized',
|
|
120
|
+
}));
|
|
121
|
+
} finally {
|
|
122
|
+
CorrectionObserverService.stop?.({} as any);
|
|
123
|
+
safeRmDir(workspaceDir);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('stops cleanly and cancels pending timer', () => {
|
|
128
|
+
const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-corr-obs-stop-'));
|
|
129
|
+
const stateDir = path.join(workspaceDir, '.state');
|
|
130
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
131
|
+
|
|
132
|
+
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
CorrectionObserverService.start({
|
|
136
|
+
workspaceDir,
|
|
137
|
+
stateDir,
|
|
138
|
+
logger,
|
|
139
|
+
config: { get: () => undefined },
|
|
140
|
+
} as any);
|
|
141
|
+
|
|
142
|
+
CorrectionObserverService.stop?.({} as any);
|
|
143
|
+
|
|
144
|
+
vi.advanceTimersByTime(30_000);
|
|
145
|
+
|
|
146
|
+
expect(mockDispatch).not.toHaveBeenCalled();
|
|
147
|
+
} finally {
|
|
148
|
+
safeRmDir(workspaceDir);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('does not reschedule after stop during active cycle (P2 fix)', async () => {
|
|
153
|
+
const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-corr-obs-race-'));
|
|
154
|
+
const stateDir = path.join(workspaceDir, '.state');
|
|
155
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
156
|
+
|
|
157
|
+
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
|
158
|
+
|
|
159
|
+
let cycleResolve: () => void;
|
|
160
|
+
const cyclePromise = new Promise<void>(r => { cycleResolve = r; });
|
|
161
|
+
mockDispatch.mockImplementationOnce(async () => {
|
|
162
|
+
cycleResolve!();
|
|
163
|
+
return { updated: false, summary: 'in-flight' };
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
CorrectionObserverService.start({
|
|
168
|
+
workspaceDir,
|
|
169
|
+
stateDir,
|
|
170
|
+
logger,
|
|
171
|
+
config: { get: () => undefined },
|
|
172
|
+
} as any);
|
|
173
|
+
|
|
174
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
175
|
+
await cyclePromise;
|
|
176
|
+
|
|
177
|
+
CorrectionObserverService.stop?.({} as any);
|
|
178
|
+
|
|
179
|
+
vi.advanceTimersByTime(15 * 60 * 1000 * 2);
|
|
180
|
+
|
|
181
|
+
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
|
182
|
+
} finally {
|
|
183
|
+
CorrectionObserverService.stop?.({} as any);
|
|
184
|
+
safeRmDir(workspaceDir);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('logs structured reason when workspaceDir is missing (ERR-002)', () => {
|
|
189
|
+
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
|
190
|
+
|
|
191
|
+
CorrectionObserverService.start({
|
|
192
|
+
workspaceDir: undefined as any,
|
|
193
|
+
logger,
|
|
194
|
+
config: { get: () => undefined },
|
|
195
|
+
} as any);
|
|
196
|
+
|
|
197
|
+
expect(logger.warn).toHaveBeenCalledWith(
|
|
198
|
+
expect.stringContaining('workspaceDir not found')
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('double start same workspace only dispatches one loop (P1 fix)', async () => {
|
|
203
|
+
const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-corr-dbl-'));
|
|
204
|
+
const stateDir = path.join(workspaceDir, '.state');
|
|
205
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
206
|
+
|
|
207
|
+
const logger1 = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
|
208
|
+
const logger2 = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
CorrectionObserverService.start({
|
|
212
|
+
workspaceDir,
|
|
213
|
+
stateDir,
|
|
214
|
+
logger: logger1,
|
|
215
|
+
config: { get: () => undefined },
|
|
216
|
+
} as any);
|
|
217
|
+
|
|
218
|
+
CorrectionObserverService.start({
|
|
219
|
+
workspaceDir,
|
|
220
|
+
stateDir,
|
|
221
|
+
logger: logger2,
|
|
222
|
+
config: { get: () => undefined },
|
|
223
|
+
} as any);
|
|
224
|
+
|
|
225
|
+
expect(logger2.info).toHaveBeenCalledWith(
|
|
226
|
+
expect.stringContaining('Already started')
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
230
|
+
for (let i = 0; i < 20; i++) {
|
|
231
|
+
await Promise.resolve();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
|
235
|
+
} finally {
|
|
236
|
+
CorrectionObserverService.stop?.({} as any);
|
|
237
|
+
safeRmDir(workspaceDir);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('stop after double start cancels all timers and allows clean restart', async () => {
|
|
242
|
+
const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-corr-stopdbl-'));
|
|
243
|
+
const stateDir = path.join(workspaceDir, '.state');
|
|
244
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
245
|
+
|
|
246
|
+
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
CorrectionObserverService.start({
|
|
250
|
+
workspaceDir,
|
|
251
|
+
stateDir,
|
|
252
|
+
logger,
|
|
253
|
+
config: { get: () => undefined },
|
|
254
|
+
} as any);
|
|
255
|
+
|
|
256
|
+
CorrectionObserverService.start({
|
|
257
|
+
workspaceDir,
|
|
258
|
+
stateDir,
|
|
259
|
+
logger,
|
|
260
|
+
config: { get: () => undefined },
|
|
261
|
+
} as any);
|
|
262
|
+
|
|
263
|
+
CorrectionObserverService.stop?.({} as any);
|
|
264
|
+
|
|
265
|
+
vi.advanceTimersByTime(30_000);
|
|
266
|
+
|
|
267
|
+
expect(mockDispatch).not.toHaveBeenCalled();
|
|
268
|
+
|
|
269
|
+
CorrectionObserverService.start({
|
|
270
|
+
workspaceDir,
|
|
271
|
+
stateDir,
|
|
272
|
+
logger,
|
|
273
|
+
config: { get: () => undefined },
|
|
274
|
+
} as any);
|
|
275
|
+
|
|
276
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
277
|
+
expect.stringContaining('[PD:CorrectionObserver] Starting')
|
|
278
|
+
);
|
|
279
|
+
} finally {
|
|
280
|
+
CorrectionObserverService.stop?.({} as any);
|
|
281
|
+
safeRmDir(workspaceDir);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe('runCorrectionObserverCycle — Independent Execution', () => {
|
|
287
|
+
it('skips cycle when no recent sessions exist', async () => {
|
|
288
|
+
const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-corr-cycle-'));
|
|
289
|
+
const stateDir = path.join(workspaceDir, '.state');
|
|
290
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
291
|
+
|
|
292
|
+
const wctx = WorkspaceContext.fromHookContext({ workspaceDir });
|
|
293
|
+
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
|
294
|
+
|
|
295
|
+
mockDb.listRecentSessions.mockReturnValueOnce([]);
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
await runCorrectionObserverCycle(wctx, logger as any);
|
|
299
|
+
|
|
300
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
301
|
+
expect.stringContaining('No recent sessions found')
|
|
302
|
+
);
|
|
303
|
+
expect(mockDispatch).not.toHaveBeenCalled();
|
|
304
|
+
} finally {
|
|
305
|
+
safeRmDir(workspaceDir);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('logs structured error on cycle failure (ERR-002)', async () => {
|
|
310
|
+
const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-corr-err-'));
|
|
311
|
+
const stateDir = path.join(workspaceDir, '.state');
|
|
312
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
313
|
+
|
|
314
|
+
const wctx = WorkspaceContext.fromHookContext({ workspaceDir });
|
|
315
|
+
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
|
316
|
+
|
|
317
|
+
mockDb.listRecentSessions.mockImplementationOnce(() => {
|
|
318
|
+
throw new Error('DB connection failed');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
await runCorrectionObserverCycle(wctx, logger as any);
|
|
323
|
+
|
|
324
|
+
expect(logger.warn).toHaveBeenCalledWith(
|
|
325
|
+
expect.stringContaining('Correction observer cycle failed')
|
|
326
|
+
);
|
|
327
|
+
} finally {
|
|
328
|
+
safeRmDir(workspaceDir);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
});
|
|
@@ -1,173 +1,50 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
const mockDb = {
|
|
14
|
-
listRecentSessions: vi.fn(() => [{ sessionId: 'session-1' }]),
|
|
15
|
-
listUserTurnsForSession: vi.fn(() => [{ rawExcerpt: 'User said wrong input', correctionDetected: true, correctionCue: 'wrong' }]),
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
const mockOptimizationService = {
|
|
19
|
-
buildTrajectoryHistory: vi.fn(async () => [
|
|
20
|
-
{ sessionId: 'session-1', timestamp: 'now', term: 'wrong', userMessage: '' }
|
|
21
|
-
]),
|
|
22
|
-
applyResult: vi.fn(),
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
// Mock dependencies
|
|
26
|
-
vi.mock('../../src/core/correction-cue-learner.js', () => ({
|
|
27
|
-
CorrectionCueLearner: { get: vi.fn(() => mockLearner) },
|
|
28
|
-
}));
|
|
29
|
-
|
|
30
|
-
vi.mock('../../src/core/trajectory.js', () => ({
|
|
31
|
-
TrajectoryRegistry: {
|
|
32
|
-
get: vi.fn(() => mockDb),
|
|
33
|
-
clear: vi.fn(),
|
|
34
|
-
},
|
|
35
|
-
}));
|
|
36
|
-
|
|
37
|
-
vi.mock('../../src/service/keyword-optimization-service.js', () => ({
|
|
38
|
-
KeywordOptimizationService: { get: vi.fn(() => mockOptimizationService) },
|
|
39
|
-
}));
|
|
40
|
-
|
|
41
|
-
// Mock principles-core runtime-v2 observer/scheduler classes
|
|
42
|
-
const mockDispatch = vi.fn().mockResolvedValue({
|
|
43
|
-
updated: true,
|
|
44
|
-
summary: 'Keyword store optimized',
|
|
45
|
-
updates: { wrong: { action: 'update', weight: 0.4, reasoning: 'slightly high FP' } }
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
const mockRegister = vi.fn();
|
|
49
|
-
|
|
50
|
-
vi.mock('@principles/core/runtime-v2', () => {
|
|
51
|
-
return {
|
|
52
|
-
WorkflowFunnelLoader: class {
|
|
53
|
-
getFunnel = vi.fn(() => ({
|
|
54
|
-
policy: {
|
|
55
|
-
runtimeKind: 'pi-ai',
|
|
56
|
-
provider: 'anthropic',
|
|
57
|
-
model: 'anthropic/claude-3-5-sonnet',
|
|
58
|
-
apiKeyEnv: 'ANTHROPIC_API_KEY',
|
|
59
|
-
timeoutMs: 30000,
|
|
60
|
-
}
|
|
61
|
-
}));
|
|
62
|
-
},
|
|
63
|
-
PiAiRuntimeAdapter: class {},
|
|
64
|
-
CorrectionObserver: class {},
|
|
65
|
-
AgentScheduler: class {
|
|
66
|
-
register = mockRegister;
|
|
67
|
-
dispatch = mockDispatch;
|
|
68
|
-
}
|
|
69
|
-
};
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
// Import EvolutionWorkerService
|
|
73
|
-
import { EvolutionWorkerService } from '../../src/service/evolution-worker.js';
|
|
74
|
-
import { safeRmDir } from '../test-utils.js';
|
|
75
|
-
|
|
76
|
-
function createMockApi() {
|
|
77
|
-
return {
|
|
78
|
-
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
79
|
-
runtime: {
|
|
80
|
-
agent: { runEmbeddedPiAgent: vi.fn() },
|
|
81
|
-
system: {
|
|
82
|
-
requestHeartbeatNow: vi.fn(),
|
|
83
|
-
runHeartbeatOnce: vi.fn(),
|
|
84
|
-
},
|
|
85
|
-
},
|
|
86
|
-
} as any;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const fastPollConfig = { get: (k: string) => k === 'intervals.worker_poll_ms' ? 1000 : undefined };
|
|
90
|
-
|
|
91
|
-
describe('EvolutionWorkerService Correction Observer Integration', () => {
|
|
92
|
-
beforeEach(() => {
|
|
93
|
-
vi.useFakeTimers();
|
|
94
|
-
vi.clearAllMocks();
|
|
95
|
-
EvolutionWorkerService.api = null;
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
afterEach(() => {
|
|
99
|
-
vi.useRealTimers();
|
|
100
|
-
EvolutionWorkerService.api = null;
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { PLUGIN_SURFACE_REGISTRY } from '@principles/core/runtime-v2';
|
|
3
|
+
import { DEFAULT_FEATURE_FLAGS, computeEffectiveFlags } from '@principles/core/runtime-v2';
|
|
4
|
+
|
|
5
|
+
describe('Correction Observer Ownership — Feature Flag & Surface Registry Consistency (PRI-293, ERR-027)', () => {
|
|
6
|
+
it('correction_observer feature flag is registered as quiet with enabled:true (disableable runtime kill switch)', () => {
|
|
7
|
+
const flag = DEFAULT_FEATURE_FLAGS.find(f => f.id === 'correction_observer');
|
|
8
|
+
expect(flag).toBeDefined();
|
|
9
|
+
expect(flag!.category).toBe('quiet');
|
|
10
|
+
expect(flag!.enabled).toBe(true);
|
|
101
11
|
});
|
|
102
12
|
|
|
103
|
-
it('
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
// Initialize an empty queue to avoid processing actual queue items
|
|
109
|
-
fs.writeFileSync(
|
|
110
|
-
path.join(stateDir, 'evolution_queue.json'),
|
|
111
|
-
JSON.stringify([], null, 2),
|
|
112
|
-
'utf8'
|
|
13
|
+
it('correction_observer can be disabled via workspace config (P1 fix — runtime kill switch)', () => {
|
|
14
|
+
const result = computeEffectiveFlags(
|
|
15
|
+
{ correction_observer: { enabled: false } },
|
|
16
|
+
DEFAULT_FEATURE_FLAGS,
|
|
17
|
+
'.pd/feature-flags.yaml',
|
|
113
18
|
);
|
|
19
|
+
expect(result.flags['correction_observer'].enabled).toBe(false);
|
|
20
|
+
expect(result.warnings).not.toContain(expect.stringContaining('core flag cannot be disabled'));
|
|
21
|
+
});
|
|
114
22
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
}, null, 2),
|
|
123
|
-
'utf8'
|
|
124
|
-
);
|
|
125
|
-
|
|
126
|
-
// Invalidate workspace cache to load the newly written settings
|
|
127
|
-
WorkspaceContext.clearCache();
|
|
128
|
-
ConfigService.reset();
|
|
129
|
-
|
|
130
|
-
const mockApi = createMockApi();
|
|
131
|
-
EvolutionWorkerService.api = mockApi;
|
|
132
|
-
|
|
133
|
-
try {
|
|
134
|
-
EvolutionWorkerService.start({
|
|
135
|
-
workspaceDir,
|
|
136
|
-
stateDir,
|
|
137
|
-
logger: mockApi.logger,
|
|
138
|
-
config: fastPollConfig,
|
|
139
|
-
api: mockApi,
|
|
140
|
-
} as any);
|
|
141
|
-
|
|
142
|
-
// 1. Advance to startup timer (5000ms) and wait for microtasks to settle
|
|
143
|
-
await vi.advanceTimersByTimeAsync(5000);
|
|
144
|
-
for (let i = 0; i < 20; i++) {
|
|
145
|
-
await Promise.resolve();
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// 2. Advance past the poll interval (1000ms) to trigger runCycle
|
|
149
|
-
await vi.advanceTimersByTimeAsync(1050);
|
|
150
|
-
for (let i = 0; i < 20; i++) {
|
|
151
|
-
await Promise.resolve();
|
|
152
|
-
}
|
|
23
|
+
it('service:correction-observer surface is registered as core with enabledByDefault:true', () => {
|
|
24
|
+
const surface = PLUGIN_SURFACE_REGISTRY.find(s => s.id === 'service:correction-observer');
|
|
25
|
+
expect(surface).toBeDefined();
|
|
26
|
+
expect(surface!.category).toBe('core');
|
|
27
|
+
expect(surface!.enabledByDefault).toBe(true);
|
|
28
|
+
});
|
|
153
29
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}));
|
|
30
|
+
it('startup:correction-observer surface is registered as core with enabledByDefault:true', () => {
|
|
31
|
+
const surface = PLUGIN_SURFACE_REGISTRY.find(s => s.id === 'startup:correction-observer');
|
|
32
|
+
expect(surface).toBeDefined();
|
|
33
|
+
expect(surface!.category).toBe('core');
|
|
34
|
+
expect(surface!.enabledByDefault).toBe(true);
|
|
35
|
+
});
|
|
161
36
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
37
|
+
it('evolution_worker feature flag remains quiet with enabled:false', () => {
|
|
38
|
+
const flag = DEFAULT_FEATURE_FLAGS.find(f => f.id === 'evolution_worker');
|
|
39
|
+
expect(flag).toBeDefined();
|
|
40
|
+
expect(flag!.category).toBe('quiet');
|
|
41
|
+
expect(flag!.enabled).toBe(false);
|
|
42
|
+
});
|
|
167
43
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
44
|
+
it('service:evolution-worker surface remains quiet with enabledByDefault:false', () => {
|
|
45
|
+
const surface = PLUGIN_SURFACE_REGISTRY.find(s => s.id === 'service:evolution-worker');
|
|
46
|
+
expect(surface).toBeDefined();
|
|
47
|
+
expect(surface!.category).toBe('quiet');
|
|
48
|
+
expect(surface!.enabledByDefault).toBe(false);
|
|
172
49
|
});
|
|
173
50
|
});
|