/**
 * ReconcilerService — The "CPU" of Crewly V3
 *
 * The Reconciler periodically and on-demand recomputes system truth from
 * durable state. It does NOT trust event delivery — it reads state and
 * determines what should be true.
 *
 * Two-level loop:
 * - Fast loop (10s): Lease expiry + agent health only
 * - Full loop (60s): Complete scan of all Requests, WorkItems, Claims
 *
 * Performance budget: single reconcile < 3s (CEO requirement).
 *
 * @module services/reconciler/reconciler.service
 */
import { createEmptyReconcileResult, DEFAULT_RECONCILER_CONFIG, } from '../../types/v2/index.js';
import { detectStuckWorkItems, detectExpiredClaims, reconcileRequestStatus, detectRecoverableWorkItems, detectRetryableFailedWorkItems, detectDependencyResolvedWorkItems, detectUnclaimedTasks, runPruningPass, } from './reconcile-rules.js';
import { getSettingsService } from '../settings/index.js';
import { LoggerService } from '../core/logger.service.js';
// ---------------------------------------------------------------------------
// ReconcilerService
// ---------------------------------------------------------------------------
export class ReconcilerService {
    config;
    dataProvider;
    fastLoopTimer = null;
    fullLoopTimer = null;
    history = [];
    isRunning = false;
    totalPasses = 0;
    totalCorrections = 0;
    /**
     * Creates a new ReconcilerService.
     *
     * @param dataProvider - Provides data access and mutation capabilities
     * @param config - Optional configuration override
     */
    constructor(dataProvider, config) {
        this.dataProvider = dataProvider;
        this.config = { ...DEFAULT_RECONCILER_CONFIG, ...config };
    }
    // -------------------------------------------------------------------------
    // Public API
    // -------------------------------------------------------------------------
    /**
     * Start the reconciliation loops (fast + full).
     * Called during backend startup.
     */
    start() {
        if (this.fastLoopTimer || this.fullLoopTimer) {
            this.stop();
        }
        this.fastLoopTimer = setInterval(() => void this.runFast(), this.config.fastLoopIntervalMs);
        this.fullLoopTimer = setInterval(() => void this.runFull(), this.config.fullLoopIntervalMs);
    }
    /**
     * Stop the reconciliation loops. Called during graceful shutdown.
     */
    stop() {
        if (this.fastLoopTimer) {
            clearInterval(this.fastLoopTimer);
            this.fastLoopTimer = null;
        }
        if (this.fullLoopTimer) {
            clearInterval(this.fullLoopTimer);
            this.fullLoopTimer = null;
        }
    }
    /**
     * Full reconciliation pass — scans all entities.
     * Called on startup, periodically (60s), and manually via API.
     *
     * @returns The reconciliation result
     */
    async runFull() {
        return this.executeReconcile('full', async (result) => {
            const [workItems, requests, claims, agentHealthMap] = await Promise.all([
                this.dataProvider.getActiveWorkItems(),
                this.dataProvider.getActiveRequests(),
                this.dataProvider.getActiveClaims(),
                this.dataProvider.getAgentHealthMap(),
            ]);
            // 1. Detect stuck WorkItems (agent dead or timed out)
            const stuck = detectStuckWorkItems(workItems, agentHealthMap, this.config.workItemTimeoutMs);
            result.corrections.push(...stuck.corrections);
            result.workItemsTimedOut += stuck.stuckIds.length;
            // 2. Detect expired claims
            const expired = detectExpiredClaims(claims);
            result.corrections.push(...expired.corrections);
            // 3. Detect recoverable blocked WorkItems (agent back online)
            const recoverable = detectRecoverableWorkItems(workItems, agentHealthMap);
            result.corrections.push(...recoverable.corrections);
            result.workItemsRequeued += recoverable.recoverableIds.length;
            // 3b. Auto-retry failed WorkItems with remaining retries
            const retryable = detectRetryableFailedWorkItems(workItems);
            result.corrections.push(...retryable.corrections);
            result.workItemsRequeued += retryable.retriedIds.length;
            // 3c. Unblock WorkItems whose dependencies are all resolved
            const workItemMap = new Map(workItems.map(wi => [wi.id, wi]));
            const unblocked = detectDependencyResolvedWorkItems(workItems, workItemMap);
            result.corrections.push(...unblocked.corrections);
            result.workItemsRequeued += unblocked.unblockedIds.length;
            // 4. Pruning pass (F4): TTL expiry + orphan cascade + deep cascade + stale queue detection
            const pruning = runPruningPass(workItems);
            result.corrections.push(...pruning.totalCorrections);
            result.staleItemsCleaned += pruning.ttlExpiredCount + pruning.orphanCancelledCount + pruning.cascadeCancelledCount;
            // 4a. Stale-queued visibility (2026-05-16): the pruning rule no
            // longer auto-cancels these WIs (was destroying user work — Atlas
            // X-article task on 2026-05-16). Log them so the stuck condition
            // is observable without destroying the work; the wake / eviction-
            // under-pressure path (PR #585) is responsible for actually
            // making progress on them.
            if (pruning.staleQueuedCount > 0) {
                // ReconcilerService has no injected logger field (the rest of
                // the service surfaces failures via result.errors); use the
                // shared LoggerService directly. Stale-queued isn't a failure
                // — it's an observability signal, so it lives in the log
                // stream rather than the result envelope.
                LoggerService.getInstance()
                    .createComponentLogger('ReconcilerService')
                    .warn('WorkItems queued past staleness threshold (NOT cancelling — eviction path will retry wake)', {
                    staleQueuedCount: pruning.staleQueuedCount,
                    // Cap the id list to avoid huge log lines if dozens accumulate.
                    staleIds: pruning.staleQueuedIds.slice(0, 20),
                    truncated: pruning.staleQueuedIds.length > 20,
                });
                // Self-heal fix #2 (2026-05-20): emit an actionable
                // `task:queued_too_long` event so the owner (typically ORC)
                // can re-deliver / re-target / fail-fast. Resolves the
                // 2026-05-20 ESTestNode incident pattern where the WI sat
                // queued for 53min because the recovery chain quietly
                // skipped active-but-idle agents.
                if (this.dataProvider.broadcastStaleQueuedWIs) {
                    const staleWithMeta = [];
                    for (const wiId of pruning.staleQueuedIds) {
                        const wi = workItems.find((w) => w.id === wiId);
                        if (!wi)
                            continue;
                        staleWithMeta.push({
                            id: wi.id,
                            target: wi.target,
                            owner: wi.owner,
                            createdAt: wi.createdAt,
                        });
                    }
                    this.dataProvider.broadcastStaleQueuedWIs(staleWithMeta);
                }
            }
            // Clear dedup entries for WIs that are no longer queued — a WI
            // that was claimed/completed/cancelled since the last reconcile
            // should get a fresh first-fire if it ever re-enters stuck state.
            if (this.dataProvider.clearResolvedStuckWiDedup) {
                const currentlyQueued = new Set(workItems.filter((w) => w.status === 'queued').map((w) => w.id));
                this.dataProvider.clearResolvedStuckWiDedup(currentlyQueued);
            }
            // 5. Reconcile Request statuses
            for (const request of requests) {
                const requestWorkItems = await this.dataProvider.getWorkItemsForRequest(request.id);
                const correction = reconcileRequestStatus(request, requestWorkItems);
                if (correction) {
                    result.corrections.push(correction);
                    result.requestsUpdated++;
                }
            }
            // 6. Apply all corrections
            await this.applyCorrections(result.corrections, result);
            // 7. Backfill token usage on completed WorkItems with 0 tokens
            if (this.dataProvider.backfillTokenUsage) {
                try {
                    await this.dataProvider.backfillTokenUsage();
                }
                catch {
                    // Token backfill is non-fatal
                }
            }
            // 8. Hybrid Wake: detect unclaimed tasks and wake dormant agents (H3)
            // Call after status corrections to ensure we have current pool state.
            await this.runHybridWake(workItems, agentHealthMap, result);
        });
    }
    /**
     * Fast reconciliation pass — checks leases, agent health, and Hybrid Wake.
     * Called frequently (every 10s) with minimal overhead.
     *
     * @returns The reconciliation result
     */
    async runFast() {
        return this.executeReconcile('fast', async (result) => {
            const [claims, agentHealthMap, workItems] = await Promise.all([
                this.dataProvider.getActiveClaims(),
                this.dataProvider.getAgentHealthMap(),
                this.dataProvider.getActiveWorkItems(),
            ]);
            // 1. Check lease expiry
            const expired = detectExpiredClaims(claims);
            result.corrections.push(...expired.corrections);
            // 2. Quick stuck check on running items
            const runningItems = workItems.filter(wi => wi.status === 'running');
            const stuck = detectStuckWorkItems(runningItems, agentHealthMap, this.config.workItemTimeoutMs);
            result.corrections.push(...stuck.corrections);
            result.workItemsTimedOut += stuck.stuckIds.length;
            // 3. Apply corrections
            await this.applyCorrections(result.corrections, result);
            // 4. Hybrid Wake: detect unclaimed tasks and wake dormant agents
            await this.runHybridWake(workItems, agentHealthMap, result);
        });
    }
    /**
     * Targeted reconciliation for a single Request.
     * Called when a specific Request's state may have changed.
     *
     * @param requestId - The Request ID to reconcile
     */
    async reconcileRequest(requestId) {
        return this.executeReconcile('targeted_request', async (result) => {
            const requests = await this.dataProvider.getActiveRequests();
            const request = requests.find(r => r.id === requestId);
            if (!request)
                return;
            const workItems = await this.dataProvider.getWorkItemsForRequest(requestId);
            const correction = reconcileRequestStatus(request, workItems);
            if (correction) {
                result.corrections.push(correction);
                result.requestsUpdated++;
                await this.applyCorrections(result.corrections, result);
            }
        });
    }
    /**
     * Returns current Reconciler status for API exposure.
     *
     * @returns Current status including last result, config, and counters
     */
    getStatus() {
        const now = new Date();
        return {
            isRunning: this.isRunning,
            lastResult: this.history[0],
            nextFullReconcileAt: this.fullLoopTimer
                ? new Date(now.getTime() + this.config.fullLoopIntervalMs).toISOString()
                : undefined,
            nextFastReconcileAt: this.fastLoopTimer
                ? new Date(now.getTime() + this.config.fastLoopIntervalMs).toISOString()
                : undefined,
            totalPasses: this.totalPasses,
            totalCorrections: this.totalCorrections,
            config: { ...this.config },
        };
    }
    /**
     * Returns recent reconciliation history.
     *
     * @param limit - Maximum number of results to return
     * @returns Array of recent ReconcileResults (newest first)
     */
    getHistory(limit = 20) {
        return this.history.slice(0, Math.min(limit, this.config.maxHistorySize));
    }
    /**
     * Updates the Reconciler configuration at runtime.
     *
     * @param updates - Partial configuration to merge
     */
    updateConfig(updates) {
        this.config = { ...this.config, ...updates };
        // Restart loops if timing changed
        if (updates.fastLoopIntervalMs || updates.fullLoopIntervalMs) {
            const wasRunning = !!(this.fastLoopTimer || this.fullLoopTimer);
            if (wasRunning) {
                this.stop();
                this.start();
            }
        }
    }
    // -------------------------------------------------------------------------
    // Private
    // -------------------------------------------------------------------------
    /**
     * Executes a reconciliation pass with timing, error handling, and history tracking.
     *
     * @param type - The type of reconciliation
     * @param logic - The reconciliation logic to execute
     * @returns The completed ReconcileResult
     */
    async executeReconcile(type, logic) {
        if (this.isRunning) {
            return createEmptyReconcileResult(type);
        }
        this.isRunning = true;
        const result = createEmptyReconcileResult(type);
        const startTime = Date.now();
        try {
            await logic(result);
        }
        catch (err) {
            const message = err instanceof Error ? err.message : String(err);
            result.errors.push(`Reconcile error: ${message}`);
        }
        finally {
            result.durationMs = Date.now() - startTime;
            result.reconciledAt = new Date().toISOString();
            this.isRunning = false;
            this.totalPasses++;
            this.totalCorrections += result.corrections.length;
            // Add to history (newest first)
            this.history.unshift(result);
            if (this.history.length > this.config.maxHistorySize) {
                this.history = this.history.slice(0, this.config.maxHistorySize);
            }
        }
        return result;
    }
    /**
     * Applies corrections by calling the data provider.
     * Handles errors gracefully — a failed correction doesn't stop the pass.
     *
     * @param corrections - Corrections to apply
     * @param result - The ReconcileResult to accumulate errors into
     */
    async applyCorrections(corrections, result) {
        // Steve 2026-05-15 dogfood (WI 5afef18f, 21:55:49): TWO rules can
        // both emit blocked→queued for the same WI in a single tick —
        // `detectRecoverableWorkItems` (agent back online) and
        // `detectDependencyResolvedWorkItems` (dependencies satisfied). The
        // first requeueWorkItem call succeeds; the second throws "Cannot
        // release WorkItem: status must be 'running' or 'blocked', got
        // 'queued'" because the WI is already past blocked. Dedup before
        // iterating so each entity gets at most one terminal-state
        // correction per tick — first writer wins. Key by entity+newState
        // to keep room for legitimate distinct corrections on the same
        // entity (e.g. a separate cancelled correction after a queued
        // one, which the matrix rejects anyway but we surface explicitly).
        const seen = new Set();
        const deduped = [];
        let dupCount = 0;
        for (const c of corrections) {
            const key = `${c.entityType}:${c.entityId}:${c.newState}`;
            if (seen.has(key)) {
                dupCount++;
                continue;
            }
            seen.add(key);
            deduped.push(c);
        }
        if (dupCount > 0) {
            // ReconcilerService has no injected logger; the result.errors
            // array is the surface here. A duplicate-correction skip is not
            // an error per se, but we want it observable in the tick result
            // so a `runFull()` caller can see it in returned diagnostics.
            // No error pushed — duplicates are expected when rules overlap
            // (Steve 2026-05-15: detectRecoverable + detectDependencyResolved
            // both produced blocked→queued for the same WI).
        }
        for (const correction of deduped) {
            try {
                // Handle claim-specific corrections via ClaimService
                if (correction.entityType === 'claim') {
                    if (correction.newState === 'expiring') {
                        await this.dataProvider.markClaimExpiring(correction.entityId);
                    }
                    else if (correction.newState === 'revoked') {
                        await this.dataProvider.revokeClaimAndRelease(correction.entityId, correction.reason);
                        result.claimsRevoked++;
                    }
                    continue;
                }
                // blocked → queued (agent-back-online recovery) is special: it
                // needs the full lifecycle — release the dangling claim, clear
                // startedAt, increment retryCount — which `releaseBack` owns.
                // The earlier code called `applyCorrection` (which flips status
                // to queued via updateItemStatus) AND `requeueWorkItem` (which
                // calls `releaseBack`, which expects status running|blocked).
                // The second call always threw "Cannot release WorkItem: status
                // must be 'running' or 'blocked', got 'queued'" — see Steve's
                // 2026-05-15 17:58:18 dogfood log for WI e61e16e9. Skip
                // applyCorrection for this case so releaseBack sees the
                // original blocked status and the full recovery side-effects run.
                if (correction.entityType === 'work_item' &&
                    correction.newState === 'queued' &&
                    correction.previousState === 'blocked') {
                    await this.dataProvider.requeueWorkItem(correction.entityId);
                    continue;
                }
                await this.dataProvider.applyCorrection(correction);
                // If a WorkItem was revoked (from claim expiry), release it back to pool
                if (correction.entityType === 'work_item' &&
                    correction.newState === 'revoked') {
                    await this.dataProvider.releaseToPool(correction.entityId, correction.reason);
                }
            }
            catch (err) {
                const message = err instanceof Error ? err.message : String(err);
                result.errors.push(`Failed to apply correction for ${correction.entityType}:${correction.entityId}: ${message}`);
            }
        }
    }
    /**
     * Runs Hybrid Wake logic: detects unclaimed tasks in the pool and
     * wakes dormant agents (suspended → rehydrate, inactive → start).
     *
     * Only runs if the data provider supports pool item retrieval and wake execution.
     *
     * @param workItems - All active WorkItems (used as fallback if no pool provider)
     * @param agentHealthMap - Map of agent session → health info
     * @param result - The ReconcileResult to accumulate wake actions into
     */
    async runHybridWake(workItems, agentHealthMap, result) {
        // Only run if the provider supports wake actions
        if (!this.dataProvider.executeWakeAction)
            return;
        // Respect maxConcurrentAgents — count currently active agents and skip wake
        // if we're at capacity. This is the primary control preventing resource exhaustion.
        let maxConcurrent = 50; // matches ConfigService default (MAX_CONCURRENT_AGENTS)
        try {
            const settings = await getSettingsService().getSettings();
            maxConcurrent = settings.general.maxConcurrentAgents;
        }
        catch {
            // Use default if settings unavailable
        }
        let activeAgentCount = 0;
        for (const agent of agentHealthMap.values()) {
            if (agent.status === 'active') {
                activeAgentCount++;
            }
        }
        if (activeAgentCount >= maxConcurrent) {
            return;
        }
        // Use pool items if available, otherwise fall back to active work items
        const poolItems = this.dataProvider.getAvailablePoolItems
            ? await this.dataProvider.getAvailablePoolItems()
            : workItems.filter(wi => wi.status === 'queued');
        const { wakeActions } = detectUnclaimedTasks(poolItems, agentHealthMap);
        // Execute each wake action
        for (const action of wakeActions) {
            try {
                const success = await this.dataProvider.executeWakeAction(action);
                if (success) {
                    result.wakeActions.push(action);
                    result.agentsWoken++;
                }
                else {
                    result.errors.push(`Wake action failed for agent ${action.agentSessionName} (strategy: ${action.strategy})`);
                }
            }
            catch (err) {
                const message = err instanceof Error ? err.message : String(err);
                result.errors.push(`Wake action error for agent ${action.agentSessionName}: ${message}`);
            }
        }
    }
}
//# sourceMappingURL=reconciler.service.js.map