prism-mcp-server 7.2.0 → 7.3.3

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.
@@ -0,0 +1,683 @@
1
+ /**
2
+ * Dark Factory Background Runner (v7.3)
3
+ *
4
+ * Non-blocking background loop that:
5
+ * 1. On startup: marks stale RUNNING pipelines as FAILED ("Unexpected Server Termination")
6
+ * 2. On each tick: picks up RUNNING pipelines and advances their step
7
+ * 3. Executes the PLAN → EXECUTE → VERIFY → iterate cycle
8
+ * 4. Pulses heartbeat during LLM execution
9
+ * 5. Sweeps for zombie pipelines (lapsed heartbeats)
10
+ * 6. Emits experience events on completion/failure
11
+ *
12
+ * CRITICAL: This module MUST NOT block the MCP event loop.
13
+ * - Uses setInterval (not while-true) to yield between ticks
14
+ * - All errors are caught — crashes never propagate to the MCP server
15
+ * - Only ONE pipeline step executes per tick (sequential, not parallel)
16
+ *
17
+ * CRITICAL: All logging MUST use console.error() (stderr).
18
+ * Using console.log() (stdout) will corrupt the MCP JSON-RPC stream.
19
+ */
20
+ import { getStorage } from '../storage/index.js';
21
+ import { VALID_ACTION_TYPES } from './schema.js';
22
+ import { SafetyController } from './safetyController.js';
23
+ import { invokeClawAgent } from './clawInvocation.js';
24
+ import { PRISM_DARK_FACTORY_POLL_MS, PRISM_DARK_FACTORY_MAX_RUNTIME_MS, PRISM_USER_ID, PRISM_VERIFICATION_LAYERS, PRISM_VERIFICATION_DEFAULT_SEVERITY } from '../config.js';
25
+ import { debugLog } from '../utils/logger.js';
26
+ import path from 'path';
27
+ import fs from 'fs';
28
+ import * as crypto from 'crypto';
29
+ import { Gatekeeper } from '../verification/gatekeeper.js';
30
+ import { VerificationRunner } from '../verification/runner.js';
31
+ import { computeRubricHash } from '../verification/schema.js';
32
+ import { VerificationGateError } from '../errors.js';
33
+ /** Interval handle for graceful shutdown */
34
+ let runnerInterval = null;
35
+ /** Tracks whether the runner is currently processing a tick (prevents overlap) */
36
+ let tickInProgress = false;
37
+ // ─── Startup Initialization ──────────────────────────────────
38
+ /**
39
+ * Called once during server startup after storage is warm.
40
+ * Marks any stale RUNNING pipelines as FAILED — they were orphaned
41
+ * by a previous server crash or OOM event.
42
+ */
43
+ async function recoverStalePipelines() {
44
+ try {
45
+ const storage = await getStorage();
46
+ const runningPipelines = await storage.listPipelines(undefined, 'RUNNING', PRISM_USER_ID);
47
+ if (runningPipelines.length === 0) {
48
+ debugLog('[DarkFactory] No stale pipelines found on startup.');
49
+ return;
50
+ }
51
+ debugLog(`[DarkFactory] Found ${runningPipelines.length} stale RUNNING pipeline(s) — marking as FAILED.`);
52
+ for (const pipeline of runningPipelines) {
53
+ try {
54
+ await storage.savePipeline({
55
+ ...pipeline,
56
+ status: 'FAILED',
57
+ error: 'Unexpected Server Termination: pipeline was RUNNING when server restarted.',
58
+ current_step: pipeline.current_step,
59
+ });
60
+ debugLog(`[DarkFactory] Pipeline ${pipeline.id} marked FAILED (was RUNNING on restart).`);
61
+ }
62
+ catch (err) {
63
+ // Status guard may fire if pipeline was already ABORTED/COMPLETED
64
+ // by a concurrent process — safe to ignore.
65
+ console.error(`[DarkFactory] Failed to recover pipeline ${pipeline.id}: ${err instanceof Error ? err.message : String(err)}`);
66
+ }
67
+ }
68
+ }
69
+ catch (err) {
70
+ console.error(`[DarkFactory] Startup recovery failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
71
+ }
72
+ }
73
+ // ─── Heartbeat ─────────────────────────────────────────────────
74
+ /**
75
+ * Pulse the heartbeat timestamp for a running pipeline.
76
+ * Called periodically during LLM execution to prove liveness.
77
+ * This is intentionally cheap — just a timestamp update.
78
+ */
79
+ async function pulseHeartbeat(pipelineId, userId) {
80
+ try {
81
+ const storage = await getStorage();
82
+ const pipeline = await storage.getPipeline(pipelineId, userId);
83
+ if (!pipeline || pipeline.status !== 'RUNNING')
84
+ return;
85
+ await storage.savePipeline({
86
+ ...pipeline,
87
+ last_heartbeat: new Date().toISOString(),
88
+ });
89
+ }
90
+ catch {
91
+ // Heartbeat failures are non-fatal — the zombie sweep will catch it
92
+ }
93
+ }
94
+ /**
95
+ * Creates a heartbeat interval that pulses every 15 seconds during execution.
96
+ * Returns a cleanup function to stop the interval.
97
+ */
98
+ function startHeartbeatInterval(pipelineId, userId) {
99
+ const interval = setInterval(() => {
100
+ pulseHeartbeat(pipelineId, userId).catch(() => { });
101
+ }, 15_000);
102
+ return () => clearInterval(interval);
103
+ }
104
+ // ─── Zombie Sweep ─────────────────────────────────────────────
105
+ /**
106
+ * Find RUNNING pipelines whose heartbeat has lapsed and mark them FAILED.
107
+ * This catches pipelines where the LLM call silently hung or the runner
108
+ * crashed mid-execution without updating status.
109
+ */
110
+ async function sweepZombies() {
111
+ try {
112
+ const storage = await getStorage();
113
+ const running = await storage.listPipelines(undefined, 'RUNNING', PRISM_USER_ID);
114
+ for (const pipeline of running) {
115
+ if (SafetyController.isHeartbeatLapsed(pipeline)) {
116
+ debugLog(`[DarkFactory] Zombie detected: pipeline ${pipeline.id} heartbeat lapsed.`);
117
+ try {
118
+ await storage.savePipeline({
119
+ ...pipeline,
120
+ status: 'FAILED',
121
+ error: `Zombie pipeline: no heartbeat for ${SafetyController.HEARTBEAT_TIMEOUT_MS / 1000}s.`,
122
+ });
123
+ }
124
+ catch {
125
+ // Status guard may fire — pipeline was already terminated
126
+ }
127
+ // Emit failure experience event
128
+ await emitExperienceEvent(pipeline, 'failure', 'Pipeline zombie-swept due to heartbeat lapse.');
129
+ }
130
+ }
131
+ }
132
+ catch (err) {
133
+ console.error(`[DarkFactory] Zombie sweep failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
134
+ }
135
+ }
136
+ // ─── Experience Event Emission ─────────────────────────────────
137
+ /**
138
+ * Emit a structured experience event to the session ledger.
139
+ * This feeds the ML routing system (v7.2) so future pipelines benefit
140
+ * from past success/failure patterns.
141
+ */
142
+ async function emitExperienceEvent(pipeline, eventType, outcome) {
143
+ try {
144
+ const storage = await getStorage();
145
+ const spec = JSON.parse(pipeline.spec);
146
+ const summary = `[${eventType.toUpperCase()}] Dark Factory pipeline ${pipeline.id} → ${spec.objective.slice(0, 100)} → ${outcome.slice(0, 200)}`;
147
+ // Use saveLedger directly (same pattern as sessionSaveExperienceHandler)
148
+ await storage.saveLedger({
149
+ project: pipeline.project,
150
+ conversation_id: `dark-factory-${pipeline.id}`,
151
+ user_id: pipeline.user_id,
152
+ event_type: eventType,
153
+ summary,
154
+ decisions: [
155
+ `Context: Dark Factory autonomous pipeline`,
156
+ `Action: ${spec.objective.slice(0, 200)}`,
157
+ `Outcome: ${outcome.slice(0, 200)}`,
158
+ `Iterations: ${pipeline.iteration}`,
159
+ `Final Step: ${pipeline.current_step}`,
160
+ ],
161
+ keywords: ['dark-factory', 'autonomous', eventType, pipeline.project],
162
+ importance: eventType === 'failure' ? 1 : 0,
163
+ });
164
+ debugLog(`[DarkFactory] Experience event emitted: ${eventType} for pipeline ${pipeline.id}`);
165
+ }
166
+ catch (err) {
167
+ // Experience events are advisory — never block execution
168
+ console.error(`[DarkFactory] Experience event failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
169
+ }
170
+ }
171
+ // ─── EXECUTE Output Parsing ────────────────────────────────────
172
+ /**
173
+ * Defensively parse raw LLM output into an ExecutionStepResult.
174
+ *
175
+ * The LLM is instructed to return pure JSON, but in practice may:
176
+ * - Wrap JSON in markdown code fences (```json ... ```)
177
+ * - Include preamble text before the JSON ("Here's my output:\n{...}")
178
+ * - Include trailing commentary after the JSON
179
+ *
180
+ * Extraction strategy (ordered from most to least precise):
181
+ * 1. Try raw input as-is (pure JSON)
182
+ * 2. Strip markdown code fences and try the inner content
183
+ * 3. Extract first { ... last } and try as JSON (brace extraction)
184
+ * 4. Give up — return parse error
185
+ *
186
+ * After successful JSON parse, validates shape:
187
+ * - Root must be an object with `actions` array
188
+ * - Each action must have a valid ActionType and non-empty targetPath
189
+ *
190
+ * Returns { parsed, error } — exactly one will be non-null.
191
+ *
192
+ * @internal Exported for unit testing only. Not part of the public API.
193
+ */
194
+ export function parseExecuteOutput(raw) {
195
+ if (!raw || typeof raw !== 'string' || raw.trim() === '') {
196
+ return { parsed: null, error: 'JSON Parse Error: empty or non-string input' };
197
+ }
198
+ const cleaned = raw.trim();
199
+ let jsonCandidate = null;
200
+ // Strategy 1: Try raw trimmed input as-is
201
+ if (cleaned.startsWith('{')) {
202
+ jsonCandidate = cleaned;
203
+ }
204
+ // Strategy 2: Strip markdown code fences
205
+ if (!jsonCandidate) {
206
+ // Match ```json or ``` blocks anywhere in the text (not just start/end of string)
207
+ const fenceMatch = cleaned.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
208
+ if (fenceMatch) {
209
+ jsonCandidate = fenceMatch[1].trim();
210
+ }
211
+ }
212
+ // Strategy 3: Brace extraction — find first { to last }
213
+ if (!jsonCandidate) {
214
+ const firstBrace = cleaned.indexOf('{');
215
+ const lastBrace = cleaned.lastIndexOf('}');
216
+ if (firstBrace !== -1 && lastBrace > firstBrace) {
217
+ jsonCandidate = cleaned.slice(firstBrace, lastBrace + 1);
218
+ }
219
+ }
220
+ if (!jsonCandidate) {
221
+ return { parsed: null, error: 'JSON Parse Error: no JSON object found in LLM output' };
222
+ }
223
+ // Attempt JSON parse
224
+ let parsed;
225
+ try {
226
+ parsed = JSON.parse(jsonCandidate);
227
+ }
228
+ catch {
229
+ return { parsed: null, error: 'JSON Parse Error: LLM output is not valid JSON' };
230
+ }
231
+ // Shape validation: must be an object with an 'actions' array
232
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
233
+ return { parsed: null, error: 'Shape Error: output is not a JSON object' };
234
+ }
235
+ if (!Array.isArray(parsed.actions)) {
236
+ return { parsed: null, error: 'Shape Error: output missing required "actions" array' };
237
+ }
238
+ const result = parsed;
239
+ // Validate each action in the array
240
+ for (let i = 0; i < result.actions.length; i++) {
241
+ const action = result.actions[i];
242
+ if (!action || typeof action !== 'object' || Array.isArray(action)) {
243
+ return { parsed: null, error: `Shape Error: actions[${i}] is not an object` };
244
+ }
245
+ if (!action.type || !VALID_ACTION_TYPES.includes(action.type)) {
246
+ return { parsed: null, error: `Shape Error: actions[${i}].type "${action.type}" is not a valid ActionType` };
247
+ }
248
+ if (!action.targetPath || typeof action.targetPath !== 'string' || action.targetPath.trim() === '') {
249
+ return { parsed: null, error: `Shape Error: actions[${i}].targetPath is empty or missing` };
250
+ }
251
+ }
252
+ return { parsed: result, error: null };
253
+ }
254
+ // ─── Step Execution ────────────────────────────────────────────
255
+ /**
256
+ * Execute a single step of the pipeline.
257
+ * Returns an IterationResult with success/failure status.
258
+ *
259
+ * v7.3.1: EXECUTE steps are parsed as structured JSON. Malformed output
260
+ * or out-of-scope actions cause immediate step failure (fail closed).
261
+ * The `scopeViolation` field on the result signals the runner to
262
+ * terminate the entire pipeline (not just the step).
263
+ */
264
+ async function executeStep(pipeline, spec) {
265
+ const stepStart = new Date().toISOString();
266
+ const step = pipeline.current_step;
267
+ debugLog(`[DarkFactory] Executing step=${step} iter=${pipeline.iteration} pipeline=${pipeline.id}`);
268
+ // Start heartbeat pulse during LLM execution
269
+ const stopHeartbeat = startHeartbeatInterval(pipeline.id, pipeline.user_id);
270
+ try {
271
+ // All steps use the Claw invocation wrapper which applies:
272
+ // - SafetyController boundary prompt
273
+ // - BYOM model override
274
+ // - Timeout enforcement
275
+ const { success, resultText } = await invokeClawAgent(spec, pipeline);
276
+ // For non-EXECUTE steps, return as-is (free-form text)
277
+ if (step !== 'EXECUTE') {
278
+ return {
279
+ iteration: pipeline.iteration,
280
+ step,
281
+ started_at: stepStart,
282
+ completed_at: new Date().toISOString(),
283
+ success,
284
+ notes: resultText.slice(0, 2000),
285
+ };
286
+ }
287
+ // ── v7.3.1: EXECUTE step — parse and validate structured output ──
288
+ if (!success) {
289
+ // LLM invocation itself failed (timeout, error, etc.)
290
+ return {
291
+ iteration: pipeline.iteration,
292
+ step,
293
+ started_at: stepStart,
294
+ completed_at: new Date().toISOString(),
295
+ success: false,
296
+ notes: `LLM invocation failed: ${resultText.slice(0, 500)}`,
297
+ };
298
+ }
299
+ // Parse the structured JSON output
300
+ const { parsed, error: parseError } = parseExecuteOutput(resultText);
301
+ if (parseError || !parsed) {
302
+ debugLog(`[DarkFactory] EXECUTE output parse failure: ${parseError}`);
303
+ return {
304
+ iteration: pipeline.iteration,
305
+ step,
306
+ started_at: stepStart,
307
+ completed_at: new Date().toISOString(),
308
+ success: false,
309
+ notes: parseError || 'Unknown parse error',
310
+ };
311
+ }
312
+ // Empty actions array is valid (LLM decided nothing needs doing)
313
+ if (parsed.actions.length === 0) {
314
+ return {
315
+ iteration: pipeline.iteration,
316
+ step,
317
+ started_at: stepStart,
318
+ completed_at: new Date().toISOString(),
319
+ success: true,
320
+ notes: parsed.notes || 'No actions taken',
321
+ };
322
+ }
323
+ // Validate ALL actions are within scope BEFORE any execution
324
+ const scopeError = SafetyController.validateActionsInScope(parsed.actions, spec);
325
+ if (scopeError) {
326
+ debugLog(`[DarkFactory] EXECUTE scope violation: ${scopeError}`);
327
+ return {
328
+ iteration: pipeline.iteration,
329
+ step,
330
+ started_at: stepStart,
331
+ completed_at: new Date().toISOString(),
332
+ success: false,
333
+ notes: `Scope Violation: ${scopeError}`,
334
+ scopeViolation: scopeError,
335
+ };
336
+ }
337
+ // All actions validated — return success with structured notes
338
+ return {
339
+ iteration: pipeline.iteration,
340
+ step,
341
+ started_at: stepStart,
342
+ completed_at: new Date().toISOString(),
343
+ success: true,
344
+ notes: parsed.notes || `Executed ${parsed.actions.length} action(s) successfully`,
345
+ };
346
+ }
347
+ finally {
348
+ stopHeartbeat();
349
+ }
350
+ }
351
+ // ─── Main Tick ─────────────────────────────────────────────────
352
+ /**
353
+ * A single tick of the runner loop. Picks up one RUNNING pipeline
354
+ * and advances it by one step.
355
+ *
356
+ * Design: Sequential execution (one pipeline per tick) to prevent
357
+ * resource starvation. The poll interval (default 30s) determines throughput.
358
+ */
359
+ async function runnerTick() {
360
+ // Guard: prevent overlapping ticks if a previous LLM call runs long
361
+ if (tickInProgress) {
362
+ debugLog('[DarkFactory] Tick skipped — previous tick still in progress.');
363
+ return;
364
+ }
365
+ tickInProgress = true;
366
+ try {
367
+ // Phase 1: Zombie sweep (cheap — just DB reads)
368
+ await sweepZombies();
369
+ // Phase 2: Promote PENDING → RUNNING, then find a RUNNING pipeline to advance
370
+ const storage = await getStorage();
371
+ // Pick up PENDING pipelines and promote to RUNNING (queue → active)
372
+ const pending = await storage.listPipelines(undefined, 'PENDING', PRISM_USER_ID);
373
+ if (pending.length > 0) {
374
+ // Promote oldest PENDING pipeline (FIFO)
375
+ const toPromote = pending.sort((a, b) => new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime())[0];
376
+ debugLog(`[DarkFactory] Promoting PENDING pipeline ${toPromote.id} → RUNNING`);
377
+ await storage.savePipeline({ ...toPromote, status: 'RUNNING' });
378
+ }
379
+ const running = await storage.listPipelines(undefined, 'RUNNING', PRISM_USER_ID);
380
+ if (running.length === 0) {
381
+ return; // Nothing to do
382
+ }
383
+ // Pick the oldest updated pipeline (FIFO fairness)
384
+ const pipeline = running.sort((a, b) => new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime())[0];
385
+ // Poison Pill Guard: Parse spec with localized error handling.
386
+ // If a pipeline has corrupt/invalid JSON in its spec column,
387
+ // we MUST mark it FAILED immediately. Otherwise the runner will
388
+ // re-fetch the same broken pipeline every tick (infinite loop).
389
+ let spec;
390
+ try {
391
+ spec = JSON.parse(pipeline.spec);
392
+ }
393
+ catch (parseErr) {
394
+ debugLog(`[DarkFactory] Pipeline ${pipeline.id} has invalid spec JSON — marking FAILED.`);
395
+ try {
396
+ await storage.savePipeline({
397
+ ...pipeline,
398
+ status: 'FAILED',
399
+ error: `Invalid spec JSON: ${parseErr instanceof Error ? parseErr.message : 'parse error'}`,
400
+ });
401
+ }
402
+ catch {
403
+ // Status guard — already terminated
404
+ }
405
+ return;
406
+ }
407
+ // Safety check: wall-clock runtime exceeded?
408
+ if (SafetyController.isRuntimeExceeded(pipeline)) {
409
+ debugLog(`[DarkFactory] Pipeline ${pipeline.id} exceeded max runtime — aborting.`);
410
+ try {
411
+ await storage.savePipeline({
412
+ ...pipeline,
413
+ status: 'FAILED',
414
+ error: `Pipeline exceeded maximum runtime (${PRISM_DARK_FACTORY_MAX_RUNTIME_MS}ms). Aborted by safety controller.`,
415
+ });
416
+ }
417
+ catch {
418
+ // Status guard — already terminated
419
+ }
420
+ await emitExperienceEvent(pipeline, 'failure', 'Exceeded maximum runtime.');
421
+ return;
422
+ }
423
+ // Safety check: runtime path-scope enforcement
424
+ // Validates that the working directory exists and is a real path.
425
+ // isPathWithinScope() is called later during actual file operations,
426
+ // but we gate-check the workspace root here to fail fast.
427
+ if (spec.workingDirectory) {
428
+ const resolvedDir = path.resolve(spec.workingDirectory);
429
+ if (!fs.existsSync(resolvedDir)) {
430
+ debugLog(`[DarkFactory] Pipeline ${pipeline.id} working directory does not exist: ${spec.workingDirectory}`);
431
+ try {
432
+ await storage.savePipeline({
433
+ ...pipeline,
434
+ status: 'FAILED',
435
+ error: `Working directory does not exist: ${spec.workingDirectory}`,
436
+ });
437
+ }
438
+ catch { /* Status guard */ }
439
+ await emitExperienceEvent(pipeline, 'failure', `Working directory not found: ${spec.workingDirectory}`);
440
+ return;
441
+ }
442
+ // Verify path scope — prevents path traversal attacks where
443
+ // a crafted spec.workingDirectory escapes the intended scope.
444
+ if (!SafetyController.isPathWithinScope(resolvedDir, spec)) {
445
+ debugLog(`[DarkFactory] Pipeline ${pipeline.id} working directory out of scope: ${spec.workingDirectory}`);
446
+ try {
447
+ await storage.savePipeline({
448
+ ...pipeline,
449
+ status: 'FAILED',
450
+ error: `Working directory out of permitted scope: ${spec.workingDirectory}`,
451
+ });
452
+ }
453
+ catch { /* Status guard */ }
454
+ await emitExperienceEvent(pipeline, 'failure', `Path scope violation: ${spec.workingDirectory}`);
455
+ return;
456
+ }
457
+ }
458
+ // Safety check: iteration limit exceeded?
459
+ if (!SafetyController.validateIterationLimit(pipeline.iteration, spec)) {
460
+ debugLog(`[DarkFactory] Pipeline ${pipeline.id} exceeded iteration limit — aborting.`);
461
+ try {
462
+ await storage.savePipeline({
463
+ ...pipeline,
464
+ status: 'FAILED',
465
+ error: `Pipeline exceeded max iterations (${spec.maxIterations}). Aborted by safety controller.`,
466
+ });
467
+ }
468
+ catch {
469
+ // Status guard
470
+ }
471
+ await emitExperienceEvent(pipeline, 'failure', `Exceeded max iterations (${spec.maxIterations}).`);
472
+ return;
473
+ }
474
+ // Execute the current step
475
+ const result = await executeStep(pipeline, spec);
476
+ // v7.3.1: Scope violation in EXECUTE step → immediate pipeline termination
477
+ if ('scopeViolation' in result && result.scopeViolation) {
478
+ debugLog(`[DarkFactory] Pipeline ${pipeline.id} terminated: scope violation in EXECUTE step.`);
479
+ try {
480
+ await storage.savePipeline({
481
+ ...pipeline,
482
+ status: 'FAILED',
483
+ error: `Scope violation during EXECUTE: ${result.scopeViolation}`,
484
+ });
485
+ }
486
+ catch { /* Status guard */ }
487
+ await emitExperienceEvent(pipeline, 'failure', `Scope violation: ${result.scopeViolation}`);
488
+ return;
489
+ }
490
+ const currentStep = pipeline.current_step;
491
+ // ── Phase 4: Verification Pipeline Orchestrator ──
492
+ if (currentStep === 'VERIFY' && spec.workingDirectory) {
493
+ const harnessPath = path.join(path.resolve(spec.workingDirectory), 'verification_harness.json');
494
+ if (fs.existsSync(harnessPath)) {
495
+ try {
496
+ const rawHarness = fs.readFileSync(harnessPath, 'utf8');
497
+ const harnessData = JSON.parse(rawHarness);
498
+ // GAP-5 fix: Persist the harness so CLI drift detection works for DarkFactory runs
499
+ const rubricHash = computeRubricHash(harnessData.tests);
500
+ const harness = {
501
+ ...harnessData,
502
+ project: pipeline.project,
503
+ conversation_id: `dark-factory-${pipeline.id}`,
504
+ created_at: new Date().toISOString(),
505
+ rubric_hash: rubricHash,
506
+ };
507
+ await storage.saveVerificationHarness(harness, pipeline.user_id);
508
+ // GAP-2 fix: Build VerificationConfig from env vars so PRISM_VERIFICATION_LAYERS
509
+ // and PRISM_VERIFICATION_DEFAULT_SEVERITY are respected in DarkFactory pipelines
510
+ const vConfig = {
511
+ enabled: true,
512
+ layers: PRISM_VERIFICATION_LAYERS,
513
+ default_severity: PRISM_VERIFICATION_DEFAULT_SEVERITY,
514
+ };
515
+ const verificationResult = await VerificationRunner.runSuite(rawHarness, {
516
+ harness,
517
+ layers: PRISM_VERIFICATION_LAYERS,
518
+ config: vConfig,
519
+ });
520
+ const coverageScore = verificationResult.total > 0 ? (verificationResult.total - verificationResult.skipped_count) / verificationResult.total : 0;
521
+ const executedCount = verificationResult.total - verificationResult.skipped_count;
522
+ const passRate = executedCount > 0 ? verificationResult.passed_count / executedCount : 0;
523
+ // GAP-4 fix: Use proper ValidationResult type instead of `any`
524
+ const valResult = {
525
+ id: crypto.randomUUID(),
526
+ rubric_hash: rubricHash,
527
+ project: pipeline.project,
528
+ conversation_id: `dark-factory-${pipeline.id}`,
529
+ run_at: new Date().toISOString(),
530
+ passed: passRate >= harnessData.min_pass_rate && verificationResult.severity_gate.action !== "abort",
531
+ pass_rate: passRate,
532
+ critical_failures: verificationResult.severity_gate.failed_assertions.length,
533
+ coverage_score: coverageScore,
534
+ result_json: JSON.stringify(verificationResult),
535
+ gate_action: verificationResult.severity_gate.action,
536
+ gate_override: false,
537
+ };
538
+ const { canContinue, validatedResult } = Gatekeeper.executeGate(valResult);
539
+ await storage.saveVerificationRun(validatedResult, pipeline.user_id);
540
+ // GAP-3 fix: Emit verification experience event for ML routing feedback
541
+ try {
542
+ const confidenceScore = Math.round(passRate * 100);
543
+ await storage.saveLedger({
544
+ project: pipeline.project,
545
+ conversation_id: `dark-factory-${pipeline.id}`,
546
+ user_id: pipeline.user_id,
547
+ event_type: 'validation_result',
548
+ summary: `[VERIFY] ${verificationResult.passed_count}/${verificationResult.total} passed (gate: ${verificationResult.severity_gate.action})`,
549
+ keywords: ['dark-factory', 'verification', pipeline.project],
550
+ importance: verificationResult.severity_gate.action === 'abort' ? 2 : 0,
551
+ confidence_score: confidenceScore,
552
+ });
553
+ }
554
+ catch { /* experience events are advisory — never block execution */ }
555
+ if (!canContinue) {
556
+ result.success = false;
557
+ result.notes = (result.notes ? result.notes + '\n\n' : '') + `[GATE BLOCKED] Pipeline verification runner failed the security gate.`;
558
+ }
559
+ else {
560
+ result.success = result.success && validatedResult.passed;
561
+ }
562
+ }
563
+ catch (err) {
564
+ if (err instanceof VerificationGateError) {
565
+ debugLog(`[DarkFactory] Pipeline ${pipeline.id} ABORTED by Verification Gate.`);
566
+ try {
567
+ await storage.savePipeline({
568
+ ...pipeline,
569
+ status: 'FAILED',
570
+ error: `[GATE ABORT] ${err.message}`,
571
+ });
572
+ }
573
+ catch { /* Status guard */ }
574
+ await emitExperienceEvent(pipeline, 'failure', `[GATE ABORT] ${err.message}`);
575
+ return;
576
+ }
577
+ else {
578
+ console.error(`[DarkFactory] Verification harness crash: ${err.message}`);
579
+ result.success = false;
580
+ result.notes = `[GATE CRASH] Verification suite failed to execute: ${err.message}`;
581
+ }
582
+ }
583
+ }
584
+ }
585
+ // Determine next step based on result
586
+ const nextStep = SafetyController.getNextStep(currentStep, pipeline.iteration, spec, result.success // For VERIFY step: success means tests passed
587
+ );
588
+ if (nextStep === null || currentStep === 'FINALIZE') {
589
+ // Pipeline complete — determine final status
590
+ const finalStatus = result.success ? 'COMPLETED' : 'FAILED';
591
+ const finalError = result.success ? null : `Pipeline ended at step=${currentStep}: ${result.notes?.slice(0, 500)}`;
592
+ try {
593
+ await storage.savePipeline({
594
+ ...pipeline,
595
+ status: finalStatus,
596
+ current_step: 'FINALIZE',
597
+ error: finalError,
598
+ last_heartbeat: new Date().toISOString(),
599
+ });
600
+ }
601
+ catch (err) {
602
+ // Kill switch: if savePipeline throws "Cannot update pipeline... already ABORTED",
603
+ // someone externally killed this pipeline. Respect the kill.
604
+ if (err instanceof Error && err.message.includes('Cannot update pipeline')) {
605
+ debugLog(`[DarkFactory] Kill switch activated for pipeline ${pipeline.id}: ${err.message}`);
606
+ return;
607
+ }
608
+ throw err;
609
+ }
610
+ await emitExperienceEvent({ ...pipeline, status: finalStatus, current_step: 'FINALIZE' }, result.success ? 'success' : 'failure', result.success
611
+ ? `Pipeline completed successfully after ${pipeline.iteration} iteration(s).`
612
+ : `Pipeline failed at step=${currentStep}: ${result.notes?.slice(0, 200)}`);
613
+ debugLog(`[DarkFactory] Pipeline ${pipeline.id} finished: ${finalStatus}`);
614
+ }
615
+ else {
616
+ // Advance to next step
617
+ try {
618
+ await storage.savePipeline({
619
+ ...pipeline,
620
+ current_step: nextStep.step,
621
+ iteration: nextStep.iteration,
622
+ last_heartbeat: new Date().toISOString(),
623
+ });
624
+ }
625
+ catch (err) {
626
+ // Kill switch detection
627
+ if (err instanceof Error && err.message.includes('Cannot update pipeline')) {
628
+ debugLog(`[DarkFactory] Kill switch activated for pipeline ${pipeline.id}: ${err.message}`);
629
+ return;
630
+ }
631
+ throw err;
632
+ }
633
+ debugLog(`[DarkFactory] Pipeline ${pipeline.id} advanced: ${currentStep} → ${nextStep.step} (iter ${nextStep.iteration})`);
634
+ }
635
+ }
636
+ catch (err) {
637
+ // Top-level catch: NEVER let runner errors crash the MCP server
638
+ console.error(`[DarkFactory] Runner tick error (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
639
+ }
640
+ finally {
641
+ tickInProgress = false;
642
+ }
643
+ }
644
+ // ─── Public API ────────────────────────────────────────────────
645
+ /**
646
+ * Start the Dark Factory background runner.
647
+ * Called once during server startup, after storage is warm.
648
+ *
649
+ * This function:
650
+ * 1. Recovers stale pipelines from a previous crash
651
+ * 2. Starts the continuous poll loop (non-blocking setInterval)
652
+ *
653
+ * The runner is designed to be invisible to the MCP client.
654
+ * It never blocks tool calls or resource requests.
655
+ */
656
+ export async function startDarkFactoryRunner() {
657
+ debugLog(`[DarkFactory] Starting background runner (poll interval: ${PRISM_DARK_FACTORY_POLL_MS}ms)`);
658
+ // Phase 1: Recover any stale pipelines from previous crash
659
+ await recoverStalePipelines();
660
+ // Phase 2: Start the continuous poll loop
661
+ // setInterval ensures we yield to the event loop between ticks
662
+ runnerInterval = setInterval(() => {
663
+ runnerTick().catch(err => {
664
+ console.error(`[DarkFactory] Unhandled tick error: ${err instanceof Error ? err.message : String(err)}`);
665
+ });
666
+ }, PRISM_DARK_FACTORY_POLL_MS);
667
+ // Prevent the interval from keeping the process alive if MCP client disconnects
668
+ if (runnerInterval && typeof runnerInterval.unref === 'function') {
669
+ runnerInterval.unref();
670
+ }
671
+ debugLog('[DarkFactory] Background runner started.');
672
+ }
673
+ /**
674
+ * Stop the Dark Factory background runner.
675
+ * Called during graceful shutdown.
676
+ */
677
+ export function stopDarkFactoryRunner() {
678
+ if (runnerInterval) {
679
+ clearInterval(runnerInterval);
680
+ runnerInterval = null;
681
+ debugLog('[DarkFactory] Background runner stopped.');
682
+ }
683
+ }