Skip to the content

Capsid menu

src/stale-jobs.ts

186 lines and 12 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. This file on GitHub.

Functions

The functions of stale-jobs.ts
FunctionLinesComplexityCoveredCRAPMutation
tallyPrStates46 to 502100%OkCRAP 2No data: mutation testing has not run on this repo yet
isCount52 to 543100%OkCRAP 3No data: mutation testing has not run on this repo yet
isEntry56 to 608100%WatchCRAP 8No data: mutation testing has not run on this repo yet
parseStaleCache64 to 749100%WatchCRAP 9No data: mutation testing has not run on this repo yet
readStaleCache78 to 883100%OkCRAP 3No data: mutation testing has not run on this repo yet
nextStaleCache92 to 983100%OkCRAP 3No data: mutation testing has not run on this repo yet
prsSettled102 to 1044100%OkCRAP 4No data: mutation testing has not run on this repo yet
plural106 to 1082100%OkCRAP 2No data: mutation testing has not run on this repo yet
classifyStale117 to 15117100%WatchCRAP 17No data: mutation testing has not run on this repo yet
staleJobs164 to 2041092%WatchCRAP 10.1No data: mutation testing has not run on this repo yet
<anonymous:168>168 to 1681100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:169>169 to 1691100%OkCRAP 1No data: mutation testing has not run on this repo yet

Imports

It imports src/env.ts, src/jobs-seat.ts, src/manual-steps.ts, src/ops-types/views.ts, src/time.ts. Imported by src/inbox.ts, src/merge-resume.ts, src/portal-stale.ts, src/tools/jobs.ts, src/whats-next.ts.

Source

import type { Env } from "./env";
import { MERGE_RESUME_ACTOR } from "./jobs-seat";
import type { OpsStaleJob, StaleRule } from "./ops-types/views";
import { readStepCache, stepsSeen, type StaleStepEntry } from "./manual-steps";
import { HOUR_MS, DAY_MS } from "./time";

// The stale view (capsid/research/design-stale-jobs.md, D5): `capsid_work_jobs` action list with
// stale: true. Four rules, each a fact the Worker already holds, so reading the view
// costs no GitHub reads:
//   unchanged: a blocked or claimed job whose row has not changed in 3 days;
//   resumed-not-completed: a job the merge-resume step resumed 24 hours or more ago that
//     is still blocked, claimed or queued;
//   prs-settled: a blocked job whose named pull requests are all merged or closed, read
//     from the cache the merge-resume step writes (src/merge-resume.ts);
//   steps-seen: a blocked job whose every "check:" line the watcher's last pass saw done,
//     read from the cache that pass writes (src/manual-steps.ts).
// Nothing here closes or moves a job; the view only says why a job looks stuck.

export const STALE_UNCHANGED_MS = 3 * DAY_MS;
export const STALE_RESUMED_MS = DAY_MS;
const STALE_ROWS_MAX = 200;

// One KV value (APP_KV) holding, per blocked job, the states of the pull requests it
// names as the merge-resume step last read them.
export const STALE_PRS_KEY = "stale:prs";

export type PrState = "merged" | "closed" | "open" | "unread";

export interface StalePrEntry {
  merged: number;
  closed: number;
  open: number;
  unread: number;
  // ISO time of the read.
  at: string;
}

export type StalePrCache = Record<string, StalePrEntry>;

// The row and its rules are the Portal's contract too (GET /portal/api/stale), so they
// live in src/ops-types/.
export type { StaleRule };
type StaleRow = OpsStaleJob;

/** The counts one job's pull request reads add up to. */
export function tallyPrStates(states: readonly PrState[], at: string): StalePrEntry {
  const entry: StalePrEntry = { merged: 0, closed: 0, open: 0, unread: 0, at };
  for (const state of states) entry[state] += 1;
  return entry;
}

function isCount(value: unknown): value is number {
  return typeof value === "number" && Number.isInteger(value) && value >= 0;
}

function isEntry(value: unknown): value is StalePrEntry {
  if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
  const v = value as Record<string, unknown>;
  return isCount(v.merged) && isCount(v.closed) && isCount(v.open) && isCount(v.unread) && typeof v.at === "string";
}

/** The cache as stored, keeping only entries of the right shape. null (never written) is
 *  an empty cache; anything else that is not an object is reported as unreadable. */
export function parseStaleCache(raw: unknown): { cache: StalePrCache; problem: string | null } {
  if (raw === null || raw === undefined) return { cache: {}, problem: null };
  if (typeof raw !== "object" || Array.isArray(raw)) return { cache: {}, problem: `${STALE_PRS_KEY} is not a JSON object` };
  const cache: StalePrCache = {};
  let dropped = 0;
  for (const [id, entry] of Object.entries(raw as Record<string, unknown>)) {
    if (isEntry(entry)) cache[id] = entry;
    else dropped++;
  }
  return { cache, problem: dropped > 0 ? `${STALE_PRS_KEY} held ${dropped} entr${dropped === 1 ? "y" : "ies"} of the wrong shape, left out` : null };
}

/** The stored cache. A value that is not JSON is reported, not thrown, so the view
 *  still serves the two rules that do not need it. */
export async function readStaleCache(kv: KVNamespace): Promise<{ cache: StalePrCache; problem: string | null }> {
  const text = await kv.get(STALE_PRS_KEY);
  if (text === null) return { cache: {}, problem: null };
  let raw: unknown;
  try {
    raw = JSON.parse(text);
  } catch {
    return { cache: {}, problem: `${STALE_PRS_KEY} is not JSON` };
  }
  return parseStaleCache(raw);
}

/** The next cache: this tick's reads replace each job's entry, and only jobs still
 *  blocked keep one. */
export function nextStaleCache(prior: StalePrCache, reads: StalePrCache, stillBlocked: ReadonlySet<string>): StalePrCache {
  const next: StalePrCache = {};
  for (const [id, entry] of Object.entries({ ...prior, ...reads })) {
    if (stillBlocked.has(id)) next[id] = entry;
  }
  return next;
}

/** Rule C's test on one cache entry: nothing open, nothing unread, and at least one
 *  pull request merged or closed. */
export function prsSettled(entry: StalePrEntry | undefined): boolean {
  return entry !== undefined && entry.open === 0 && entry.unread === 0 && entry.merged + entry.closed > 0;
}

function plural(n: number, word: string): string {
  return `${n} ${word}${n === 1 ? "" : "s"}`;
}

/**
 * Why one job is stale, or null. Pure, so the rules are tested without a database.
 * The first rule that holds wins, the most actionable first: settled pull requests, then
 * manual steps seen, then an auto-resume nobody finished, then a row nobody touched. A
 * cache entry read before the job last changed says nothing about the job as it is now,
 * so it is ignored.
 */
export function classifyStale(
  job: { status: string; updated_at: string; auto_resumed_at: string | null },
  entry: StalePrEntry | undefined,
  now: Date,
  steps?: StaleStepEntry
): { rule: StaleRule; reason: string } | null {
  const nowMs = now.getTime();
  if (job.status === "blocked" && entry && entry.at >= job.updated_at && prsSettled(entry)) {
    return {
      rule: "prs-settled",
      reason: `blocked, and every pull request it names is settled (${entry.merged} merged, ${entry.closed} closed without merging, read ${entry.at})`,
    };
  }
  if (job.status === "blocked" && steps && steps.at >= job.updated_at && stepsSeen(steps)) {
    return { rule: "steps-seen", reason: `blocked, and every manual step it names is seen done (${plural(steps.seen, "step")}, read ${steps.at})` };
  }
  if (job.auto_resumed_at && ["blocked", "claimed", "queued"].includes(job.status)) {
    const since = Date.parse(job.auto_resumed_at);
    if (Number.isFinite(since) && nowMs - since >= STALE_RESUMED_MS) {
      const hours = Math.floor((nowMs - since) / HOUR_MS);
      return {
        rule: "resumed-not-completed",
        reason: `auto-resumed by ${MERGE_RESUME_ACTOR} at ${job.auto_resumed_at} and still ${job.status} ${plural(hours, "hour")} later`,
      };
    }
  }
  if (job.status === "blocked" || job.status === "claimed") {
    const since = Date.parse(job.updated_at);
    if (Number.isFinite(since) && nowMs - since >= STALE_UNCHANGED_MS) {
      const days = Math.floor((nowMs - since) / DAY_MS);
      return { rule: "unchanged", reason: `${job.status} and unchanged since ${job.updated_at} (${plural(days, "day")})` };
    }
  }
  return null;
}

export interface StaleJobsResult {
  rows: StaleRow[];
  truncated: boolean;
  note?: string;
}

/**
 * The stale view. The caller's namespace scope is checked before this runs, as for
 * every capsid_work_jobs action list (src/tools/jobs.ts); a caller scoped to one namespace must name it
 * (the registrar's namespaceRefusal), and an omitted namespace means every namespace.
 */
export async function staleJobs(env: Env, now: Date, opts: { namespace?: string } = {}): Promise<StaleJobsResult> {
  const read = await readStaleCache(env.APP_KV);
  const steps = await readStepCache(env.APP_KV);
  const settled = [
    ...Object.keys(read.cache).filter((id) => prsSettled(read.cache[id])),
    ...Object.keys(steps.cache).filter((id) => stepsSeen(steps.cache[id])),
  ];
  const unchangedBefore = new Date(now.getTime() - STALE_UNCHANGED_MS).toISOString();
  const resumedBefore = new Date(now.getTime() - STALE_RESUMED_MS).toISOString();
  // The SQL narrows to the candidates; classifyStale decides, so the rules live in one
  // place. ISO strings with milliseconds compare in time order. The namespace is bound as
  // NULL for "every namespace" rather than spliced in, so the statement has no hole and
  // test-integration/query-plans.test.ts plans it as written.
  const { results } = await env.DB.prepare(
    `SELECT j.id, j.namespace, j.title, j.status, j.updated_at,
            (SELECT MAX(t.at) FROM job_touches t WHERE t.job_id = j.id AND t.actor = ?1 AND t.kind = 'resume') AS auto_resumed_at
       FROM jobs j
      WHERE j.status IN ('blocked', 'claimed', 'queued')
        AND (?5 IS NULL OR j.namespace = ?5)
        AND ((j.status IN ('blocked', 'claimed') AND j.updated_at <= ?2)
          OR EXISTS (SELECT 1 FROM job_touches r WHERE r.job_id = j.id AND r.actor = ?1 AND r.kind = 'resume' AND r.at <= ?3)
          OR (j.status = 'blocked' AND j.id IN (SELECT value FROM json_each(?4))))
      ORDER BY j.updated_at ASC
      LIMIT ?6`
  )
    .bind(MERGE_RESUME_ACTOR, unchangedBefore, resumedBefore, JSON.stringify(settled), opts.namespace ?? null, STALE_ROWS_MAX + 1)
    .all<{ id: string; namespace: string; title: string; status: string; updated_at: string; auto_resumed_at: string | null }>();
  const candidates = results ?? [];
  const truncated = candidates.length > STALE_ROWS_MAX;
  const rows: StaleRow[] = [];
  for (const job of truncated ? candidates.slice(0, STALE_ROWS_MAX) : candidates) {
    const why = classifyStale(job, read.cache[job.id], now, steps.cache[job.id]);
    if (why) rows.push({ id: job.id, namespace: job.namespace, title: job.title, status: job.status, updated_at: job.updated_at, ...why });
  }
  const notes = [
    ...(truncated ? [`more than ${STALE_ROWS_MAX} stale jobs; narrow by namespace.`] : []),
    ...(read.problem ? [`the pull request cache could not be fully read (${read.problem}), so prs-settled may be missing rows.`] : []),
    ...(steps.problem ? [`the manual steps cache could not be fully read (${steps.problem}), so steps-seen may be missing rows.`] : []),
  ];
  return { rows, truncated, ...(notes.length ? { note: notes.join(" ") } : {}) };
}