Ctrl K
ComunidadGuías…

lib/schedule-status.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Passive evidence only. Nothing here grants dispatch/recovery authority.
import { deriveJobStatus, DISPATCH_INTERVAL_MS, isScheduled, jobRequiresPr, scheduleHold } from './jobs.js';
import { nextCronTime } from './cron.js';

const time = value => typeof value === 'string' && Number.isFinite(Date.parse(value)) ? Date.parse(value) : null;
const iso = value => time(value) === null ? null : new Date(time(value)).toISOString();

export function scheduleStatus(schedule, jobs, sessions, { now = Date.now(), intervalMs = DISPATCH_INTERVAL_MS, running = true } = {}) {
  if (!isScheduled(schedule)) return null;
  const graceMs = Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : DISPATCH_INTERVAL_MS;
  // A malformed foreign link must not expose another owner's/repo's worker.
  const runs = jobs.filter(j => j.scheduleId === schedule.id && j.repoPath === schedule.repoPath && j.postedBy === schedule.postedBy);
  const observed = schedule.lastScheduleObservation?.cron === schedule.schedule ? schedule.lastScheduleObservation : null;
  const dueAt = iso(schedule.nextRunAt);
  const latest = runs.find(j => j.id === schedule.lastRunJobId);
  const open = runs.filter(j => j.state !== 'done');
  const blocker = open.find(j => j.state === 'in-progress') || open.find(j => j.state === 'todo')
    || open.find(j => j.state === 'review' && jobRequiresPr(j));
  const workerStatus = blocker ? deriveJobStatus(blocker, sessions.get(blocker.agentSessionId), { now }) : null;
  const result = {
    code: 'scheduled', label: 'Scheduled', attention: false,
    checkedAt: new Date(now).toISOString(), timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
    graceMs, nextDueAt: dueAt,
    // This is the due cursor actually examined, not a reconstructed firing.
    lastExpectedAt: iso(observed?.expectedAt), observedAt: iso(observed?.observedAt), outcome: observed?.outcome || null,
    lastRunAt: iso(schedule.lastRunAt),
    lastRun: latest ? { id: latest.id, state: latest.state, status: deriveJobStatus(latest, sessions.get(latest.agentSessionId), { now }), startedAt: iso(latest.startedAt), doneAt: iso(latest.doneAt) } : null,
    blocker: null, reason: '', action: '',
    delayedByMs: observed && time(observed.expectedAt) !== null && time(observed.observedAt) !== null
      ? Math.max(0, time(observed.observedAt) - time(observed.expectedAt)) : 0,
  };
  const set = (code, label, reason, action = '', attention = false) => Object.assign(result, { code, label, reason, action, attention });
  if (schedule.state === 'done') return set('archived', 'Archived', 'No further runs are expected.');
  if (schedule.paused) return set('paused', 'Paused', 'No runs are expected while paused.');
  if (!running) return set('stopped', 'Dispatcher stopped', 'Automatic scans are stopped.', 'Inspect the board dispatcher setting.');
  if (time(schedule.holdUntil) > now) return set('held', 'Schedule held', `Held until ${iso(schedule.holdUntil)}.`);
  // Clock rollback must not turn a future observation into evidence of a miss.
  if (time(observed?.observedAt) > now || time(schedule.lastRunAt) > now) {
    return set('clock', 'Clock evidence is ahead', 'A recorded observation is later than the server clock.', 'Inspect the server clock before interpreting lateness.', true);
  }
  if (blocker) {
    result.blocker = { id: blocker.id, state: blocker.state, status: workerStatus, agentName: blocker.agentName || null };
    const reason = scheduleHold(schedule, runs);
    if (workerStatus === 'gone' || workerStatus === 'stalled' || workerStatus === 'needs-input') {
      return set(`held-${workerStatus}`, `Held: prior worker ${workerStatus}`, `An In progress run still blocks this schedule; its worker is ${workerStatus}.`,
        'Inspect the blocking run. Recovery requires a separate decision; no replay is performed.', true);
    }
    return set('held', blocker.state === 'todo' ? 'Held: prior run queued' : blocker.state === 'review' ? 'Held: prior PR review' : 'Held: prior run active',
      reason, 'Inspect the blocking run; a posted run is not a missed firing.');
  }
  if (!dueAt) {
    return nextCronTime(schedule.schedule, now) === null
      ? set('unschedulable', 'No next firing', 'The cron has no matching future date (or is invalid).', 'Inspect the cron expression.')
      : set('unknown', 'Due time unavailable', 'There is no valid saved due timestamp.', 'Inspect the schedule; missed firings cannot be inferred.', true);
  }
  if (time(dueAt) <= now) {
    const overdue = now - time(dueAt) > graceMs;
    return set(overdue ? 'overdue' : 'due', overdue ? 'Run posting overdue' : 'Due: awaiting scan',
      `No run posting observed for the saved due time. Scan interval: ${graceMs / 1000}s; slow scans can take longer.`,
      overdue ? 'Inspect the dispatcher and last error. This is lateness evidence, not proof of worker failure.' : '', overdue);
  }
  if (observed?.outcome === 'error') return set('post-error', 'Last run was not posted', 'The last observed posting attempt failed.', 'Inspect the schedule’s last error.', true);
  if (observed?.outcome === 'held') return set('held-previously', 'Last firing held; blocker cleared', 'The next saved firing is in the future. Held recurring firings are not replayed.');
  if (result.delayedByMs > graceMs) return set('posted-late', 'Last run posted late', 'A delayed scan posted one run. Intermediate ticks were not replayed.');
  return result;
}