/**
 * @module        worker-manager-impl
 * @description   WorkerManager 接口的具体实现，整合 Supervisor/Judge/Limiter 为统一 ACP 接口
 *
 * @author        eddy
 * @organization  wykj
 * @ownership     wykj/eddy
 *
 * @created       2026-03-28
 * @updated       2026-04-03
 *
 * @role          manager
 * @layer         manager
 * @boundedContext worker-lifecycle
 */
import { execFileSync } from 'node:child_process';
import { readdirSync, unlinkSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { getMarkerPathFromStateFile, patchCurrentCardFile, writeCurrentCardFile } from '../core/markerFile.js';
import { createIdleWorkerSlot, readState, writeState } from '../core/state.js';
// ─── Timeout Defaults ──────────────────────────────────────────
const DEFAULT_TIMEOUTS = {
    startupSec: 60, // 60s for worker to start
    developmentSec: 4 * 3600, // 4h for development (single-worker model, no integration phase)
    inputWaitSec: 1800, // 30min waiting for input
    forceMultiplier: 1.5, // Hard kill at 1.5x timeout
};
// ─── Implementation ────────────────────────────────────────────
export class WorkerManagerImpl {
    supervisor;
    completionJudge;
    agentRuntime;
    stateFile;
    maxWorkers;
    eventHandlers = [];
    taskSlotMap = new Map();
    timeouts = new Map();
    /** Abort controllers for ACP completion monitors keyed by slot name. */
    acpMonitorAborts = new Map();
    constructor(deps) {
        this.supervisor = deps.supervisor;
        this.completionJudge = deps.completionJudge;
        this.agentRuntime = deps.agentRuntime;
        this.stateFile = deps.stateFile;
        this.maxWorkers = deps.maxWorkers;
    }
    // ─── run / resume ────────────────────────────────────────────
    async run(request) {
        return this.acquireAndSpawn({
            taskId: request.taskId, cardId: request.cardId, project: request.project,
            phase: request.phase, stageName: request.stageName, cardTitle: request.cardTitle,
            prompt: request.prompt, cwd: request.cwd,
            branch: request.branch, targetBranch: request.targetBranch,
            tool: request.tool, transport: request.transport,
            outputFile: request.outputFile, maxRetries: request.maxRetries ?? 0,
            customTimeoutSec: request.timeoutSec,
            completionStrategy: request.completionStrategy,
        }, 'wm-run');
    }
    async resume(request) {
        return this.acquireAndSpawn({
            taskId: request.taskId, cardId: request.cardId, project: request.project,
            phase: request.phase, stageName: request.phase, // resume doesn't know stage name; fallback to phase
            prompt: request.prompt, cwd: request.cwd,
            branch: request.branch, targetBranch: request.targetBranch,
            tool: request.tool, transport: request.transport,
            outputFile: request.outputFile, maxRetries: 0,
            resumeSessionId: request.sessionId,
        }, 'wm-resume');
    }
    // ─── cancel ──────────────────────────────────────────────────
    async cancel(request) {
        const { taskId, project, reason } = request;
        const slot = this.taskSlotMap.get(taskId);
        if (!slot) {
            this.log(`Cancel: task ${taskId} not found`);
            return;
        }
        // Determine phase from lease
        const lease = this.rd().leases[taskId];
        const isIntegration = lease
            ? (lease.pmStateObserved === 'QA' || lease.phase === 'merging' || lease.phase === 'resolving_conflict')
            : false;
        const phase = isIntegration ? 'integration' : 'development';
        this.clearTimeoutForTask(taskId);
        const workerId = `${project}:${slot}:${taskId}`;
        // Stop the ACP completion monitor first so it cannot race this cancel and
        // emit a second, contradictory finalize for the same slot.
        this.acpMonitorAborts.get(slot)?.abort();
        this.acpMonitorAborts.delete(slot);
        // Completion-aware cancel for ACP workers. The OS-level supervisor.kill is
        // unreliable here: it targets a possibly-stale pid, SIGKILL can ESRCH, and the
        // ACP run keeps running. Two guards:
        //   1. If the run has ALREADY completed, a cancel (e.g. a timeout that fired
        //      after the work finished) must finalize as completed — never stamp a
        //      contradictory run.failed → NEEDS-FIX ("completed + NEEDS-FIX").
        //   2. If still running, issue a clean ACP cancelRun so the run actually
        //      stops, instead of relying on an OS kill that may miss.
        if (this.agentRuntime) {
            try {
                const acp = await this.agentRuntime.inspect(slot);
                if (acp.sessions[slot]?.currentRun?.status === 'completed') {
                    this.releaseSlotInState(slot, taskId);
                    this.taskSlotMap.delete(taskId);
                    this.emitEvent({
                        type: 'run.completed', taskId, cardId: taskId, project,
                        phase, slot, workerId,
                        timestamp: new Date().toISOString(), state: 'completed',
                        exitCode: 0,
                        completionResult: { status: 'completed', reason: 'acp_completed_pre_cancel' },
                    });
                    this.log(`Cancel for ${workerId} skipped: ACP run already completed`);
                    return;
                }
                await this.agentRuntime.cancelRun(slot);
            }
            catch (err) {
                this.log(`ACP cancel best-effort failed for ${workerId}: ${err instanceof Error ? err.message : String(err)}`);
            }
        }
        await this.supervisor.kill(workerId);
        this.releaseSlotInState(slot, taskId);
        /* resourceLimiter.release() — single worker, no-op */ ;
        this.taskSlotMap.delete(taskId);
        this.emitEvent({
            type: 'run.failed', taskId, cardId: taskId, project,
            phase, slot, workerId,
            timestamp: new Date().toISOString(), state: 'failed',
            exitCode: 1,
            completionResult: { status: 'failed', reason: `cancelled_${reason}` },
            error: `Cancelled: ${reason}`,
        });
        this.log(`Cancelled worker ${workerId} (reason=${reason})`);
    }
    // ─── inspect ─────────────────────────────────────────────────
    inspect(query) {
        const state = this.rd();
        const snapshots = [];
        for (const [slotName, worker] of Object.entries(state.workers)) {
            if (query.slot && query.slot !== slotName)
                continue;
            if (query.taskId && worker.seq !== null && String(worker.seq) !== query.taskId)
                continue;
            const seq = worker.seq !== null ? String(worker.seq) : null;
            const activeCard = seq ? state.activeCards[seq] ?? null : null;
            const lease = seq ? state.leases[seq] ?? null : null;
            snapshots.push({
                slot: slotName, taskId: seq,
                cardId: activeCard ? String(activeCard.seq) : seq,
                project: query.project ?? '',
                state: this.mapWorkerState(worker),
                phase: lease ? (lease.phase === 'merging' || lease.phase === 'resolving_conflict' ? 'integration' : 'development') : null,
                pid: worker.pid ?? null, sessionId: worker.sessionId ?? null,
                cwd: worker.worktree, branch: worker.branch,
                startedAt: worker.claimedAt,
                updatedAt: worker.lastHeartbeat ?? worker.claimedAt ?? new Date().toISOString(),
                outputTail: null, pendingInput: null,
            });
        }
        return snapshots;
    }
    onEvent(handler) { this.eventHandlers.push(handler); }
    // ─── sendInput / confirm ─────────────────────────────────────
    async sendInput(request) {
        const slot = this.requireAgentSlot(request.taskId, 'sendInput');
        await this.agentRuntime.resumeRun(slot, request.input);
        this.log(`Sent input to task ${request.taskId} in ${slot}`);
    }
    async confirm(request) {
        const slot = this.requireAgentSlot(request.taskId, 'confirm');
        const input = request.action === 'confirm' ? (request.message ?? 'yes') : (request.message ?? 'no');
        await this.agentRuntime.resumeRun(slot, input);
        this.log(`Confirmed (${request.action}) task ${request.taskId} in ${slot}`);
    }
    // ─── recover (Phase 4 — full decision matrix from doc-09 §11.3) ──
    async recover(contexts) {
        const result = {
            scanned: 0, alive: 0, completed: 0, failed: 0,
            released: 0, rebuilt: 0, queueRebuilt: 0, events: [],
        };
        // Phase 1+2: Scan leases and apply per-task decision matrix
        for (const ctx of contexts) {
            const state = readState(ctx.stateFile, this.maxWorkers);
            const processedSeqs = new Set();
            for (const [seq, lease] of Object.entries(state.leases)) {
                if (lease.phase === 'released' || lease.phase === 'suspended')
                    continue;
                result.scanned++;
                processedSeqs.add(seq);
                const slot = lease.slot;
                const worker = slot ? state.workers[slot] ?? null : null;
                const pid = worker?.pid ?? null;
                const isAlive = pid ? this.isPidAlive(pid) : false;
                const evidence = state.worktreeEvidence[seq] ?? null;
                const pmState = lease.pmStateObserved;
                // R8/R9: PM manually completed or reverted — release immediately
                if (pmState === 'Done' || pmState === 'Backlog' || pmState === 'Todo') {
                    if (slot)
                        this.releaseSlotInState(slot, seq);
                    result.released++;
                    this.log(`Recovery R8/R9: task ${seq} PM state=${pmState}, released`);
                    continue;
                }
                // R1: Worker still running — re-attach orphan PID monitoring
                if (isAlive && pid && slot) {
                    this.recoverAliveWorker(ctx, seq, lease, slot, pid);
                    result.alive++;
                    continue;
                }
                // Dead worker — check git evidence for decision
                const event = this.judgeDeadWorker(ctx, seq, lease, slot, evidence);
                if (event) {
                    result.events.push(event);
                    if (event.type === 'run.completed')
                        result.completed++;
                    else
                        result.failed++;
                }
                else {
                    // Released (R7: worktree missing or fallback)
                    result.released++;
                }
            }
        }
        // Phase 4: Emit collected events so SPSEventHandler processes them
        for (const event of result.events) {
            this.emitEvent(event);
        }
        if (result.scanned > 0) {
            this.log(`Recovery complete: scanned=${result.scanned} alive=${result.alive} ` +
                `completed=${result.completed} failed=${result.failed} ` +
                `released=${result.released} rebuilt=${result.rebuilt} queueRebuilt=${result.queueRebuilt}`);
        }
        return result;
    }
    // ─── Private: Unified acquire + spawn flow ───────────────────
    async acquireAndSpawn(ctx, label) {
        const { taskId, cardId, project, phase, stageName, cardTitle, prompt, cwd, branch, targetBranch, tool, transport, outputFile, maxRetries, resumeSessionId } = ctx;
        if (this.taskSlotMap.has(taskId)) {
            this.log(`Duplicate task ${taskId}, already in slot ${this.taskSlotMap.get(taskId)}`);
            return this.reject('duplicate_task');
        }
        // Single worker: fixed slot, no queue, no resource competition
        const state = this.rd();
        const slot = this.findIdleSlot(state);
        if (!slot) {
            this.log(`No idle slot for task ${taskId} (worker busy)`);
            return this.reject('resource_exhausted');
        }
        const nowIso = new Date().toISOString();
        this.claimSlot(state, slot, { seq: taskId, cardId, project, phase, branch, cwd, tool, transport, outputFile, nowIso, targetBranch });
        this.wr(state, label);
        this.taskSlotMap.set(taskId, slot);
        const workerId = `${project}:${slot}:${taskId}`;
        let pid = null;
        let sessionId;
        let resourceAcquired = true; // track whether resource was released by spawn-fail path
        try {
            if (!this.agentRuntime) {
                throw new Error('acp-sdk transport requires agentRuntime');
            }
            // Env vars exposed to the spawned agent process (and its hook scripts).
            //
            // Only stable identity — values that do not change across card dispatches
            // within the same claude process lifetime. Per-card info (cardId, stage,
            // title) is NOT injected: POSIX env is frozen at spawn, so any per-card
            // value here would go stale once the process is reused for the next card.
            // Hooks that need the current card info must read the marker file instead
            // (see writeCurrentCardFile below); the SPS_PROJECT + SPS_WORKER_SLOT pair
            // serves as the marker file path index.
            const extraEnv = {
                SPS_PROJECT: project,
                SPS_WORKER_SLOT: slot,
            };
            // Atomically write per-slot "current card" marker. This is the
            // authoritative source of current-card info for hook scripts.
            this.writeCurrentCardFile(slot, taskId, stageName);
            const session = resumeSessionId
                ? await this.agentRuntime.resumeRun(slot, prompt)
                : await this.agentRuntime.startRun(slot, prompt, tool, cwd, { extraEnv });
            sessionId = session.sessionId;
            pid = session.pid ?? null;
            // ACP completion monitor — polls inspectRun until terminal state
            this.monitorAcpCompletion({
                workerId, taskId, cardId, project, phase, slot, branch, cwd,
                targetBranch, outputFile, tool, transport, maxRetries,
                sessionName: session.sessionName,
                completionStrategy: ctx.completionStrategy,
            });
        }
        catch (err) {
            this.log(`Spawn failed for ${taskId}: ${err instanceof Error ? err.message : String(err)}`);
            if (resourceAcquired) {
                /* resourceLimiter.release() — single worker, no-op */ ;
                resourceAcquired = false;
            }
            this.releaseSlotInState(slot, taskId);
            this.taskSlotMap.delete(taskId);
            this.emitEvent({
                type: 'run.failed', taskId, cardId, project, phase, slot, workerId,
                timestamp: new Date().toISOString(), state: 'failed',
                error: `Spawn failed: ${err instanceof Error ? err.message : String(err)}`,
            });
            return this.reject('spawn_failed');
        }
        // Update state with pid/sessionId obtained after spawn
        const postState = this.rd();
        if (postState.workers[slot]) {
            postState.workers[slot].pid = pid;
            postState.workers[slot].sessionId = sessionId ?? null;
            this.wr(postState, `${label}-pid`);
        }
        // v0.41.2: Patch marker file with sessionId + pid now that spawn succeeded.
        // First write (pre-spawn) has cardId/stage/dispatchedAt so hooks work even
        // if this patch fails. Extra fields are for post-mortem correlation with
        // ACP/OS logs.
        this.patchCurrentCardFile(slot, { sessionId: sessionId ?? undefined, pid: pid ?? undefined });
        this.log(`Launched ${transport}/${tool} worker ${workerId} in ${slot} (pid=${pid})`);
        this.startTimeout(taskId, phase, project, slot, ctx.customTimeoutSec);
        return { accepted: true, slot, workerId, pid: pid ?? undefined, sessionId };
    }
    // ─── Private: Exit Handler ───────────────────────────────────
    async handleExit(ctx) {
        const { workerId, taskId, cardId, project, phase, slot, branch, cwd, targetBranch, outputFile, tool, transport, exitCode, maxRetries } = ctx;
        this.clearTimeoutForTask(taskId);
        this.log(`Worker ${workerId} exited with code ${exitCode}`);
        const completion = this.completionJudge.judge({
            worktree: cwd, branch, baseBranch: targetBranch, outputFile, exitCode, phase,
            completionStrategy: ctx.completionStrategy,
        });
        this.log(`CompletionJudge for ${workerId}: ${completion.status} (${completion.reason})`);
        const isComplete = completion.status === 'completed';
        // Emit event — SPSEventHandler handles PM operations, slot release, notifications
        this.emitEvent({
            type: isComplete ? 'run.completed' : 'run.failed',
            taskId, cardId, project, phase, slot, workerId,
            timestamp: new Date().toISOString(),
            state: isComplete ? 'completed' : 'failed',
            exitCode, completionResult: completion,
        });
        // Release supervisor handle and resource limiter slot
        this.supervisor.remove(workerId);
        /* resourceLimiter.release() — single worker, no-op */ ;
        this.taskSlotMap.delete(taskId);
        // Safety net: if no event handlers are registered, release the slot
        // in state.json directly. Normally SPSEventHandler does this via the
        // event system, but if it's missing the slot would leak forever.
        if (this.eventHandlers.length === 0) {
            this.releaseSlotInState(slot, taskId);
            this.log(`Safety net: released slot ${slot} for task ${taskId} (no event handlers)`);
        }
    }
    // ─── Private: ACP Completion Monitor ───────────────────────────
    /**
     * Poll ACP session until run reaches terminal state, then emit event.
     *
     * ACP protocol provides authoritative completion status:
     *   - completed → trust directly, emit run.completed (skip CompletionJudge)
     *   - failed/cancelled → trust directly, emit run.failed
     *   - lost (process died) → fall back to CompletionJudge (check git evidence)
     *   - timeout → fall back to CompletionJudge
     *
     * This avoids the old pattern of converting everything to exitCode and
     * letting CompletionJudge guess from git artifacts.
     */
    monitorAcpCompletion(ctx) {
        if (!this.agentRuntime)
            return;
        const runtime = this.agentRuntime;
        const pollIntervalMs = 10_000; // 10s
        // Abort any previous monitor for this slot (prevents ghost polls
        // from a prior phase, e.g. development monitor still running when
        // integration starts on the same slot).
        this.acpMonitorAborts.get(ctx.slot)?.abort();
        const abortController = new AbortController();
        this.acpMonitorAborts.set(ctx.slot, abortController);
        let pendingTimeout = null;
        const poll = async () => {
            if (abortController.signal.aborted) {
                this.log(`ACP monitor: ${ctx.taskId} aborted (slot ${ctx.slot} reused)`);
                return;
            }
            // No max poll duration — ACP monitor polls until terminal state.
            // If the process dies, inspect returns 'lost'. Artificial timeouts
            // risk killing workers legitimately working on long tasks.
            try {
                const state = await runtime.inspect(ctx.slot.replace(/^worker-/, ''));
                if (abortController.signal.aborted)
                    return;
                const session = Object.values(state.sessions).find(s => s.sessionName === ctx.sessionName);
                if (!session) {
                    // Process died — session gone. Fall back to CompletionJudge
                    // which checks git evidence (worker may have pushed before dying).
                    this.log(`ACP monitor: ${ctx.taskId} session lost — falling back to CompletionJudge`);
                    this.acpMonitorAborts.delete(ctx.slot);
                    await this.handleExit({ ...ctx, exitCode: 1 });
                    return;
                }
                const runStatus = session.currentRun?.status;
                if (runStatus === 'completed') {
                    // ── ACP says completed — trust it directly ──
                    this.log(`ACP monitor: ${ctx.taskId} completed (authoritative)`);
                    this.clearAcpSessionRun(ctx.sessionName);
                    this.acpMonitorAborts.delete(ctx.slot);
                    this.finalizeAcpWorker(ctx, 'run.completed', 0, { status: 'completed', reason: 'acp_completed' });
                    return;
                }
                if (runStatus === 'failed' || runStatus === 'cancelled') {
                    // ── ACP says explicitly failed/cancelled — trust it directly ──
                    this.log(`ACP monitor: ${ctx.taskId} ${runStatus} (authoritative)`);
                    this.clearAcpSessionRun(ctx.sessionName);
                    this.acpMonitorAborts.delete(ctx.slot);
                    this.finalizeAcpWorker(ctx, 'run.failed', 1, { status: 'failed', reason: `acp_${runStatus}` });
                    return;
                }
                if (runStatus === 'lost') {
                    // ── Process died mid-run — fall back to CompletionJudge ──
                    this.log(`ACP monitor: ${ctx.taskId} lost — falling back to CompletionJudge`);
                    this.clearAcpSessionRun(ctx.sessionName);
                    this.acpMonitorAborts.delete(ctx.slot);
                    await this.handleExit({ ...ctx, exitCode: 1 });
                    return;
                }
                // Still running — poll again (store timeout ID for cleanup)
                pendingTimeout = setTimeout(poll, pollIntervalMs);
            }
            catch (err) {
                if (abortController.signal.aborted)
                    return;
                this.log(`ACP monitor error for ${ctx.taskId}: ${err instanceof Error ? err.message : String(err)}`);
                pendingTimeout = setTimeout(poll, pollIntervalMs);
            }
        };
        // Clean up pending setTimeout when abort fires
        abortController.signal.addEventListener('abort', () => {
            if (pendingTimeout)
                clearTimeout(pendingTimeout);
        }, { once: true });
        // Start first poll after a short delay (let the run initialize)
        pendingTimeout = setTimeout(poll, pollIntervalMs);
    }
    /**
     * Finalize an ACP worker with a known status — emit event and release resources.
     * Bypasses CompletionJudge since ACP protocol provides authoritative status.
     */
    finalizeAcpWorker(ctx, eventType, exitCode, completionResult) {
        this.clearTimeoutForTask(ctx.taskId);
        this.log(`ACP finalize ${ctx.workerId}: ${completionResult.status} (${completionResult.reason})`);
        this.emitEvent({
            type: eventType,
            taskId: ctx.taskId, cardId: ctx.cardId, project: ctx.project,
            phase: ctx.phase, slot: ctx.slot, workerId: ctx.workerId,
            timestamp: new Date().toISOString(),
            state: eventType === 'run.completed' ? 'completed' : 'failed',
            exitCode, completionResult,
        });
        // Release resources (same as handleExit tail)
        this.supervisor.remove(ctx.workerId);
        /* resourceLimiter.release() — single worker, no-op */ ;
        this.taskSlotMap.delete(ctx.taskId);
        if (this.eventHandlers.length === 0) {
            this.releaseSlotInState(ctx.slot, ctx.taskId);
            this.log(`Safety net: released slot ${ctx.slot} for task ${ctx.taskId} (no event handlers)`);
        }
    }
    /** Clear currentRun in session state so the slot can be reused for next task. */
    clearAcpSessionRun(sessionName) {
        try {
            const state = readState(this.stateFile, this.maxWorkers);
            for (const session of Object.values(state.sessions ?? {})) {
                if (session.sessionName === sessionName && session.currentRun) {
                    session.currentRun = null;
                    session.status = 'idle';
                }
            }
            this.wr(state, 'acp-clear-run');
        }
        catch (err) {
            this.log(`Failed to clear ACP session run: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    // ─── Private: Recovery Helpers ────────────────────────────────
    /**
     * R1: Worker PID is still alive — re-attach orphan monitoring.
     */
    recoverAliveWorker(ctx, seq, lease, slot, pid) {
        const workerId = `${ctx.project}:${slot}:${seq}`;
        const phase = (lease.phase === 'merging' || lease.phase === 'resolving_conflict')
            ? 'integration' : 'development';
        /* resourceLimiter.tryAcquire() — single worker, no-op */ ;
        this.supervisor.monitorOrphanPid(workerId, pid, {
            id: workerId, transport: 'acp-sdk', pid,
            outputFile: null, project: ctx.project, seq, slot,
            branch: lease.branch ?? '', worktree: lease.worktree ?? '',
            tool: 'claude', exitCode: null, sessionId: lease.sessionId ?? null,
            runId: lease.runId ?? null, sessionState: null, remoteStatus: null,
            lastEventAt: null,
            startedAt: lease.claimedAt ?? new Date().toISOString(),
            exitedAt: null,
        }, (exitCode) => this.handleExit({
            workerId, taskId: seq, cardId: seq, project: ctx.project,
            phase, slot, branch: lease.branch ?? '',
            cwd: lease.worktree ?? '', targetBranch: ctx.baseBranch,
            outputFile: '', tool: 'claude', transport: 'acp-sdk',
            exitCode, maxRetries: 0,
        }));
        this.taskSlotMap.set(seq, slot);
        this.log(`Recovery R1: task ${seq} alive (pid=${pid}), re-attached monitor`);
    }
    /**
     * Decision matrix for dead workers (R2-R7).
     * Returns a WorkerEvent for completed/failed, or null if slot was released.
     */
    judgeDeadWorker(ctx, seq, lease, slot, evidence) {
        const phase = (lease.phase === 'merging' || lease.phase === 'resolving_conflict')
            ? 'integration' : 'development';
        const workerId = `${ctx.project}:${slot ?? 'unknown'}:${seq}`;
        // R2: Branch merged into base — task complete
        if (evidence?.mergedToBase) {
            this.log(`Recovery R2: task ${seq} branch merged to base`);
            return this.makeRecoveryEvent('run.completed', seq, ctx, slot ?? '', phase, workerId, 'already_merged');
        }
        // R3: Pushed with commits ahead — development complete
        if (evidence?.pushed && evidence.aheadOfBase > 0) {
            this.log(`Recovery R3: task ${seq} pushed with ${evidence.aheadOfBase} commits ahead`);
            return this.makeRecoveryEvent('run.completed', seq, ctx, slot ?? '', phase, workerId, 'branch_pushed');
        }
        // R4: Local commits unpushed — rescue push, then fail for restart
        if (!evidence?.pushed && evidence && evidence.aheadOfBase > 0) {
            if (lease.worktree && lease.branch) {
                const rescued = this.rescuePush(lease.worktree, lease.branch);
                if (rescued)
                    this.log(`Recovery R4: rescued push for task ${seq}`);
            }
            return this.makeRecoveryEvent('run.failed', seq, ctx, slot ?? '', phase, workerId, 'needs_restart');
        }
        // R5: Dirty state (rebase/merge/conflict) — rescue push, then fail
        if (evidence && ['rebase', 'merge', 'conflict'].includes(evidence.gitStatus)) {
            if (lease.worktree && lease.branch) {
                const rescued = this.rescuePush(lease.worktree, lease.branch);
                if (rescued)
                    this.log(`Recovery R5: rescued push for task ${seq} (dirty=${evidence.gitStatus})`);
            }
            return this.makeRecoveryEvent('run.failed', seq, ctx, slot ?? '', phase, workerId, 'needs_restart');
        }
        // R7: Worktree missing — release slot, SPS re-prepares
        if (evidence && !evidence.worktreeExists) {
            if (slot)
                this.releaseSlotInState(slot, seq);
            this.log(`Recovery R7: task ${seq} worktree missing, released`);
            return null;
        }
        // R6: No changes (fallback) — failed with no artifacts
        this.log(`Recovery R6: task ${seq} no artifacts found`);
        return this.makeRecoveryEvent('run.failed', seq, ctx, slot ?? '', phase, workerId, 'no_artifacts');
    }
    /**
     * Create a WorkerEvent for recovery results.
     */
    makeRecoveryEvent(type, seq, ctx, slot, phase, workerId, reason) {
        return {
            type, taskId: seq, cardId: seq, project: ctx.project,
            phase, slot, workerId,
            timestamp: new Date().toISOString(),
            state: type === 'run.completed' ? 'completed' : 'failed',
            completionResult: {
                status: type === 'run.completed' ? 'completed' : 'failed',
                reason,
            },
        };
    }
    /**
     * Try to push unpushed commits from a worktree as a rescue operation.
     */
    rescuePush(worktree, branch) {
        try {
            execFileSync('git', ['-C', worktree, 'push', 'origin', branch], {
                timeout: 30_000,
                stdio: ['ignore', 'pipe', 'pipe'],
            });
            return true;
        }
        catch {
            this.log(`Rescue push failed for ${branch} in ${worktree}`);
            return false;
        }
    }
    /**
     * Check if a process is still alive by sending signal 0.
     */
    isPidAlive(pid) {
        try {
            process.kill(pid, 0);
            return true;
        }
        catch {
            return false;
        }
    }
    // ─── Private: State Helpers ──────────────────────────────────
    rd() { return readState(this.stateFile, this.maxWorkers); }
    wr(state, by) { writeState(this.stateFile, state, by); }
    findIdleSlot(state) {
        return Object.entries(state.workers).find(([, w]) => w.status === 'idle')?.[0] ?? null;
    }
    claimSlot(state, slot, ctx) {
        const seqNum = parseInt(ctx.seq, 10) || 0;
        state.workers[slot] = {
            ...createIdleWorkerSlot(), status: 'active', seq: seqNum,
            branch: ctx.branch, worktree: ctx.cwd, claimedAt: ctx.nowIso, lastHeartbeat: ctx.nowIso,
            mode: 'acp-sdk', transport: 'acp-sdk', agent: ctx.tool,
            outputFile: null,
        };
        state.activeCards[ctx.seq] = {
            seq: seqNum, state: 'Inprogress', worker: slot, mrUrl: null,
            conflictDomains: [], startedAt: ctx.nowIso, retryCount: 0,
        };
        state.leases[ctx.seq] = {
            seq: seqNum, pmStateObserved: ctx.phase === 'integration' ? 'QA' : 'Inprogress',
            phase: 'coding', slot, branch: ctx.branch, worktree: ctx.cwd,
            sessionId: null, runId: null, claimedAt: ctx.nowIso, retryCount: 0,
            lastTransitionAt: ctx.nowIso,
        };
    }
    releaseSlotInState(slot, taskId) {
        const state = this.rd();
        state.workers[slot] = createIdleWorkerSlot();
        delete state.activeCards[taskId];
        delete state.leases[taskId];
        this.wr(state, 'wm-release');
    }
    mapWorkerState(w) {
        if (w.status === 'idle')
            return 'idle';
        if (w.status === 'active') {
            if (w.remoteStatus === 'waiting_input')
                return 'waiting_input';
            if (w.remoteStatus === 'needs_confirmation' || w.sessionState === 'needs_confirmation')
                return 'needs_confirmation';
            if (w.remoteStatus === 'completed')
                return 'completed';
            if (w.remoteStatus === 'failed')
                return 'failed';
            return 'running';
        }
        if (w.status === 'releasing')
            return 'completed';
        return 'running';
    }
    requireAgentSlot(taskId, op) {
        const slot = this.taskSlotMap.get(taskId);
        if (!slot)
            throw new Error(`Task ${taskId} not found`);
        if (!this.agentRuntime)
            throw new Error(`${op} requires agentRuntime (not available)`);
        const state = this.rd();
        const w = state.workers[slot];
        if (!w || (w.transport !== 'acp-sdk' && w.mode !== 'acp-sdk' && w.mode !== 'acp')) {
            throw new Error(`${op} unsupported for transport=${w?.transport ?? 'unknown'}`);
        }
        return slot;
    }
    // ─── Private: Timeout Management ─────────────────────────────
    startTimeout(taskId, phase, project, slot, customTimeoutSec) {
        const baseSec = customTimeoutSec ?? DEFAULT_TIMEOUTS.developmentSec;
        const hardSec = Math.ceil(baseSec * DEFAULT_TIMEOUTS.forceMultiplier);
        const workerId = `${project}:${slot}:${taskId}`;
        const softTimer = setTimeout(() => {
            this.emitEvent({
                type: 'status.update', taskId, cardId: taskId, project, phase, slot,
                workerId, timestamp: new Date().toISOString(), state: 'running',
                error: `Timeout: exceeded ${baseSec}s`,
            });
            this.log(`Soft timeout for ${taskId} after ${baseSec}s`);
            // Hard timeout — force kill (wrapped in try-catch to prevent unhandled rejection)
            const hardTimer = setTimeout(async () => {
                try {
                    this.log(`Hard timeout for ${taskId} after ${hardSec}s — force killing`);
                    await this.cancel({ taskId, project, reason: 'timeout' });
                }
                catch (err) {
                    this.log(`Hard timeout cancel failed for ${taskId}: ${err instanceof Error ? err.message : err}`);
                }
            }, (hardSec - baseSec) * 1000);
            hardTimer.unref();
            this.timeouts.set(`${taskId}:hard`, hardTimer);
        }, baseSec * 1000);
        softTimer.unref();
        this.timeouts.set(taskId, softTimer);
    }
    clearTimeoutForTask(taskId) {
        const soft = this.timeouts.get(taskId);
        const hard = this.timeouts.get(`${taskId}:hard`);
        if (soft) {
            clearTimeout(soft);
            this.timeouts.delete(taskId);
        }
        if (hard) {
            clearTimeout(hard);
            this.timeouts.delete(`${taskId}:hard`);
        }
    }
    // ─── Private: Event + Response Helpers ───────────────────────
    emitEvent(event) {
        for (const handler of this.eventHandlers) {
            try {
                handler(event);
            }
            catch (err) {
                this.log(`Event handler error: ${err instanceof Error ? err.message : String(err)}`);
            }
        }
    }
    cleanup() {
        // Clear all pending timeouts (soft + hard)
        for (const [_key, timer] of this.timeouts) {
            clearTimeout(timer);
        }
        this.timeouts.clear();
        // Abort all ACP monitors
        for (const [_slot, controller] of this.acpMonitorAborts) {
            controller.abort();
        }
        this.acpMonitorAborts.clear();
        // v0.41.2: Hygiene — delete per-slot marker files so the runtime/ dir
        // doesn't accumulate stale files across tick runs. Safe here because
        // cleanup only fires on tick exit (no active hook could still need them).
        try {
            const runtimeDir = dirname(this.stateFile);
            const entries = readdirSync(runtimeDir);
            for (const name of entries) {
                if (/^worker-.*-current\.json(\.tmp)?$/.test(name)) {
                    try {
                        unlinkSync(resolve(runtimeDir, name));
                    }
                    catch { /* best effort */ }
                }
            }
        }
        catch { /* runtime dir may not exist, non-fatal */ }
        this.log('Cleanup: cleared all timeouts, ACP monitors, and marker files');
    }
    reject(reason) {
        return { accepted: false, slot: null, workerId: null, rejectReason: reason };
    }
    log(msg) { process.stderr.write(`[worker-manager] ${msg}\n`); }
    /**
     * Atomically write the per-slot "current card" marker file that hook scripts
     * consult to know which card is currently being worked on. Delegates to the
     * shared markerFile utility; reports non-fatal write errors to the logger.
     */
    writeCurrentCardFile(slot, cardId, stage) {
        const finalPath = getMarkerPathFromStateFile(this.stateFile, slot);
        writeCurrentCardFile(finalPath, cardId, stage, (err) => {
            this.log(`Failed to write current-card marker for ${slot}:${cardId} — ${err instanceof Error ? err.message : String(err)}`);
        });
    }
    /**
     * Merge sessionId + pid into the existing marker file after a successful
     * spawn. Non-fatal: if the marker was deleted or patching fails, the base
     * info (cardId/stage) from the first write is still there so hooks work.
     */
    patchCurrentCardFile(slot, patch) {
        const finalPath = getMarkerPathFromStateFile(this.stateFile, slot);
        patchCurrentCardFile(finalPath, patch, (err) => {
            this.log(`Failed to patch current-card marker for ${slot} with sessionId/pid — ${err instanceof Error ? err.message : String(err)}`);
        });
    }
}
//# sourceMappingURL=worker-manager-impl.js.map