Files
AgentCompany/src/main/orchestration/scheduler-housekeeping.ts
T
ZacharyZhang-NY 827f06f358 Migrate 26 high-value catch sites to AppError taxonomy (C6 wave 1)
Classify scheduler, orchestration, IPC read, and automation catch
blocks via logAppError + classifyUnknownError so each failure surfaces
a stable `module.subdomain.condition` code plus a kind. No behaviour
change — returns, throws, and fall-through are preserved; only log
prefixes and the taxonomy metadata are new.

Wave 1 coverage (26 sites, all non-database):

  - orchestration/scheduler-lock-recovery (3)
  - orchestration/scheduler-housekeeping (5)
  - orchestration/scheduler-idle-automation (2)
  - orchestration/scheduler-heartbeat (4)
  - orchestration/turn-context (2)
  - ipc-core-read-handlers (6)
  - automation/automation-handlers (4)

Relax the Logger interface so `error` is optional, letting callers
whose deps expose only `{info, warn}` (scheduler contexts) pass in
directly without wrapping. The helper falls back to warn when error
is absent.
2026-04-20 13:37:40 -04:00

143 lines
5.8 KiB
TypeScript

import type { OrchestrationDependencies } from "./types";
import { sweepStaleReviews } from "../task-quality-gates";
import type { SweepStaleReviewsDeps } from "../task-quality-gates";
import { resolveApprovalDecision } from "../approval-policy";
import { dispatchAgentMessage } from "../messaging/dispatch";
import { logAppError } from "../errors/index.js";
export function createHousekeepingScheduler(
deps: OrchestrationDependencies,
wakeAgentIfPossible: (agentId: string, companyId: string, trigger: string) => string | null | undefined,
) {
const {
db,
logger,
publishDomainChanged,
notify,
hasWorkerReady,
isQuitting,
budgetService,
} = deps;
let timer: ReturnType<typeof setInterval> | null = null;
function start() {
if (timer) clearInterval(timer);
timer = setInterval(() => {
try {
if (isQuitting() || !hasWorkerReady()) return;
const activeCompanies = db.company.listActiveCompanyIds();
for (const company of activeCompanies) {
try {
const currentMonth = new Date().toISOString().slice(0, 7);
const lastResetMonth = db.settings.getLastBudgetResetMonth(company.id) ?? "";
if (currentMonth !== lastResetMonth) {
db.agents.resetMonthlyBudgets(company.id);
db.settings.setLastBudgetResetMonth(company.id, currentMonth);
logger.info(`[housekeeping] Reset monthly budgets for company ${company.name} (${currentMonth})`);
}
// D-06: Auto-close incidents from previous budget windows
const closedIncidents = budgetService.autoCloseExpiredIncidents(company.id);
if (closedIncidents > 0) {
logger.info(`[housekeeping] Auto-closed ${closedIncidents} expired budget incident(s) for company ${company.name}`);
}
// TASK-04: Stale review escalation sweep
try {
const sweepDeps: SweepStaleReviewsDeps = {
db,
dispatchAgentMessage,
resolveApprovalDecision,
notify,
getCompanyExecutionMode: (cId: string) => db.company.getCompanyExecutionMode(cId),
handleTaskStatusChange: (taskId: string, cId: string, previousStatus: string, newStatus: string) => {
deps.dispatchAutomation("task_status_changed", { companyId: cId, taskId, previousStatus, newStatus });
publishDomainChanged();
},
handleApprovalResolved: (approvalId: string, cId: string, _decision: string) => {
deps.dispatchAutomation("approval_resolved", { companyId: cId });
publishDomainChanged();
},
emitEvent: deps.emitEvent,
publishDomainChanged,
wakeAgentIfPossible: (agentId: string | null | undefined, cId: string, trigger: string) =>
agentId != null ? wakeAgentIfPossible(agentId, cId, trigger) ?? null : null,
logger,
};
const escalated = sweepStaleReviews(company.id, sweepDeps);
if (escalated > 0) {
logger.info(`[housekeeping] Auto-escalated ${escalated} stale review(s) for company ${company.name}`);
publishDomainChanged();
}
} catch (error) {
logAppError(logger, error, {
kind: "recoverable",
code: "scheduler.housekeeping.stale_review_sweep_failed",
message: `Stale review sweep failed for company ${company.id}`,
context: { companyId: company.id },
});
}
} catch (error) {
logAppError(logger, error, {
kind: "recoverable",
code: "scheduler.housekeeping.company_tick_failed",
message: `Housekeeping tick failed for company ${company.id}`,
context: { companyId: company.id },
});
}
}
try {
const staleWakeups = db.wakeup.purgeExpiredWakeupRequests();
if (staleWakeups > 0) {
logger.info(`[housekeeping] Purged ${staleWakeups} expired wakeup request(s)`);
}
} catch (error) {
logAppError(logger, error, {
kind: "recoverable",
code: "scheduler.housekeeping.wakeup_purge_failed",
message: "Expired wakeup request purge failed; next tick will retry",
});
}
try {
// agent_message_projections accumulates one row per (inbox
// message, session) forever. Once a session has rotated away
// — no agent references it as its current session_id — its
// projection rows only consume disk. Keep a 24h safety margin
// so an active rotation window cannot strip the ledger out
// from under a live run.
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const purgedProjections = db.inboxProjections.purgeOrphanProjections(cutoff);
if (purgedProjections > 0) {
logger.info(`[housekeeping] Purged ${purgedProjections} orphan inbox projection ledger row(s)`);
}
} catch (error) {
logAppError(logger, error, {
kind: "recoverable",
code: "scheduler.housekeeping.inbox_projection_purge_failed",
message: "Orphan inbox projection purge failed; next tick will retry",
});
}
} catch (error) {
logAppError(logger, error, {
kind: "recoverable",
code: "scheduler.housekeeping.tick_failed",
message: "Housekeeping scheduler tick raised; next interval will retry",
});
}
}, 300_000);
}
function stop() {
if (timer) clearInterval(timer);
timer = null;
}
return { start, stop };
}