/**
 * Job Scheduler — cron-based job execution engine.
 *
 * Schedules jobs via croner, respects session limits and quota,
 * queues jobs when at capacity, and drains when slots open.
 *
 * Simplified from Dawn's 1400-line scheduler — serial queue,
 * no JSONL discovery, no machine coordination.
 */
import { Cron } from 'croner';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import fs from 'node:fs';
import crypto from 'node:crypto';
import { SafeFsExecutor } from '../core/SafeFsExecutor.js';
import { DegradationReporter } from '../monitoring/DegradationReporter.js';
const execFileAsync = promisify(execFile);
import path from 'node:path';
import { ExecutionJournal } from '../core/ExecutionJournal.js';
import { JobReflector } from '../core/JobReflector.js';
import { loadJobs } from './JobLoader.js';
import { JobRunHistory } from './JobRunHistory.js';
import { SkipLedger } from './SkipLedger.js';
import { classifySessionDeath } from '../monitoring/QuotaExhaustionDetector.js';
import { TOPIC_STYLE } from '../messaging/TelegramAdapter.js';
import { decideClaimPath } from './JobLeaseCutoverGate.js';
/**
 * Fallback expected duration (minutes) used when a run is encountered for a
 * slug that's no longer present in `jobs` (e.g., job config was removed
 * or reloaded mid-sleep). Reaper threshold = this × thresholdMultiplier.
 */
const DEFAULT_EXPECTED_MINUTES = 30;
const PRIORITY_ORDER = {
    critical: 0,
    high: 1,
    medium: 2,
    low: 3,
};
export class JobScheduler {
    config;
    sessionManager;
    state;
    stateDir;
    skipLedger;
    runHistory;
    jobs = [];
    cronTasks = new Map();
    queue = [];
    running = false;
    paused = false;
    /** Map session names to run IDs for completion tracking */
    activeRunIds = new Map();
    /** Retry state for skipped jobs: slug → { retries, lastAttempt, timer } */
    retryState = new Map();
    /** Retry delays in ms: 1min, 5min, 15min, 30min, 1h, 2h */
    static RETRY_DELAYS_MS = [
        60_000, // 1 min
        300_000, // 5 min
        900_000, // 15 min
        1_800_000, // 30 min
        3_600_000, // 1 hour
        7_200_000, // 2 hours
    ];
    /** Local machine identity — used for machine-scoped job filtering */
    machineId = null;
    machineName = null;
    /**
     * Callback to check if a job at the given priority may run.
     * May return a plain boolean (legacy) or a CanRunJobResult so the
     * scheduler can record the actual gating reason (memory vs quota vs gate).
     */
    canRunJob = () => true;
    /** Optional messenger for sending job notifications */
    messenger = null;
    /** Optional Telegram adapter for job-topic coupling */
    telegram = null;
    /** Optional quota tracker for death classification cross-reference */
    quotaTracker = null;
    /** Optional job claim manager for multi-machine deduplication (Phase 4C) */
    claimManager = null;
    /**
     * MULTI-MACHINE-SEAMLESSNESS-SPEC §WS4.3 journal-lease cutover. The durable,
     * epoch-fenced lease store — the journal-backed UPGRADE of the best-effort
     * AgentBus broadcast in `claimManager`. It is consulted for a job ONLY when
     * `journalLeaseGate` selects the 'journal' path (flag on + pool coherent). The
     * gate guarantees the two mechanisms are never both live for a job set (the
     * named migration hazard). When unset, the scheduler stays on the legacy bus
     * path (byte-for-byte today's behavior).
     */
    leaseClaimStore = null;
    /**
     * Live provider for the cutover-gate input (flag + dry-run + the pool peers'
     * advertised ws43JournalLease capability + this machine's lease epoch). Read
     * LIVE at each claim boundary so a flag flip / a peer going dark cuts the pool
     * back to the bus immediately. When unset, the gate is never consulted and the
     * legacy bus path runs (strict no-op).
     */
    journalLeaseGate = null;
    /** Optional sink for the most-recent cutover decision (status/audit surface). */
    onCutoverDecision = null;
    /**
     * UNIFIED-SESSION-LIFECYCLE §P0 #9 (SE-8): function returning cumulative
     * wall-time-asleep in milliseconds during a half-open window [startMs, endMs).
     * Wired by server.ts to SleepWakeDetector.getCumulativeSleepMsBetween. When
     * unset, the wake-reaper falls back to no sleep subtraction (the pre-Phase-2
     * behavior — never more aggressive).
     */
    cumulativeSleepProvider = null;
    /** Optional LLM provider for per-job reflection (Living Skills Phase 4) */
    intelligence = null;
    /** Optional IntegrationGate for post-completion learning consolidation */
    integrationGate = null;
    /** Optional TopicMemory for topic-aware job sessions */
    topicMemory = null;
    /**
     * MULTI-MACHINE-SEAMLESSNESS-SPEC §WS4.3 role-guard-at-spawn (CMT-1416).
     * When set, a STATE-WRITING job (JobDefinition.writesState) is refused at the
     * spawn boundary if this machine does NOT hold the lease — the TOCTOU re-check
     * that closes the window where a machine awake at boot demotes to read-only
     * standby mid-run while its cron tasks keep firing (the scheduler is built only
     * when awake but is never torn down on demotion). The provider returns the live
     * gate state read at the spawn boundary (NOT cached at wiring time): `enabled`
     * reflects `multiMachine.seamlessness.ws43RoleGuard` and `holdsLease` reflects
     * the coordinator's structural lease verdict. When unset (or `enabled:false`),
     * the guard is a strict no-op — byte-for-byte today's behavior. A
     * single-machine agent always holds the lease, so the guard never fires.
     */
    roleGuardProvider = null;
    /**
     * MULTI-MACHINE-SEAMLESSNESS-SPEC §WS4.3 — best-effort callback to raise ONE
     * deduped attention item when a state-writing job is refused at the spawn
     * boundary. Wired by server.ts to the attention surface. Signal-only: a missing
     * callback (or a throwing one) never blocks the refusal — the refusal is the
     * load-bearing safety, the attention item is the operator heads-up.
     */
    roleGuardAttention = null;
    constructor(config, sessionManager, state, stateDir) {
        this.config = config;
        this.sessionManager = sessionManager;
        this.state = state;
        this.stateDir = stateDir;
        this.skipLedger = new SkipLedger(stateDir);
        this.runHistory = new JobRunHistory(stateDir);
    }
    /**
     * Set a messaging adapter for job completion notifications.
     */
    setMessenger(adapter) {
        this.messenger = adapter;
    }
    /**
     * Inject the cumulative-sleep provider used by the wake-reaper
     * (UNIFIED-SESSION-LIFECYCLE §P0 #9 / SE-8). Pass undefined or omit to leave
     * the reaper in its no-subtraction fallback behavior.
     */
    setCumulativeSleepProvider(fn) {
        this.cumulativeSleepProvider = fn;
    }
    /**
     * Set the Telegram adapter for job-topic coupling.
     * Every job gets its own topic — the user's window into the job.
     */
    setTelegram(adapter) {
        this.telegram = adapter;
        // If scheduler already started, ensure job topics now that Telegram is available.
        // This fixes the startup race condition where start() runs before Telegram connects.
        if (this.running && this.jobs.length > 0) {
            const enabledJobs = this.jobs.filter(j => j.enabled);
            this.ensureJobTopics(enabledJobs).catch(err => {
                console.error(`[scheduler] Failed to ensure job topics (post-Telegram init): ${err}`);
            });
        }
    }
    /**
     * Set the quota tracker for session death classification.
     * When set, session deaths are cross-referenced with quota state
     * to determine if they died from quota exhaustion.
     */
    setQuotaTracker(tracker) {
        this.quotaTracker = tracker;
    }
    /**
     * Set the job claim manager for multi-machine deduplication.
     * When set, the scheduler will broadcast claims before executing jobs
     * and skip jobs already claimed by other machines.
     */
    setJobClaimManager(manager) {
        this.claimManager = manager;
    }
    /**
     * MULTI-MACHINE-SEAMLESSNESS-SPEC §WS4.3 — inject the journal-lease cutover.
     *
     * `store` is the durable epoch-fenced lease store; `gateInput` returns the
     * LIVE cutover-gate input (flag, dry-run, pool peers' advertised capability,
     * and this machine's current lease epoch) — read fresh at each claim boundary,
     * never cached. `onDecision` (optional) receives the resolved decision per job
     * for the status/audit surface.
     *
     * When omitted, the scheduler never consults the journal path — the legacy
     * AgentBus `claimManager` runs unchanged (single-machine / flag-off no-op).
     */
    setJournalLeaseCutover(store, gateInput, onDecision) {
        this.leaseClaimStore = store;
        this.journalLeaseGate = gateInput;
        this.onCutoverDecision = onDecision ?? null;
    }
    /**
     * Resolve which claim mechanism a job uses RIGHT NOW. Pure read of the live
     * gate input. Returns null when the cutover is not wired (legacy bus only).
     * Exposed for the scheduler's claim seams (check + claim + complete) so a
     * single evaluation drives all three — never two mechanisms for one job.
     */
    resolveClaimPath(slug) {
        if (!this.journalLeaseGate || !this.leaseClaimStore)
            return null;
        let input;
        try {
            input = this.journalLeaseGate();
        }
        catch {
            // @silent-fallback-ok — fail toward the legacy bus path (the conservative
            // side) if the live gate read throws; never strand a job on a broken gate.
            return null;
        }
        const decision = decideClaimPath(input);
        this.onCutoverDecision?.(slug, decision);
        return { path: decision.path, epoch: input.epoch, journalDryRun: decision.journalDryRun };
    }
    /**
     * Release a claim for a completed job across BOTH mechanisms. The path that
     * actually took the claim may differ from the path the gate would pick at
     * completion time (a mid-run flag flip), so we release both — each store's
     * completeClaim is a strict no-op when this machine does not own the slug's
     * live record, so releasing the unused store is harmless. The journal release
     * is synchronous (durable); the bus release is best-effort async.
     */
    releaseClaim(slug, result) {
        try {
            this.leaseClaimStore?.completeClaim(slug, result);
        }
        catch {
            /* @silent-fallback-ok — best-effort; lease expires on its own otherwise */
        }
        this.claimManager?.completeClaim(slug, result).catch(err => {
            console.error(`[scheduler] Failed to broadcast claim completion for "${slug}": ${err}`);
        });
    }
    /**
     * Set local machine identity for machine-scoped job filtering.
     * Jobs with a `machines` field will only run on machines whose ID or name matches.
     */
    setMachineIdentity(machineId, machineName) {
        this.machineId = machineId;
        this.machineName = machineName;
        this.runHistory.setMachineId(machineId);
    }
    /**
     * Set the intelligence provider for per-job LLM reflection (Living Skills Phase 4).
     */
    setIntelligence(provider) {
        this.intelligence = provider;
    }
    /**
     * Set the IntegrationGate for post-completion learning consolidation.
     * When set, reflection runs synchronously before queue drain.
     * When not set, the existing fire-and-forget reflection behavior is preserved.
     */
    setIntegrationGate(gate) {
        this.integrationGate = gate;
    }
    /**
     * Set the TopicMemory for topic-aware job sessions.
     * When set, jobs bound to a topic receive awareness context about the topic's focus.
     */
    setTopicMemory(topicMemory) {
        this.topicMemory = topicMemory;
    }
    /**
     * MULTI-MACHINE-SEAMLESSNESS-SPEC §WS4.3 — inject the role-guard-at-spawn
     * provider. `provider` is read LIVE at each spawn boundary (never cached) so a
     * mid-run demotion takes effect immediately. `onRefused` (optional) raises the
     * deduped attention item when a state-writing job is refused. When the provider
     * is omitted the guard is a strict no-op. See `roleGuardProvider`.
     */
    setRoleGuard(provider, onRefused) {
        this.roleGuardProvider = provider;
        this.roleGuardAttention = onRefused ?? null;
    }
    /**
     * Start the scheduler — load jobs, set up cron tasks, check for missed jobs.
     */
    start() {
        if (this.running)
            return;
        this.jobs = loadJobs(this.config.jobsFile);
        this.running = true;
        // Phase 1b: agentmd entries now flow through the dispatch path
        // alongside legacy entries. The Phase 1a defensive filter on
        // execute.type === 'agentmd' has been removed; buildPrompt's
        // 'agentmd' case returns the cached body, and spawnJobSession
        // threads the per-job tool allowlist into the spawn call.
        const enabledJobs = this.jobs.filter(j => j.enabled);
        // Machine-scoped filtering — skip jobs not targeted at this machine
        const scopedJobs = enabledJobs.filter(j => this.isJobScopedToThisMachine(j));
        const skippedByScope = enabledJobs.length - scopedJobs.length;
        if (skippedByScope > 0) {
            const skippedNames = enabledJobs
                .filter(j => !this.isJobScopedToThisMachine(j))
                .map(j => j.slug);
            console.log(`[scheduler] ${skippedByScope} job(s) skipped (machine scope): ${skippedNames.join(', ')}`);
        }
        for (const job of scopedJobs) {
            try {
                const task = new Cron(job.schedule, async () => {
                    // New cron window — reset retry state so we get fresh attempts
                    this.clearRetryState(job.slug);
                    await this.triggerJob(job.slug, 'scheduled');
                });
                this.cronTasks.set(job.slug, task);
            }
            catch (err) {
                console.error(`[scheduler] Invalid cron expression for job "${job.slug}": ${job.schedule} — ${err instanceof Error ? err.message : err}`);
            }
        }
        // Check for missed jobs — any enabled job overdue by >1.5x its interval.
        // Delay the first evaluation by startupGraceMs (default 5s) so the HTTP
        // server is ready before health-check gates run.  Without this, gate
        // checks fail on startup and jobs wait for the next cron window.
        const graceMs = this.config.startupGraceMs ?? 5000;
        if (graceMs > 0) {
            console.log(`[scheduler] Startup grace period: ${graceMs}ms before missed-job evaluation`);
            setTimeout(() => this.checkMissedJobs(scopedJobs), graceMs);
        }
        else {
            this.checkMissedJobs(scopedJobs);
        }
        // Ensure every job has a Telegram topic (job-topic coupling)
        if (this.telegram) {
            this.ensureJobTopics(scopedJobs).catch(err => {
                console.error(`[scheduler] Failed to ensure job topics: ${err}`);
            });
        }
        this.state.appendEvent({
            type: 'scheduler_start',
            summary: `Scheduler started with ${scopedJobs.length} enabled jobs` + (skippedByScope > 0 ? ` (${skippedByScope} skipped by machine scope)` : ''),
            timestamp: new Date().toISOString(),
        });
    }
    /**
     * Stop the scheduler — cancel all cron tasks.
     */
    stop() {
        if (!this.running)
            return;
        for (const [, task] of this.cronTasks) {
            task.stop();
        }
        this.cronTasks.clear();
        this.queue = [];
        // Clear all retry timers
        for (const [, state] of this.retryState) {
            if (state.timer)
                clearTimeout(state.timer);
        }
        this.retryState.clear();
        this.running = false;
        this.state.appendEvent({
            type: 'scheduler_stop',
            summary: 'Scheduler stopped',
            timestamp: new Date().toISOString(),
        });
    }
    /**
     * Trigger a job by slug. Checks claims, quota, session limits, queues if at capacity.
     */
    async triggerJob(slug, reason) {
        const job = this.jobs.find(j => j.slug === slug);
        if (!job) {
            throw new Error(`Unknown job: ${slug}`);
        }
        if (this.paused) {
            this.skipLedger.recordSkip(slug, 'paused');
            return 'skipped';
        }
        // Machine scope check — skip jobs not targeted at this machine
        if (!this.isJobScopedToThisMachine(job)) {
            this.skipLedger.recordSkip(slug, 'machine-scope');
            this.state.appendEvent({
                type: 'job_skipped',
                summary: `Job "${slug}" skipped — not scoped to this machine`,
                timestamp: new Date().toISOString(),
                metadata: { slug, reason, machines: job.machines },
            });
            return 'skipped';
        }
        // WS4.3 role-guard-at-spawn (MULTI-MACHINE-SEAMLESSNESS-SPEC §WS4.3,
        // CMT-1416). A STATE-WRITING job must not spawn on a read-only standby. The
        // scheduler is constructed only on an awake machine, but it is NEVER torn
        // down on demotion — so a machine awake at boot that loses the lease mid-run
        // keeps firing cron tasks. This is the spawn-boundary TOCTOU re-check (the
        // same family as WS1.1's _ownershipReadForDrain): the provider is read LIVE
        // here, so a mid-run demotion takes effect immediately. The writable owner's
        // own scheduler runs the job (the cron fires on every machine; only the
        // owner's pass clears the guard), so the refusal re-routes by construction —
        // and ONE deduped attention item gives the operator a heads-up. DARK by
        // default (provider unset or enabled:false → strict no-op); single-machine
        // agents always hold the lease, so the guard never fires.
        if (job.writesState && this.roleGuardProvider) {
            let guard;
            try {
                guard = this.roleGuardProvider();
            }
            catch {
                // @silent-fallback-ok — a throwing provider must never gate a job: the
                // guard is an additive safety, and a broken provider degrades to today's
                // behavior (spawn proceeds) rather than wedging the scheduler.
                guard = { enabled: false, holdsLease: true };
            }
            if (guard.enabled && !guard.holdsLease) {
                this.skipLedger.recordSkip(slug, 'role-guard');
                console.log(`[scheduler] Job "${slug}" refused at spawn boundary — state-writing job on a read-only standby (not the lease-holder).`);
                this.state.appendEvent({
                    type: 'job_skipped',
                    summary: `Job "${slug}" refused — state-writing job not allowed on a read-only standby`,
                    timestamp: new Date().toISOString(),
                    metadata: { slug, reason, gateReason: 'role-guard', writesState: true },
                });
                try {
                    this.roleGuardAttention?.(slug, this.machineId);
                }
                catch {
                    // @silent-fallback-ok — the attention heads-up is best-effort; the
                    // refusal above is the load-bearing safety and already happened.
                }
                return 'skipped';
            }
        }
        // Multi-machine claim check (Phase 4C — Gap 5; WS4.3 journal-lease cutover).
        // The cutover gate resolves WHICH mechanism owns dedup for this job RIGHT
        // NOW — the durable epoch-fenced journal lease (flag on + pool coherent) or
        // the legacy AgentBus broadcast. Exactly one is consulted per job, so the
        // two never run simultaneously for the same job set (the named migration
        // hazard). The decision is recomputed live at each seam; in the steady
        // coherent state it is stable across the same tick.
        const claimPath = this.resolveClaimPath(slug);
        const remoteHeld = claimPath?.path === 'journal'
            ? this.leaseClaimStore.hasRemoteClaim(slug)
            : (this.claimManager?.hasRemoteClaim(slug) ?? false);
        if (remoteHeld) {
            const claimedBy = claimPath?.path === 'journal'
                ? this.leaseClaimStore.getClaim(slug)?.machineId
                : this.claimManager?.getClaim(slug)?.machineId;
            this.skipLedger.recordSkip(slug, 'claimed');
            this.state.appendEvent({
                type: 'job_skipped',
                summary: `Job "${slug}" skipped — claimed by another machine`,
                timestamp: new Date().toISOString(),
                metadata: { slug, reason, claimedBy, claimPath: claimPath?.path ?? 'bus' },
            });
            return 'skipped';
        }
        // Per-slug double-run guard (june15-headless-spawn-reroute, PR2 finding O3).
        // A rerouted job REPL survives a server restart (boot purge keeps alive
        // sessions) but JobScheduler.activeRunIds (in-memory) is lost — so without
        // this guard the same slug could re-trigger on its next cron tick while the
        // orphan session still runs: double execution + double billing. If a LIVE
        // session already holds this jobSlug, skip the trigger (log + record, no
        // throw). Applies to ALL jobs (not only rerouted ones) — a slow headless job
        // that outran its cron interval was always at risk; this closes it generally.
        // The boot-reconcile in SessionManager.purgeDeadSessions kills surviving
        // rerouted job sessions, so the steady state is one-run-per-slug.
        const liveWithSlug = this.sessionManager.listRunningSessions().some(s => s.jobSlug === slug);
        if (liveWithSlug) {
            this.skipLedger.recordSkip(slug, 'already-running');
            console.log(`[scheduler] Job "${slug}" skipped — a live session already holds this slug (avoiding double-run).`);
            this.state.appendEvent({
                type: 'job_skipped',
                summary: `Job "${slug}" skipped — already running`,
                timestamp: new Date().toISOString(),
                metadata: { slug, reason, gateReason: 'already-running' },
            });
            return 'skipped';
        }
        // Script jobs bypass quota gating — they don't consume LLM tokens
        if (job.execute.type !== 'script') {
            const gateResult = this.normalizeCanRunResult(this.canRunJob(job.priority));
            if (!gateResult.allowed) {
                const skipReason = gateResult.reason ?? 'quota';
                const detail = gateResult.detail ? ` (${gateResult.detail})` : '';
                this.skipLedger.recordSkip(slug, skipReason);
                this.state.appendEvent({
                    type: 'job_skipped',
                    summary: `Job "${slug}" skipped — ${skipReason}${detail}`,
                    timestamp: new Date().toISOString(),
                    metadata: { slug, reason, priority: job.priority, gateReason: skipReason, gateDetail: gateResult.detail },
                });
                this.scheduleRetry(slug, skipReason);
                return 'skipped';
            }
        }
        // Run gate command if configured — zero-token pre-screening (async, non-blocking)
        if (job.gate) {
            if (!await this.runGateAsync(job)) {
                this.scheduleRetry(slug, 'gate');
                return 'skipped';
            }
        }
        // Script jobs are zero-token shell work — they do NOT spawn a model session,
        // so they bypass session-capacity gating and the spawn path entirely and run
        // directly in a bounded subprocess. Without this they were spawned with a
        // "Run this script: ..." prompt and could hang for hours holding a live
        // session slot, leaving run-history stuck at `pending` (Codey gap-run F005).
        if (job.execute.type === 'script') {
            this.clearRetryState(slug);
            this.runScriptJob(job, reason);
            return 'triggered';
        }
        // Check session capacity
        const runningSessions = this.sessionManager.listRunningSessions();
        const jobSessions = runningSessions.filter(s => s.jobSlug);
        if (jobSessions.length >= this.config.maxParallelJobs) {
            this.enqueue(slug, reason);
            return 'queued';
        }
        // Take the claim before spawning. The cutover gate (resolved at the check
        // seam above) decides the mechanism: a durable epoch-fenced journal lease
        // when the pool is coherent, else the legacy best-effort AgentBus broadcast.
        // DRY-RUN: when the gate is coherent-but-dry-run, the intended journal claim
        // is LOGGED (the WS-wide "log intended claims" posture) but the bus path
        // still runs — so a dry-run pool never half-migrates.
        const timeoutMs = (job.expectedDurationMinutes ?? 30) * 2 * 60_000;
        if (claimPath?.path === 'journal') {
            // Synchronous, durable: the lease is persisted before the spawn, so a
            // crash between claim and spawn leaves the lease (fenced, expiring) rather
            // than a silent double-run window.
            const attempt = this.leaseClaimStore.tryClaim(slug, claimPath.epoch, timeoutMs);
            if (!attempt.ok && attempt.reason === 'held-by-peer') {
                // A peer leased it between the check and here — yield (rare race).
                this.skipLedger.recordSkip(slug, 'claimed');
                this.state.appendEvent({
                    type: 'job_skipped',
                    summary: `Job "${slug}" skipped — leased by another machine`,
                    timestamp: new Date().toISOString(),
                    metadata: { slug, reason, claimedBy: attempt.heldBy, claimPath: 'journal' },
                });
                return 'skipped';
            }
        }
        else {
            if (claimPath?.journalDryRun) {
                console.log(`[scheduler] WS4.3 journal-lease DRY-RUN — would lease "${slug}" via journal (epoch ${claimPath.epoch}); running legacy bus path.`);
            }
            if (this.claimManager) {
                this.claimManager.tryClaim(slug, timeoutMs).catch(err => {
                    console.error(`[scheduler] Failed to broadcast claim for "${slug}": ${err}`);
                });
            }
        }
        // Clear retry state on successful trigger
        this.clearRetryState(slug);
        this.spawnJobSession(job, reason);
        return 'triggered';
    }
    /**
     * Check if a job is scoped to run on this machine.
     * Jobs without a `machines` field run everywhere (backwards-compatible).
     * Jobs with `machines` only run if this machine's ID or name matches.
     */
    isJobScopedToThisMachine(job) {
        if (!job.machines || job.machines.length === 0)
            return true;
        if (!this.machineId && !this.machineName)
            return true; // No identity = run everything
        return job.machines.some(m => {
            const lower = m.toLowerCase();
            return ((this.machineId && lower === this.machineId.toLowerCase()) ||
                (this.machineName && lower === this.machineName.toLowerCase()));
        });
    }
    /**
     * Process the queue — dequeue and run next job if a slot is available.
     */
    processQueue() {
        if (this.paused || this.queue.length === 0)
            return;
        const runningSessions = this.sessionManager.listRunningSessions();
        const jobSessions = runningSessions.filter(s => s.jobSlug);
        if (jobSessions.length >= this.config.maxParallelJobs)
            return;
        const next = this.queue.shift();
        if (!next)
            return;
        const job = this.jobs.find(j => j.slug === next.slug);
        if (!job)
            return;
        const queueGate = this.normalizeCanRunResult(this.canRunJob(job.priority));
        if (!queueGate.allowed) {
            // Re-add to front of queue — don't silently drop
            this.queue.unshift(next);
            return;
        }
        this.spawnJobSession(job, `queued:${next.reason}`);
    }
    /**
     * Pause — cron tasks keep ticking but triggers are skipped.
     */
    pause() {
        this.paused = true;
    }
    /**
     * Clear the pending job queue.
     */
    clearQueue() {
        this.queue.length = 0;
    }
    /**
     * Resume — triggers start executing again.
     */
    resume() {
        this.paused = false;
        this.processQueue();
    }
    /**
     * Get scheduler status for the /status endpoint.
     */
    getStatus() {
        const runningSessions = this.sessionManager.listRunningSessions();
        return {
            running: this.running,
            paused: this.paused,
            jobCount: this.jobs.length,
            enabledJobs: this.jobs.filter(j => j.enabled).length,
            queueLength: this.queue.length,
            activeJobSessions: runningSessions.filter(s => s.jobSlug).length,
        };
    }
    /**
     * Get loaded job definitions (for /jobs endpoint).
     */
    getJobs() {
        return this.jobs;
    }
    /**
     * Get the current queue.
     */
    getQueue() {
        return [...this.queue];
    }
    /**
     * Get live nextScheduled times for all jobs from croner tasks.
     * Returns a map of slug → ISO timestamp (or undefined if no cron task).
     * This is the live schedule, independent of saved state files.
     */
    getNextRunTimes() {
        const result = {};
        for (const job of this.jobs) {
            result[job.slug] = this.getNextRun(job.slug);
        }
        return result;
    }
    /**
     * Check if a job will run on this machine (for API visibility).
     */
    isJobLocal(slug) {
        const job = this.jobs.find(j => j.slug === slug);
        return job ? this.isJobScopedToThisMachine(job) : false;
    }
    /**
     * Get the skip ledger instance (for API routes).
     */
    getSkipLedger() {
        return this.skipLedger;
    }
    /**
     * Get the run history instance (for API routes).
     */
    getRunHistory() {
        return this.runHistory;
    }
    /**
     * Reap stuck job runs after a system sleep/wake transition.
     *
     * When the host machine sleeps mid-job, the job's tmux session is
     * suspended along with everything else and timer-based supervision
     * (claim TTL, completion callbacks) cannot fire. On wake, runs that
     * were in-flight stay `pending` in the ledger until the next claim
     * TTL multi-hour timeout kicks in — or, in some cases, indefinitely.
     *
     * This method is invoked from the SleepWakeDetector wake handler.
     * It scans `activeRunIds` and reaps any run whose elapsed time
     * exceeds twice its `expectedDurationMinutes` — the same threshold
     * the scheduler already uses for claim TTL.
     *
     * Reaping is idempotent: a second invocation finds the run is no
     * longer `pending` and skips it.
     */
    async reapStuckRuns(sleepEvent) {
        const result = { reaped: [], skipped: 0 };
        const minSleepSec = this.config.wakeReaper?.minSleepSeconds ?? 60;
        if (sleepEvent.sleepDurationSeconds < minSleepSec) {
            return result;
        }
        const multiplier = this.config.wakeReaper?.thresholdMultiplier ?? 2;
        const now = Date.now();
        const entries = Array.from(this.activeRunIds.entries());
        const jobMap = new Map(this.jobs.map(j => [j.slug, j]));
        for (const [sessionName, runId] of entries) {
            const run = this.runHistory.findRun(runId);
            if (!run || run.result !== 'pending') {
                result.skipped++;
                continue;
            }
            const job = jobMap.get(run.slug);
            const expMin = job?.expectedDurationMinutes ?? DEFAULT_EXPECTED_MINUTES;
            const thresholdMs = expMin * multiplier * 60_000;
            const runStartMs = new Date(run.startedAt).getTime();
            const elapsedMs = now - runStartMs;
            // §P0 #9 / SE-8: cumulative wall-time-asleep DURING this run, not the single
            // last sleepDurationSeconds. A run that started before multiple sleeps was
            // previously credited only the last sleep and reaped early.
            const cumulativeSleepMs = this.cumulativeSleepProvider?.(runStartMs, now) ?? 0;
            const effectiveElapsed = elapsedMs - cumulativeSleepMs;
            if (effectiveElapsed <= thresholdMs) {
                result.skipped++;
                continue;
            }
            // §P0 #9 P1/P2 gate — a progressing process is KEEP regardless of clock.
            // The wall-clock gap can exceed the threshold for an actively-running job
            // (the user kept the machine awake; the job stalled on a remote API; etc.)
            // — never reap a session that's still producing real work.
            if (this.sessionManager.hasActiveProcesses?.(sessionName)) {
                console.log(`[scheduler][reaper] "${sessionName}" effective-elapsed ${Math.round(effectiveElapsed / 60_000)}m `
                    + `exceeded ${expMin}m × ${multiplier} threshold, but P1/P2 work-check found active children — keeping`);
                result.skipped++;
                continue;
            }
            // §P0 #9 route through the ReapAuthority — the authority's protected-set
            // + lease gate + reap-log + sessionReaped emission apply (one chokepoint).
            try {
                const tracked = this.sessionManager.listRunningSessions?.().find((s) => s.tmuxSession === sessionName);
                if (tracked) {
                    await this.sessionManager.terminateSession?.(tracked.id, 'wake-reaper', {
                        disposition: 'terminal',
                        finalStatus: 'killed',
                    });
                }
                else {
                    // Not currently tracked — fall back to killSession (which itself is a
                    // pane-only teardown when the state record is already terminal).
                    this.sessionManager.killSession?.(sessionName);
                }
            }
            catch {
                // Best-effort — session may already be dead.
            }
            try {
                this.runHistory.recordCompletion({
                    runId,
                    result: 'timeout',
                    error: `Reaped on wake — effective elapsed ${Math.round(effectiveElapsed / 60_000)}m (cumulative sleep ${Math.round(cumulativeSleepMs / 60_000)}m subtracted) exceeded ${expMin}min × ${multiplier} threshold`,
                });
            }
            catch (err) {
                // Leave activeRunIds entry intact: run is still 'pending' in the ledger,
                // so the next reaper invocation will retry. Log slug+runId so a structural
                // failure (disk full, file locked) is greppable in operator output.
                console.error(`[scheduler][reaper] recordCompletion failed for ${run.slug} (runId=${runId}):`, err);
                continue;
            }
            try {
                // Note: claim is finalized as 'failure' (slug is unblocked for the next tick)
                // while run history records 'timeout' (preserves the cause for diagnostics).
                // The asymmetry is intentional — don't "fix" it. Releases both the journal
                // lease and the legacy bus claim (whichever took it).
                this.releaseClaim(run.slug, 'failure');
            }
            catch {
                // Best-effort — claim may have already cleared.
            }
            this.activeRunIds.delete(sessionName);
            result.reaped.push(run.slug);
        }
        if (result.reaped.length > 0) {
            console.log(`[scheduler][reaper] Reaped ${result.reaped.length} stuck job(s) after ${sleepEvent.sleepDurationSeconds}s sleep: ${result.reaped.join(', ')}`);
        }
        return result;
    }
    enqueue(slug, reason) {
        // Don't queue duplicates
        if (this.queue.some(q => q.slug === slug))
            return;
        // Cap queue size to prevent unbounded growth
        if (this.queue.length >= 50) {
            console.warn(`[scheduler] Queue full (50 items), dropping enqueue for "${slug}"`);
            return;
        }
        this.queue.push({ slug, reason, queuedAt: new Date().toISOString() });
        // Sort by priority — critical first
        this.queue.sort((a, b) => {
            const jobA = this.jobs.find(j => j.slug === a.slug);
            const jobB = this.jobs.find(j => j.slug === b.slug);
            return (PRIORITY_ORDER[jobA?.priority ?? 'low']) - (PRIORITY_ORDER[jobB?.priority ?? 'low']);
        });
    }
    /**
     * Execute a `script`-type job directly in a bounded subprocess instead of
     * spawning a model session. Script jobs are zero-token infrastructure work
     * (e.g. dashboard-link refresh); spawning a model for them wasted a session
     * slot and could hang for hours (Codey gap-run F005). Mirrors spawnJobSession's
     * run-history / job-state / claim bookkeeping so observability is identical.
     */
    runScriptJob(job, reason) {
        const script = `${job.execute.value}${job.execute.args ? ' ' + job.execute.args : ''}`;
        const sessionName = `script-${job.slug}-${Date.now().toString(36)}`;
        const observability = JobScheduler.computeRunObservability(job, JobScheduler.resolveAllowlist(job));
        const runId = this.runHistory.recordStart({
            slug: job.slug,
            sessionId: sessionName,
            trigger: reason,
            model: job.model,
            origin: observability.origin,
            resolvedPath: observability.resolvedPath,
            bodyHash: observability.bodyHash,
            frontmatterHash: observability.frontmatterHash,
            manifestVersion: observability.manifestVersion,
            toolAllowlist: observability.toolAllowlist,
            unrestrictedTools: observability.unrestrictedTools,
            clampedAllowlist: observability.clampedAllowlist,
        });
        this.state.saveJobState({
            slug: job.slug,
            lastRun: new Date().toISOString(),
            lastResult: 'pending',
            lastError: undefined,
            consecutiveFailures: 0,
            nextScheduled: this.getNextRun(job.slug),
        });
        this.state.appendEvent({
            type: 'job_triggered',
            summary: `Job "${job.slug}" triggered (${reason})`,
            sessionId: sessionName,
            timestamp: new Date().toISOString(),
            metadata: { slug: job.slug, reason, mode: 'script' },
        });
        const timeout = Math.max(1, job.expectedDurationMinutes ?? DEFAULT_EXPECTED_MINUTES) * 2 * 60_000;
        const env = {
            ...process.env,
            ...(this.config.authToken ? { INSTAR_AUTH_TOKEN: this.config.authToken } : {}),
            ...(this.config.projectName ? { INSTAR_AGENT_ID: this.config.projectName } : {}),
            // Structural message-kind stamping (outbound-jargon-filepath-gap §2.1):
            // script-mode jobs are 'automated' senders, but SENDER_CLASS=script —
            // a script cannot read or react to an advisory, so the preflight must
            // never withhold its sends (telegram-reply.sh skips the preflight for
            // script-class and the kind still rides the metadata for the gate).
            INSTAR_MESSAGE_KIND: 'automated',
            INSTAR_JOB_SLUG: job.slug,
            INSTAR_SENDER_CLASS: 'script',
        };
        execFileAsync('/bin/sh', ['-c', script], {
            cwd: path.dirname(this.stateDir),
            encoding: 'utf-8',
            timeout,
            env,
            maxBuffer: 1024 * 1024,
        }).then(({ stdout, stderr }) => {
            const output = [stdout, stderr].filter(Boolean).join('\n').slice(-1000);
            this.runHistory.recordCompletion({ runId, result: 'success', outputSummary: output || undefined });
            this.state.saveJobState({
                slug: job.slug,
                lastRun: new Date().toISOString(),
                lastResult: 'success',
                lastError: undefined,
                consecutiveFailures: 0,
                nextScheduled: this.getNextRun(job.slug),
            });
            this.releaseClaim(job.slug, 'success');
        }).catch((err) => {
            const errorMsg = err instanceof Error ? err.message : String(err);
            const output = [
                err?.stdout,
                err?.stderr,
            ].filter(Boolean).join('\n').slice(-1000);
            const result = errorMsg.includes('timed out') || errorMsg.includes('ETIMEDOUT') ? 'timeout' : 'failure';
            this.runHistory.recordCompletion({ runId, result, error: errorMsg, outputSummary: output || undefined });
            const previous = this.state.getJobState(job.slug);
            this.state.saveJobState({
                slug: job.slug,
                lastRun: new Date().toISOString(),
                lastResult: result,
                lastError: errorMsg,
                consecutiveFailures: (previous?.consecutiveFailures ?? 0) + 1,
                nextScheduled: this.getNextRun(job.slug),
            });
            this.releaseClaim(job.slug, 'failure');
            this.scheduleRetry(job.slug, 'error');
        });
    }
    spawnJobSession(job, reason) {
        const prompt = this.buildPrompt(job);
        const sessionName = `job-${job.slug}-${Date.now().toString(36)}`;
        // Check for gate-written model escalation (e.g., git-sync severity)
        const model = this.resolveModelTier(job);
        // Resolve per-job tool allowlist (spec §5). For non-agentmd entries this
        // returns "legacy" — no flag is passed and the spawn runs with full tools
        // unchanged from pre-spec behavior. For agentmd entries the result encodes
        // (a) the allowlist actually passed to spawn, (b) whether unrestricted-tools
        // was authorized, and (c) whether the resolver clamped to [Read] because
        // the two-flag guard failed.
        const allowlistResolution = JobScheduler.resolveAllowlist(job);
        // Side-channel signals (Dashboard event for clamp; degradation event for
        // both clamp and the instar-origin-without-allowlist Phase 1c gap).
        this.emitAllowlistSignals(job, allowlistResolution);
        // Write active-job.json BEFORE spawning so the session-start and
        // compaction-recovery hooks can inject job-specific grounding context.
        this.state.set('active-job', {
            slug: job.slug,
            name: job.name,
            description: job.description,
            priority: job.priority,
            sessionName,
            triggeredBy: reason,
            startedAt: new Date().toISOString(),
            grounding: job.grounding ?? null,
            topicId: job.topicId ?? null,
            commonBlockers: job.commonBlockers ?? null,
        });
        // Create Living Skills sentinel file if enabled (allows hook to detect opt-in)
        if (job.livingSkills?.enabled) {
            const lsDir = path.join(this.stateDir, 'state', 'execution-journal');
            try {
                fs.mkdirSync(lsDir, { recursive: true });
                fs.writeFileSync(path.join(lsDir, `_ls-enabled-${job.slug}`), '');
            }
            catch (err) {
                console.error(`[scheduler] Failed to create Living Skills sentinel for "${job.slug}": ${err}`);
            }
        }
        // Allowlist flag for the spawn:
        // - array → passed through to --allowedTools
        // - "*" with unrestricted authority → omit flag (full tools)
        // - clamped or "legacy" with no allowlist → omit flag (full tools by default)
        // The spawn always receives at most one allowlist input — never a "*" string.
        const spawnAllowedTools = Array.isArray(allowlistResolution.allowlist)
            ? allowlistResolution.allowlist
            : undefined;
        // Compute observability fields once, used in both recordStart and the
        // spawn-error path. Hashing the body/frontmatter is cheap (a few µs for
        // typical body sizes) — see spec performance budget.
        const observability = JobScheduler.computeRunObservability(job, allowlistResolution);
        this.sessionManager.spawnSession({
            name: sessionName,
            prompt,
            model,
            jobSlug: job.slug,
            triggeredBy: `scheduler:${reason}`,
            maxDurationMinutes: job.expectedDurationMinutes,
            allowedTools: spawnAllowedTools,
            // mcpAccess: 'none' → spawn with zero project MCP servers (claude-code
            // only; the flag builder no-ops for codex). Bash/curl-only jobs skip the
            // MCP boot cost + the auth-required-remote-MCP headless hang hazard
            // (docs/specs/LOOP-SESSION-NO-MCP-SPEC.md). Absent/'project' → unchanged.
            disableProjectMcp: job.mcpAccess === 'none' ? true : undefined,
        }).then(() => {
            // Record in run history with full Phase 1b observability payload
            const runId = this.runHistory.recordStart({
                slug: job.slug,
                sessionId: sessionName,
                trigger: reason,
                model: model ?? job.model,
                origin: observability.origin,
                resolvedPath: observability.resolvedPath,
                bodyHash: observability.bodyHash,
                frontmatterHash: observability.frontmatterHash,
                manifestVersion: observability.manifestVersion,
                toolAllowlist: observability.toolAllowlist,
                unrestrictedTools: observability.unrestrictedTools,
                clampedAllowlist: observability.clampedAllowlist,
            });
            this.activeRunIds.set(sessionName, runId);
            // Update job state on successful spawn (clear error, set pending result)
            const jobState = {
                slug: job.slug,
                lastRun: new Date().toISOString(),
                lastResult: 'pending',
                lastError: undefined,
                consecutiveFailures: 0,
                nextScheduled: this.getNextRun(job.slug),
            };
            this.state.saveJobState(jobState);
            this.state.appendEvent({
                type: 'job_triggered',
                summary: `Job "${job.slug}" triggered (${reason})`,
                sessionId: sessionName,
                timestamp: new Date().toISOString(),
                metadata: { slug: job.slug, reason, model: job.model },
            });
        }).catch((err) => {
            // Record spawn error in run history
            const errorMsg = err instanceof Error ? err.message : String(err);
            this.runHistory.recordSpawnError({
                slug: job.slug,
                trigger: reason,
                error: errorMsg,
                model: model ?? job.model,
            });
            // Track failure with error message
            const failures = this.getConsecutiveFailures(job.slug) + 1;
            const jobState = {
                slug: job.slug,
                lastRun: new Date().toISOString(),
                lastResult: 'failure',
                lastError: errorMsg,
                consecutiveFailures: failures,
                nextScheduled: this.getNextRun(job.slug),
            };
            this.state.saveJobState(jobState);
            this.state.appendEvent({
                type: 'job_error',
                summary: `Job "${job.slug}" failed to spawn: ${errorMsg}`,
                timestamp: new Date().toISOString(),
                metadata: { slug: job.slug, consecutiveFailures: failures },
            });
            this.alertOnConsecutiveFailures(job, failures, errorMsg);
        });
    }
    /**
     * Resolve the model tier for a job. Normally uses the job's configured model,
     * but gates can write a severity file to escalate the model for complex work.
     *
     * The git-sync gate writes to /tmp/instar-git-sync-severity:
     * - "clean" → use configured model (haiku)
     * - "state" → escalate to sonnet (structured conflict resolution)
     * - "code"  → escalate to opus (semantic code merge)
     */
    resolveModelTier(job) {
        if (job.slug === 'git-sync') {
            try {
                const severity = fs.readFileSync('/tmp/instar-git-sync-severity', 'utf-8').trim();
                if (severity === 'code')
                    return 'opus';
                if (severity === 'state')
                    return 'sonnet';
            }
            catch {
                // @silent-fallback-ok — severity file missing, use default model
            }
        }
        return job.model;
    }
    buildPrompt(job) {
        let base;
        // execute.value is required for the legacy types ("skill" | "prompt" |
        // "script") and the JobLoader validator guarantees it. The new
        // "agentmd" type (Phase 1b) carries no value — its body lives in
        // job.body, populated at load time by AgentMdJobLoader.
        switch (job.execute.type) {
            case 'skill':
                base = `/${job.execute.value}${job.execute.args ? ' ' + job.execute.args : ''}`;
                break;
            case 'prompt':
                base = job.execute.value;
                break;
            case 'script':
                base = `Run this script: ${job.execute.value}${job.execute.args ? ' ' + job.execute.args : ''}`;
                break;
            case 'agentmd':
                // Phase 1b dispatch: the cached markdown body IS the prompt. The
                // loader (AgentMdJobLoader.loadAgentMdBody) guarantees `body` is
                // populated for any agentmd JobDefinition that survives validation;
                // if it is somehow missing here, surface that loud and refuse to
                // spawn an empty prompt rather than fail silently.
                if (typeof job.body !== 'string') {
                    throw new Error(`JobScheduler.buildPrompt: agentmd job "${job.slug}" has no cached body. ` +
                        `This indicates a loader-hydration bug (see AgentMdJobLoader.isAgentMdJobHydrated). ` +
                        `See docs/specs/INSTAR-JOBS-AS-AGENTMD-SPEC.md.`);
                }
                base = job.body;
                break;
            default: {
                const _exhaustive = job.execute.type;
                throw new Error(`Unknown execute.type: ${String(_exhaustive)}`);
            }
        }
        // Inject topic awareness for jobs bound to a Telegram topic.
        // This is soft guidance — the job knows where its output will be posted
        // and what the topic's recent focus has been, so it can stay contextually relevant.
        if (job.topicId && this.topicMemory?.isReady()) {
            try {
                const summary = this.topicMemory.getTopicSummary(job.topicId);
                const meta = this.topicMemory.getTopicMeta(job.topicId);
                if (summary?.purpose || meta?.topicName) {
                    const awarenessLines = ['[TOPIC AWARENESS]'];
                    awarenessLines.push(`This session is bound to Telegram topic${meta?.topicName ? ` "${meta.topicName}"` : ` ${job.topicId}`}.`);
                    if (summary?.purpose) {
                        awarenessLines.push(`Recent focus: ${summary.purpose}`);
                    }
                    awarenessLines.push('Your output will be posted to this topic. Keep your results relevant to this context.');
                    awarenessLines.push('If your work product doesn\'t fit this topic, note that in your output rather than posting unrelated content.');
                    awarenessLines.push('[/TOPIC AWARENESS]');
                    base = `${awarenessLines.join(' ')}\n\n${base}`;
                }
            }
            catch {
                // @silent-fallback-ok — topic awareness is non-critical
            }
        }
        // Inject view metadata instruction so job-created reports are linked
        const viewMetaBlock = [
            '[VIEW METADATA]',
            `When creating private views (POST /view), include metadata to link the report to this job:`,
            `  "metadata": { "source": { "type": "job", "id": "${job.slug}" } }`,
            '[/VIEW METADATA]',
        ].join('\n');
        base = `${viewMetaBlock}\n\n${base}`;
        // Inject handoff notes from the last execution (continuity between runs)
        const handoff = this.runHistory.getLastHandoff(job.slug);
        if (handoff) {
            const handoffBlock = [
                '[CONTINUITY FROM PREVIOUS EXECUTION]',
                `Previous session: ${handoff.fromSession} (completed: ${handoff.completedAt})`,
                '',
                'Handoff notes:',
                handoff.handoffNotes,
                handoff.stateSnapshot ? `\nState snapshot: ${JSON.stringify(handoff.stateSnapshot)}` : '',
                '',
                'Use these notes to continue where the previous execution left off.',
                'When done, include [HANDOFF]your notes[/HANDOFF] in your output to pass context to the next execution.',
                'Or run: instar job handoff ' + job.slug + ' --notes "your notes"',
                '[END CONTINUITY]',
            ].join('\n');
            base = `${handoffBlock}\n\n${base}`;
        }
        // Inject attention protocol for on-alert jobs so the LLM knows when to signal
        if (this.getNotifyMode(job) === 'on-alert') {
            const protocol = [
                '[NOTIFICATION PROTOCOL: This job runs in quiet mode.',
                'The user will NOT see your output unless you explicitly signal something needs attention.',
                'If you find something actionable or noteworthy, include "[ATTENTION] reason" on its own line in your output.',
                'If everything is routine and healthy, just complete normally — no signal needed, the user won\'t be bothered.]',
            ].join(' ');
            return `${protocol}\n\n${base}`;
        }
        return base;
    }
    /**
     * Resolve the effective notification mode for a job.
     * Default (undefined) → 'on-alert': quiet unless signaled.
     */
    getNotifyMode(job) {
        if (job.telegramNotify === false)
            return 'never';
        if (job.telegramNotify === true)
            return 'always';
        // undefined or 'on-alert' → on-alert (quiet by default)
        return 'on-alert';
    }
    /**
     * Check if session output contains an attention signal.
     * The convention: [ATTENTION] on its own line (case-insensitive).
     */
    hasAttentionSignal(output) {
        return /\[ATTENTION\]/im.test(output);
    }
    /**
     * Extract handoff notes from session output.
     * Agents can include a [HANDOFF] ... [/HANDOFF] block in their output
     * to leave context for the next execution. This is auto-extracted on completion.
     */
    static extractHandoff(output) {
        const match = output.match(/\[HANDOFF\]\s*([\s\S]*?)\s*\[\/HANDOFF\]/i);
        return match ? match[1].trim() : null;
    }
    getConsecutiveFailures(slug) {
        return this.state.getJobState(slug)?.consecutiveFailures ?? 0;
    }
    getNextRun(slug) {
        const task = this.cronTasks.get(slug);
        if (!task)
            return undefined;
        const next = task.nextRun();
        return next ? next.toISOString() : undefined;
    }
    /**
     * Called when a job's session completes. Updates job state and notifies via messenger.
     */
    async notifyJobComplete(sessionId, tmuxSession) {
        // Find which job this session belongs to by looking up session state
        const session = this.state.getSession(sessionId);
        if (!session?.jobSlug)
            return;
        const job = this.jobs.find(j => j.slug === session.jobSlug);
        if (!job)
            return;
        // Update job state with completion result
        const failed = session.status === 'failed' || session.status === 'killed';
        // Capture session output FIRST — needed for both history and notifications
        let output = '';
        try {
            output = this.sessionManager.captureOutput(tmuxSession) ?? '';
        }
        catch {
            // Session may already be dead — that's fine
        }
        // Record completion in run history (with output summary)
        const runId = this.activeRunIds.get(session.name);
        if (runId) {
            this.runHistory.recordCompletion({
                runId,
                result: session.status === 'killed' ? 'timeout' : (failed ? 'failure' : 'success'),
                error: failed ? `Session ${session.status} (${session.name})` : undefined,
                outputSummary: output ? output.slice(-1000) : undefined,
            });
            // Auto-extract handoff notes from session output if agent included [HANDOFF] marker
            const handoff = JobScheduler.extractHandoff(output);
            if (handoff) {
                this.runHistory.recordHandoff(runId, handoff);
            }
            this.activeRunIds.delete(session.name);
        }
        // Signal claim completion (Phase 4C — Gap 5; WS4.3 journal-lease cutover).
        // Releases whichever mechanism took the claim (journal lease or legacy bus).
        this.releaseClaim(job.slug, failed ? 'failure' : 'success');
        // Clear active-job.json now that the job is done
        const activeJob = this.state.get('active-job');
        if (activeJob?.slug === job.slug) {
            this.state.delete('active-job');
        }
        const existingState = this.state.getJobState(job.slug);
        const jobState = {
            slug: job.slug,
            lastRun: existingState?.lastRun ?? new Date().toISOString(),
            lastResult: failed ? 'failure' : 'success',
            lastError: failed ? `Session ${session.status} (${session.name})` : undefined,
            consecutiveFailures: failed ? (existingState?.consecutiveFailures ?? 0) + 1 : 0,
            nextScheduled: this.getNextRun(job.slug),
        };
        this.state.saveJobState(jobState);
        // Alert on consecutive failures
        if (failed && jobState.lastError) {
            this.alertOnConsecutiveFailures(job, jobState.consecutiveFailures, jobState.lastError);
        }
        // Finalize Living Skills execution journal if enabled
        if (job.livingSkills?.enabled) {
            try {
                const journal = new ExecutionJournal(this.stateDir);
                const definedSteps = (job.livingSkills.definedSteps ?? []).map(s => typeof s === 'string' ? s : s.step);
                journal.finalizeSession({
                    sessionId,
                    jobSlug: job.slug,
                    definedSteps,
                    outcome: failed ? 'failure' : 'success',
                    startedAt: existingState?.lastRun ?? session.startedAt,
                });
                // Clean up sentinel file
                const sentinelPath = path.join(this.stateDir, 'state', 'execution-journal', `_ls-enabled-${job.slug}`);
                try {
                    SafeFsExecutor.safeUnlinkSync(sentinelPath, { operation: 'src/scheduler/JobScheduler.ts:859' });
                }
                catch { /* already gone */ }
            }
            catch (err) {
                console.error(`[scheduler] ExecutionJournal finalization failed for "${job.slug}": ${err}`);
            }
        }
        // IntegrationGate — synchronous learning consolidation before queue drain.
        // When the gate is set, reflection runs synchronously (awaited) and blocks
        // queue drain if a failed job produces no learning.
        if (this.integrationGate) {
            const gateResult = await this.integrationGate.evaluate({
                job,
                sessionId,
                runId: runId ?? null,
                failed,
                output,
                topicId: job.topicId,
            });
            if (!gateResult.proceed) {
                console.error(`[scheduler] IntegrationGate blocked for "${job.slug}": ${gateResult.gateBlockReason}`);
                this.state.appendEvent({
                    type: 'integration_gate_blocked',
                    summary: `IntegrationGate blocked queue drain for "${job.slug}": ${gateResult.gateBlockReason}`,
                    timestamp: new Date().toISOString(),
                    metadata: { slug: job.slug, reason: gateResult.gateBlockReason },
                });
            }
            else {
                this.processQueue();
            }
        }
        else if (this.intelligence) {
            // Fallback: no gate, run standalone reflection (existing fire-and-forget)
            const reflectionModel = job.livingSkills?.reflectionModel ?? undefined;
            this.runJobReflection(job.slug, sessionId, runId ?? null, job.topicId, reflectionModel).catch(err => {
                console.error(`[scheduler] Per-job reflection failed for "${job.slug}": ${err}`);
            });
            this.processQueue();
        }
        else {
            this.processQueue();
        }
        // Skip notifications if no messaging configured or job opted out
        if (!this.messenger && !this.telegram)
            return;
        const notifyMode = this.getNotifyMode(job);
        if (notifyMode === 'never')
            return;
        // Output was already captured above for run history — reuse it
        // Classify death cause if session failed/was killed
        let deathCause;
        if (failed && output) {
            const quotaState = this.quotaTracker?.getState() ?? null;
            const classification = classifySessionDeath(output, quotaState);
            deathCause = classification.cause;
            this.state.appendEvent({
                type: 'session_death_classified',
                summary: `Session for "${job.slug}" classified as ${classification.cause} (${classification.confidence}): ${classification.detail}`,
                timestamp: new Date().toISOString(),
                metadata: {
                    slug: job.slug,
                    cause: classification.cause,
                    confidence: classification.confidence,
                    detail: classification.detail,
                },
            });
        }
        // Build a summary message
        const duration = session.startedAt
            ? Math.round((Date.now() - new Date(session.startedAt).getTime()) / 1000)
            : 0;
        const durationStr = duration > 60
            ? `${Math.floor(duration / 60)}m ${duration % 60}s`
            : `${duration}s`;
        let summary = `*Job Complete: ${job.name}*\n`;
        summary += `Status: ${failed ? 'Failed' : 'Done'}`;
        if (deathCause && deathCause !== 'unknown')
            summary += ` (${deathCause})`;
        summary += '\n';
        if (duration > 0)
            summary += `Duration: ${durationStr}\n`;
        if (output) {
            // Trim to last ~500 chars to keep the message readable
            const trimmed = output.length > 500
                ? '...' + output.slice(-500)
                : output;
            summary += `\n\`\`\`\n${trimmed}\n\`\`\``;
        }
        else {
            summary += '\n_No output captured (session already closed)_';
        }
        // Skip Telegram notification for jobs with no meaningful output — applies regardless of status.
        // Failure alerts are already handled by alertOnConsecutiveFailures above.
        // Prevents "No output captured (session already closed)" spam on every failed cycle.
        if (!output || !output.trim()) {
            console.log(`[scheduler] Skipping notification for ${job.slug} — no meaningful output`);
            return;
        }
        // On-alert mode: only notify if the job failed or explicitly signaled attention.
        // This is the core of the "quiet by default" behavior — routine completions are silent.
        if (notifyMode === 'on-alert' && !failed && !this.hasAttentionSignal(output)) {
            console.log(`[scheduler] Skipping notification for ${job.slug} — on-alert mode, no attention signal`);
            return;
        }
        // Lazy topic creation for on-alert jobs that need to send their first notification.
        // Topics aren't created eagerly for these jobs — only when there's something to report.
        if (this.telegram && !job.topicId && notifyMode === 'on-alert') {
            try {
                const topic = await this.telegram.findOrCreateForumTopic(`${TOPIC_STYLE.JOB.emoji} Job: ${job.name}`, TOPIC_STYLE.JOB.color, { label: 'job-topics' });
                job.topicId = topic.topicId;
                this.saveJobTopicMapping(job.slug, topic.topicId);
            }
            catch (err) {
                console.error(`[scheduler] Failed to create lazy topic for ${job.slug}: ${err}`);
            }
        }
        // Send to the job's dedicated topic if available, otherwise fall back to generic messenger
        if (this.telegram && job.topicId) {
            try {
                await this.telegram.sendToTopic(job.topicId, summary);
            }
            catch (err) {
                console.error(`[scheduler] Failed to send to job topic ${job.topicId}: ${err}`);
                // Topic may have been deleted — try to recreate
                try {
                    const newTopic = await this.telegram.findOrCreateForumTopic(`${TOPIC_STYLE.JOB.emoji} Job: ${job.name}`, TOPIC_STYLE.JOB.color, { label: 'job-topics' });
                    job.topicId = newTopic.topicId;
                    this.saveJobTopicMapping(job.slug, newTopic.topicId);
                    await this.telegram.sendToTopic(newTopic.topicId, summary);
                }
                catch (recreateErr) {
                    console.error(`[scheduler] Failed to recreate topic for ${job.slug}: ${recreateErr}`);
                }
            }
        }
        else if (this.messenger) {
            try {
                await this.messenger.send({
                    userId: 'system',
                    content: summary,
                });
            }
            catch (err) {
                console.error(`[scheduler] Failed to send job notification: ${err}`);
            }
        }
    }
    /**
     * Ensure every enabled job has a Telegram topic.
     * Creates topics for jobs that don't have one.
     * This is the "job-topic coupling" — every job lives in a topic.
     */
    async ensureJobTopics(enabledJobs) {
        if (!this.telegram)
            return;
        // Load existing topic mappings
        const mappings = this.state.get('job-topic-mappings') ?? {};
        // Collect all explicitly-configured topicIds so we never close a topic
        // that another job is actively using.
        const explicitTopicIds = new Set();
        for (const j of enabledJobs) {
            if (j.topicId)
                explicitTopicIds.add(j.topicId);
        }
        for (const job of enabledJobs) {
            // Skip eager topic creation for silent or on-alert jobs.
            // On-alert jobs get topics created lazily when they first have something to report.
            const mode = this.getNotifyMode(job);
            if (mode === 'never' || mode === 'on-alert') {
                // Clean up dynamically-created topic mappings (not explicitly configured).
                // Only remove mappings — don't close topics that may be shared or explicitly set.
                const dynamicTopicId = mappings[job.slug];
                if (dynamicTopicId && !job.topicId) {
                    // Only close if no other job explicitly uses this topic
                    if (!explicitTopicIds.has(dynamicTopicId)) {
                        console.log(`[scheduler] Cleaning up stale dynamic topic for on-alert job "${job.slug}" (topic ${dynamicTopicId})`);
                        try {
                            await this.telegram.closeForumTopic(dynamicTopicId);
                        }
                        catch {
                            // @silent-fallback-ok — topic may already be closed or deleted
                        }
                    }
                    delete mappings[job.slug];
                }
                // Never clear an explicitly-configured topicId — the job definition owns it
                continue;
            }
            // If job already has a topicId (from jobs.json or previous mapping), use it
            if (job.topicId) {
                mappings[job.slug] = job.topicId;
                continue;
            }
            // Check if we have a saved mapping
            if (mappings[job.slug]) {
                job.topicId = mappings[job.slug];
                continue;
            }
            // Create a new topic for this job
            try {
                const topic = await this.telegram.findOrCreateForumTopic(`${TOPIC_STYLE.JOB.emoji} Job: ${job.name}`, TOPIC_STYLE.JOB.color, { label: 'job-topics' });
                job.topicId = topic.topicId;
                mappings[job.slug] = topic.topicId;
                await this.telegram.sendToTopic(topic.topicId, `*${job.name}*\n${job.description}\n\nSchedule: \`${job.schedule}\`\nPriority: ${job.priority}\n\nThis topic is the home for this job. Reports, status updates, and errors will appear here.`);
            }
            catch (err) {
                console.error(`[scheduler] Failed to create topic for job ${job.slug}: ${err}`);
            }
        }
        this.state.set('job-topic-mappings', mappings);
    }
    /**
     * Save a job-topic mapping (used when recreating a deleted topic).
     */
    saveJobTopicMapping(slug, topicId) {
        const mappings = this.state.get('job-topic-mappings') ?? {};
        mappings[slug] = topicId;
        this.state.set('job-topic-mappings', mappings);
    }
    /**
     * Run a job's gate command asynchronously. Returns true if the job should proceed, false to skip.
     * Gates are zero-token pre-screening — a bash command that exits 0 (proceed) or non-zero (skip).
     * Retries up to 3 times with 5s delay to handle transient failures (e.g., server restart windows).
     *
     * Uses non-blocking async execution to avoid stalling the Node.js event loop while gates run.
     * Synchronous gate execution was causing health-check timeouts under startup load (many gates
     * firing concurrently after a restart would block the event loop for 30-300+ seconds).
     */
    async runGateAsync(job) {
        const maxAttempts = this.config.gateRetries ?? 3;
        const retryDelayMs = this.config.gateRetryDelayMs ?? 5000;
        let lastErr;
        // Expose auth token to gate shells so they can call authenticated localhost
        // endpoints (e.g. /evolution/actions/overdue). Without this, gates that curl
        // the local API silently return 401 and the downstream pipe crashes, making
        // the job skip every run cycle with no obvious signal.
        const gateEnv = {
            ...process.env,
            ...(this.config.authToken ? { INSTAR_AUTH_TOKEN: this.config.authToken } : {}),
            ...(this.config.projectName ? { INSTAR_AGENT_ID: this.config.projectName } : {}),
        };
        for (let attempt = 1; attempt <= maxAttempts; attempt++) {
            try {
                await execFileAsync('/bin/sh', ['-c', job.gate], {
                    encoding: 'utf-8',
                    timeout: 10000,
                    env: gateEnv,
                });
                if (attempt > 1) {
                    console.log(`[scheduler] Gate for "${job.slug}" passed on attempt ${attempt}/${maxAttempts}`);
                }
                return true;
            }
            catch (err) {
                lastErr = err;
                if (attempt < maxAttempts) {
                    console.log(`[scheduler] Gate for "${job.slug}" failed (attempt ${attempt}/${maxAttempts}), retrying in ${retryDelayMs / 1000}s...`);
                    await new Promise(resolve => setTimeout(resolve, retryDelayMs));
                }
            }
        }
        // All attempts failed
        const stderr = lastErr?.stderr?.trim() || '';
        // execFileAsync (promisified execFile) rejects with an error whose exit code
        // lives on `.code`, not `.status`. `.status` is the shape for synchronous
        // spawnSync/execSync. Reading `.status` always resolved to undefined → null,
        // making legitimate non-zero skips look like process crashes in the activity
        // feed. Prefer signal (kill reason), then code, then legacy status.
        const errShape = lastErr;
        const exitCode = errShape?.signal ?? errShape?.code ?? errShape?.status ?? null;
        const gateCmd = job.gate.length > 200 ? job.gate.slice(0, 200) + '…' : job.gate;
        this.skipLedger.recordSkip(job.slug, 'gate');
        this.state.appendEvent({
            type: 'job_gate_skip',
            summary: `Job "${job.slug}" skipped — gate returned exit ${exitCode} after ${maxAttempts} attempts${stderr ? `: ${stderr.slice(0, 200)}` : ''}`,
            timestamp: new Date().toISOString(),
            metadata: { slug: job.slug, exitCode, stderr: stderr.slice(0, 500), gate: gateCmd, attempts: maxAttempts },
        });
        return false;
    }
    /**
     * Normalize the canRunJob callback's result. Older wrappers return a bare
     * boolean; newer ones return { allowed, reason, detail } so the scheduler
     * can record WHY a job was gated (memory pressure vs quota etc.).
     */
    normalizeCanRunResult(result) {
        if (typeof result === 'boolean')
            return { allowed: result };
        return result;
    }
    /**
     * Schedule a retry for a transiently-skipped job.
     * Uses escalating delays: 1min, 5min, 15min, 30min, 1h, 2h.
     * Gives up after exhausting all retry slots within a single scheduled window.
     */
    scheduleRetry(slug, skipReason) {
        const state = this.retryState.get(slug);
        const retries = state ? state.retries : 0;
        const maxRetries = JobScheduler.RETRY_DELAYS_MS.length;
        if (retries >= maxRetries) {
            console.log(`[scheduler] Job "${slug}" exhausted ${maxRetries} retries (last skip: ${skipReason}) — waiting for next cron window`);
            return;
        }
        // Clear any existing retry timer
        if (state?.timer)
            clearTimeout(state.timer);
        const delayMs = JobScheduler.RETRY_DELAYS_MS[retries];
        const nextRetry = retries + 1;
        const delayLabel = delayMs >= 3_600_000 ? `${delayMs / 3_600_000}h`
            : delayMs >= 60_000 ? `${delayMs / 60_000}m`
                : `${delayMs / 1000}s`;
        console.log(`[scheduler] Job "${slug}" skipped (${skipReason}) — retry ${nextRetry}/${maxRetries} in ${delayLabel}`);
        const timer = setTimeout(() => {
            if (!this.running || this.paused)
                return;
            const job = this.jobs.find(j => j.slug === slug);
            if (!job || !job.enabled)
                return;
            console.log(`[scheduler] Retrying job "${slug}" (attempt ${nextRetry})`);
            this.triggerJob(slug, `retry:${skipReason}`).catch(err => {
                console.error(`[scheduler] Retry trigger failed for "${slug}": ${err}`);
            });
        }, delayMs);
        this.retryState.set(slug, { retries: nextRetry, timer });
    }
    /**
     * Clear retry state for a job (on success or new cron window).
     */
    clearRetryState(slug) {
        const state = this.retryState.get(slug);
        if (state) {
            if (state.timer)
                clearTimeout(state.timer);
            this.retryState.delete(slug);
        }
    }
    /**
     * Alert when a job hits consecutive failure thresholds.
     * Critical/high priority jobs alert after 2 failures.
     * Medium/low priority jobs alert after 3 failures.
     * Only alerts at the threshold (not every failure after).
     *
     * When the failure is a session limit issue (not a job execution error),
     * the notification is reframed as "Job Blocked" with intelligent diagnostics:
     * running session list with ages, stale session flags, memory pressure,
     * and actionable suggestions.
     */
    alertOnConsecutiveFailures(job, failures, error) {
        const threshold = (job.priority === 'critical' || job.priority === 'high') ? 2 : 3;
        if (failures !== threshold)
            return;
        const isSessionBlocked = error.includes('Max sessions') && error.includes('reached');
        let alertText;
        if (isSessionBlocked) {
            alertText = this.buildSessionBlockedAlert(job, failures, error);
        }
        else {
            alertText = `*Job Alert: ${job.name}*\n\n${failures} consecutive failures.\nLast error: ${error}\nPriority: ${job.priority}`;
        }
        // Send to job's topic if available
        if (this.telegram && job.topicId) {
            this.telegram.sendToTopic(job.topicId, alertText).catch(err => {
                console.error(`[scheduler] Failed to send failure alert: ${err}`);
            });
        }
        else if (this.messenger) {
            this.messenger.send({ userId: 'system', content: alertText }).catch(err => {
                console.error(`[scheduler] Failed to send failure alert: ${err}`);
            });
        }
    }
    /**
     * Build an intelligent "Job Blocked" notification when a job can't start
     * because all session slots are occupied. Includes session diagnostics,
     * staleness detection, memory pressure, and actionable suggestions.
     */
    buildSessionBlockedAlert(job, failures, error) {
        const diagnostics = this.sessionManager.getSessionDiagnostics();
        const lines = [];
        lines.push(`*Job Blocked: ${job.name}*`);
        lines.push('');
        lines.push(`Could not start — all ${diagnostics.maxSessions} session slots are in use.`);
        lines.push(`(${failures} consecutive attempts blocked)`);
        lines.push('');
        // Session list with ages
        lines.push('*Running sessions:*');
        for (const s of diagnostics.sessions) {
            const age = s.ageMinutes >= 60
                ? `${Math.floor(s.ageMinutes / 60)}h ${s.ageMinutes % 60}m`
                : `${s.ageMinutes}m`;
            const staleFlag = s.isStale ? ' ⚠️' : '';
            const jobTag = s.jobSlug ? ` (${s.jobSlug})` : '';
            lines.push(`• ${s.name}${jobTag} — ${age}${staleFlag}`);
            if (s.staleReason) {
                lines.push(`  └ ${s.staleReason}`);
            }
        }
        lines.push('');
        // Memory pressure
        const memEmoji = diagnostics.memoryPressure === 'critical' ? '🔴'
            : diagnostics.memoryPressure === 'high' ? '🟠'
                : diagnostics.memoryPressure === 'moderate' ? '🟡'
                    : '🟢';
        lines.push(`Memory: ${memEmoji} ${diagnostics.memoryUsedPercent}% used (${diagnostics.freeMemoryMB} MB free)`);
        lines.push('');
        // Suggestions
        if (diagnostics.suggestions.length > 0) {
            lines.push('*Suggestions:*');
            for (const suggestion of diagnostics.suggestions) {
                lines.push(`→ ${suggestion}`);
            }
        }
        return lines.join('\n');
    }
    async checkMissedJobs(enabledJobs) {
        const now = Date.now();
        // Collect all missed jobs first, then sort by priority before triggering.
        // This ensures high-priority jobs get the available slots when multiple
        // jobs are overdue after a restart or sleep/wake cycle.
        const missedJobs = [];
        for (const job of enabledJobs) {
            const jobState = this.state.getJobState(job.slug);
            const task = this.cronTasks.get(job.slug);
            if (!task)
                continue;
            // Jobs that have never run: trigger on startup if their first expected
            // run time has already passed (i.e., the job was added while the server
            // was down and missed its first scheduled window).
            if (!jobState?.lastRun) {
                // Use a large overdueRatio so never-run jobs sort below truly-overdue jobs
                missedJobs.push({ job, overdueRatio: 1.5 });
                continue;
            }
            const lastRun = new Date(jobState.lastRun).getTime();
            // Get expected interval from next two runs
            const nextRun = task.nextRun();
            const nextNextRun = task.nextRuns(2)[1];
            if (!nextRun || !nextNextRun)
                continue;
            const intervalMs = nextNextRun.getTime() - nextRun.getTime();
            const timeSinceLastRun = now - lastRun;
            // If overdue by more than 1.5x the interval, mark as missed
            if (timeSinceLastRun > intervalMs * 1.5) {
                missedJobs.push({ job, overdueRatio: timeSinceLastRun / intervalMs });
            }
        }
        // Sort by priority (critical first), then by how overdue (most overdue first)
        missedJobs.sort((a, b) => {
            const priorityDiff = (PRIORITY_ORDER[a.job.priority ?? 'low']) - (PRIORITY_ORDER[b.job.priority ?? 'low']);
            if (priorityDiff !== 0)
                return priorityDiff;
            return b.overdueRatio - a.overdueRatio;
        });
        for (const { job } of missedJobs) {
            await this.triggerJob(job.slug, 'missed');
        }
    }
    /**
     * Run per-job LLM reflection after execution.
     * Always-on for every completed job — history is memory.
     * Persists the reflection to run history and optionally sends to Telegram.
     */
    async runJobReflection(jobSlug, sessionId, runId, topicId, reflectionModel) {
        if (!this.intelligence)
            return;
        // Map ModelTier (opus/sonnet/haiku) to IntelligenceOptions model (capable/balanced/fast)
        const MODEL_MAP = {
            opus: 'capable',
            sonnet: 'balanced',
            haiku: 'fast',
        };
        // Default to 'fast' (haiku) for routine reflections — efficient and sufficient
        const model = reflectionModel ? MODEL_MAP[reflectionModel] ?? 'fast' : 'fast';
        const reflector = new JobReflector({
            stateDir: this.stateDir,
            intelligence: this.intelligence,
            model,
        });
        const insight = await reflector.reflect(jobSlug, { sessionId });
        if (!insight)
            return;
        // Persist reflection to run history — this is the permanent record
        if (runId) {
            this.runHistory.recordReflection(runId, {
                summary: insight.summary,
                strengths: insight.strengths,
                improvements: insight.improvements,
                deviationAnalysis: insight.deviationAnalysis,
                purposeDrift: insight.purposeDrift,
                suggestedChanges: insight.suggestedChanges,
            });
        }
        // Send reflection to the job's Telegram topic
        if (this.telegram && topicId) {
            const formatted = reflector.formatInsight(insight);
            try {
                await this.telegram.sendToTopic(topicId, formatted);
            }
            catch {
                // Topic may not exist — not critical
            }
        }
    }
    // ── Phase 1b: tool-allowlist resolution + run-record observability ─────
    //
    // The helpers below are static so they remain pure functions of the input
    // JobDefinition — no scheduler state is read. This makes them trivial to
    // exercise from unit tests without standing up a full scheduler.
    /**
     * Resolve a per-job tool allowlist per INSTAR-JOBS-AS-AGENTMD spec §5.
     *
     * Decision matrix:
     *   | execute.type !== 'agentmd'                       → kind:'legacy', allowlist:null (full tools, back-compat)
     *   | frontmatter.toolAllowlist = string[]             → kind:'array', allowlist:[...]
     *   | frontmatter.toolAllowlist = "*" + unrestricted   → kind:'unrestricted', allowlist:"*", unrestrictedTools:true
     *   | frontmatter.toolAllowlist = "*" + NOT authorized → kind:'clamped', allowlist:['Read'], clampedAllowlist:true (warning event emitted by caller)
     *   | frontmatter.toolAllowlist missing + origin=user  → kind:'default-user', allowlist:['Read']
     *   | frontmatter.toolAllowlist missing + origin=instar → kind:'instar-no-allowlist', allowlist:null (degradation event; Phase 1c lock-file will close)
     *
     * Returns a single object describing the resolution so callers can both
     * (a) thread the right argument into the spawn call and (b) annotate the
     * run record. Side-effects (degradation events) are NOT performed here —
     * they happen in the caller, where the scheduler context is available.
     */
    static resolveAllowlist(job) {
        if (job.execute.type !== 'agentmd') {
            return {
                kind: 'legacy',
                allowlist: null,
                unrestrictedTools: false,
                clampedAllowlist: false,
            };
        }
        const fm = job.frontmatter ?? {};
        const raw = fm.toolAllowlist;
        if (Array.isArray(raw)) {
            const list = raw.filter((t) => typeof t === 'string' && t.trim().length > 0);
            return {
                kind: 'array',
                allowlist: list,
                unrestrictedTools: false,
                clampedAllowlist: false,
            };
        }
        if (raw === '*') {
            // Two-flag guard: "*" + unrestrictedTools:true → full tools.
            // Otherwise clamp to [Read] and signal the clamp.
            if (job.unrestrictedTools === true) {
                // Phase-1b-gap closure: an origin:instar job in a real-tamper lockTrust
                // state cannot escape to full tools, regardless of what the manifest
                // claims. The lock-file is the higher trust authority.
                if (job.origin === 'instar' && JobScheduler.isLockUntrustedTamper(job.lockTrust)) {
                    return {
                        kind: 'lock-untrusted-clamped',
                        allowlist: ['Read'],
                        unrestrictedTools: false,
                        clampedAllowlist: true,
                        lockUntrustedClamp: true,
                    };
                }
                return {
                    kind: 'unrestricted',
                    allowlist: '*',
                    unrestrictedTools: true,
                    clampedAllowlist: false,
                };
            }
            return {
                kind: 'clamped',
                allowlist: ['Read'],
                unrestrictedTools: false,
                clampedAllowlist: true,
            };
        }
        // toolAllowlist missing
        if (job.origin === 'user') {
            // User jobs without an explicit allowlist default to minimal Read-only.
            return {
                kind: 'default-user',
                allowlist: ['Read'],
                unrestrictedTools: false,
                clampedAllowlist: false,
            };
        }
        // Instar-origin without an explicit allowlist: documented temporary
        // behavior — spawn with full tools, emit a degradation event so the
        // gap is visible until Phase 1c lock-file defaults close it.
        //
        // Phase-1b-gap closure: refuse the implicit full-tools fallback when
        // lockTrust signals a real tamper. The transitional
        // `untrusted-no-lockfile` state preserves the documented temporary
        // behavior so existing agents don't lose tool access when they apply
        // the update before the build-pipeline signing ships.
        if (job.origin === 'instar' && JobScheduler.isLockUntrustedTamper(job.lockTrust)) {
            return {
                kind: 'lock-untrusted-clamped',
                allowlist: ['Read'],
                unrestrictedTools: false,
                clampedAllowlist: true,
                lockUntrustedClamp: true,
            };
        }
        return {
            kind: 'instar-no-allowlist',
            allowlist: null,
            unrestrictedTools: false,
            clampedAllowlist: false,
        };
    }
    /** Classify a lockTrust value as a real-tamper signal. The transitional
     *  `untrusted-no-lockfile` is intentionally NOT a tamper signal — until
     *  Phase 1c-build ships, every instar agent carries this value on every
     *  origin:instar entry; treating it as tamper would break the rollout. */
    static isLockUntrustedTamper(lockTrust) {
        return (lockTrust === 'untrusted-bad-signature' ||
            lockTrust === 'untrusted-not-in-lockfile' ||
            lockTrust === 'untrusted-hash-mismatch');
    }
    /**
     * Compute Phase 1b run-record observability fields for a job. Pure
     * function: hashing is deterministic over body + canonicalized
     * frontmatter; non-agentmd entries carry nulls.
     */
    static computeRunObservability(job, resolution) {
        const isAgentMd = job.execute.type === 'agentmd';
        let origin;
        if (job.execute.type !== 'agentmd') {
            origin = 'legacy';
        }
        else if (job.origin === 'instar') {
            origin = 'instar';
        }
        else {
            origin = 'user';
        }
        const bodyHash = isAgentMd && typeof job.body === 'string'
            ? `sha256:${crypto.createHash('sha256').update(job.body).digest('hex')}`
            : null;
        const frontmatterHash = isAgentMd
            ? `sha256:${crypto
                .createHash('sha256')
                .update(JobScheduler.canonicalize(job.frontmatter ?? {}))
                .digest('hex')}`
            : null;
        return {
            origin,
            resolvedPath: isAgentMd ? (job.resolvedPath ?? null) : null,
            bodyHash,
            frontmatterHash,
            manifestVersion: isAgentMd && typeof job.manifestVersion === 'number'
                ? job.manifestVersion
                : null,
            toolAllowlist: resolution.allowlist,
            unrestrictedTools: resolution.unrestrictedTools,
            clampedAllowlist: resolution.clampedAllowlist,
        };
    }
    /**
     * Canonical JSON encoding for hash stability. Sorts object keys at every
     * depth so the same logical frontmatter always produces the same hash,
     * regardless of insertion order. Arrays preserve order. Primitive nulls
     * and undefined are encoded the same way `JSON.stringify` does.
     */
    static canonicalize(value) {
        if (value === null || typeof value !== 'object') {
            return JSON.stringify(value);
        }
        if (Array.isArray(value)) {
            return '[' + value.map(v => JobScheduler.canonicalize(v)).join(',') + ']';
        }
        const keys = Object.keys(value).sort();
        const parts = keys.map(k => {
            return JSON.stringify(k) + ':' + JobScheduler.canonicalize(value[k]);
        });
        return '{' + parts.join(',') + '}';
    }
    /**
     * Emit Phase 1b side-channel signals for an allowlist resolution.
     * Side effects (Dashboard event + degradation event) are gathered here
     * so spawnJobSession can call once at spawn time. Pure function on the
     * scheduler's `state.appendEvent` and the global DegradationReporter.
     *
     * The clamped-allowlist warning is a Dashboard-visible event AND a
     * DegradationReporter event — both surface to the user via different
     * channels (event stream vs degradation digest).
     *
     * The instar-no-allowlist path is a DegradationReporter event only:
     * Phase 1c's lock-file defaults will close this gap; until then the
     * temporary "full tools" behavior is documented and observable.
     */
    emitAllowlistSignals(job, resolution) {
        if (resolution.clampedAllowlist) {
            this.state.appendEvent({
                type: 'job_allowlist_clamped',
                summary: `Job "${job.slug}" requested toolAllowlist:"*" without unrestrictedTools:true — ` +
                    `clamped to [Read]. Set unrestrictedTools:true in the manifest to authorize full tools.`,
                timestamp: new Date().toISOString(),
                metadata: { slug: job.slug, origin: job.origin },
            });
            try {
                DegradationReporter.getInstance().report({
                    feature: 'JobScheduler.allowlistResolution',
                    primary: 'Spawn with the requested allowlist (toolAllowlist:"*")',
                    fallback: 'Clamp the spawn to ["Read"]',
                    reason: `Job "${job.slug}" requested toolAllowlist:"*" without manifest.unrestrictedTools:true`,
                    impact: 'Job will run with Read-only tools until the manifest sets unrestrictedTools:true',
                });
            }
            catch {
                // @silent-fallback-ok — degradation reporting is best-effort
            }
        }
        if (resolution.kind === 'instar-no-allowlist') {
            try {
                DegradationReporter.getInstance().report({
                    feature: 'JobScheduler.allowlistResolution',
                    primary: 'Look up a structural per-slug default allowlist (Phase 1c lock-file)',
                    fallback: 'Spawn with full tools (no --allowedTools flag)',
                    reason: `Instar-origin agentmd job "${job.slug}" has no toolAllowlist frontmatter; Phase 1c lock-file defaults are not yet shipped`,
                    impact: 'Job runs with full tools until Phase 1c lock-file defaults land',
                });
            }
            catch {
                // @silent-fallback-ok — degradation reporting is best-effort
            }
        }
        if (resolution.kind === 'lock-untrusted-clamped') {
            // Real-tamper lockTrust state refused trust elevation. Surface to BOTH
            // event stream (Dashboard) and degradation digest so operator sees it.
            this.state.appendEvent({
                type: 'job_lock_untrusted_clamped',
                summary: `Job "${job.slug}" claims origin:instar but its lock-file trust state is "${job.lockTrust}" — ` +
                    `tool elevation refused, clamped to [Read].`,
                timestamp: new Date().toISOString(),
                metadata: { slug: job.slug, lockTrust: job.lockTrust },
            });
            try {
                DegradationReporter.getInstance().report({
                    feature: 'JobScheduler.allowlistResolution',
                    primary: 'Spawn the instar default with its requested tool authorization',
                    fallback: 'Clamp the spawn to ["Read"]',
                    reason: `Instar-origin agentmd job "${job.slug}" has lockTrust="${job.lockTrust}" (real-tamper state)`,
                    impact: 'Job runs Read-only until the lock-file mismatch is acknowledged or repaired',
                });
            }
            catch {
                // @silent-fallback-ok — degradation reporting is best-effort
            }
        }
    }
    /** Sync in-memory runtime read for the GuardRegistry (GET /guards).
     *  Registration is not life: a load-shed pause reads enabled:false
     *  (off-runtime-divergent against an on-config), never healthy. Cheap
     *  property reads ONLY — getStatus() lists sessions and must not be
     *  called from the guards route. */
    guardStatus() {
        return {
            enabled: this.running && !this.paused,
            jobCount: this.jobs.length,
            pausedJobCount: this.jobs.filter((j) => !j.enabled).length,
        };
    }
}
//# sourceMappingURL=JobScheduler.js.map