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.
143 lines
5.8 KiB
TypeScript
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 };
|
|
}
|