Files
AgentCompany/src/main/automation/automation-run-completion.ts
T
ZacharyZhang-NY 40040b3dd2 refactor(errors): surface 57 deferred catch-and-swallow sites via logger
The earlier sweep cleaned the hot path (worker/runtime/automation/api-
server/ipc core — 59 sites). This pass finishes the deferred long tail:
main/* services, database/* CRUD modules, api-server/* routes, ipc-*
handlers, thread-integration, automation helpers, browser, skills.

Default strategy: catch (error) { logger.warn("[module] context:", ...) }
— preserve current behavior, make the failure visible instead of
silently hidden. Widened 3 logger interface signatures to include warn
where callers only passed info/error previously (thread-helpers,
host-environment-service, ipc-domain-common).

Legitimate boundaries left intact: JSON.parse on untrusted input, DNS
resolver no-records paths, explicit safe* helpers (safeJson, parseJson,
parseTriggerDetail), URL-parse predicates, and `<unserialisable>`
fallbacks inside the prompt-builder summarisers. Each kept catch now
either has a reason in context or is a documented safe* helper.

Tests adjusted only where test-constructed no-op loggers needed a warn
stub (message-dispatch, company-mode-switch, ipc-agent-graph-handlers).

With the earlier pass, AgentCompany no longer has silently-swallowing
catch blocks in main-process or renderer business code.
2026-04-17 15:25:13 -04:00

334 lines
13 KiB
TypeScript

import type { AppDatabase } from "../database";
import type { RunRecord, SectionId } from "@shared/types";
import type { NotificationOptions } from "../notification-service";
import type { AutomationEventContext, RunFinishedEvent } from "./types";
import { IMPORTANT_DOCUMENT_TYPES } from "./types";
function localizeNotification(locale: string | null | undefined, en: string, zh: string): string {
return locale === "zh" ? zh : en;
}
export interface RunCompletionDeps {
db: AppDatabase;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
publishDomainChanged: () => void;
notify: (options: NotificationOptions) => void;
wakeAgentIfPossible: (agentId: string | null | undefined, companyId: string, trigger: string) => string | null;
}
export interface RunCompletionCallbacks {
dispatchAutomation: (trigger: string, ctx: AutomationEventContext) => void;
handleTaskStatusChange: (taskId: string, companyId: string, previousStatus: string, newStatus: string) => void;
}
// ─── Pure helpers exported for testing ─────────────────────────────────────
export type DeliverableTaskType = "code" | "research" | "content" | "management" | "general";
/** Returns true for task types that require a tangible artifact before auto-advancing. */
export function needsDeliverableCheck(taskType: DeliverableTaskType | string): boolean {
return taskType === "research" || taskType === "content";
}
export interface DeliverableCheckInput {
taskType: DeliverableTaskType | string;
hasKnowledgeEntry: boolean;
hasDocument: boolean;
hasSubstantialComment: boolean;
}
/** Returns the next task status and comment after a successful run. */
export function determinePostRunStatus(input: DeliverableCheckInput): {
nextStatus: "in_review" | "in_progress";
comment: string;
} {
if (!needsDeliverableCheck(input.taskType)) {
return {
nextStatus: "in_review",
comment: "Run completed successfully. Awaiting review.",
};
}
const delivered = input.hasKnowledgeEntry || input.hasDocument || input.hasSubstantialComment;
if (delivered) {
return {
nextStatus: "in_review",
comment: "Run completed with deliverable. Awaiting review.",
};
}
return {
nextStatus: "in_progress",
comment: `${input.taskType} task run completed but no deliverable artifact was detected. Flagging for manager review.`,
};
}
export interface BudgetThresholdInput {
name: string;
budgetMonthlyUsd: number;
spentMonthlyUsd: number;
}
/**
* Fires budget threshold notifications when utilization >= 80%.
* The notify callback receives a NotificationOptions-style object so callers
* can forward it directly to the notification service.
*/
export function checkBudgetThreshold(
agent: BudgetThresholdInput,
notify: (opts: { title: string; body: string; urgency: "critical" | "informational"; navigation: { section: SectionId }; batchKey?: string }) => void,
localizeNotification: (en: string, zh: string) => string,
): void {
if (agent.budgetMonthlyUsd <= 0) return;
const utilization = agent.spentMonthlyUsd / agent.budgetMonthlyUsd;
if (utilization < 0.8) return;
const pct = Math.round(utilization * 100);
notify({
title: localizeNotification("Budget Warning", "预算警告"),
body: localizeNotification(
`${agent.name} has used ${pct}% of monthly budget`,
`${agent.name} 已使用月度预算的 ${pct}%`,
),
urgency: "informational",
navigation: { section: "costs" },
batchKey: "budget_alerts",
});
if (utilization >= 1.0) {
notify({
title: localizeNotification("Budget Exhausted", "预算耗尽"),
body: localizeNotification(
`${agent.name} has reached 100% of monthly budget and is paused`,
`${agent.name} 已达到月度预算上限并已暂停`,
),
urgency: "critical",
navigation: { section: "costs" },
});
}
}
// ─── Internal DB-backed deliverable check ───────────────────────────────────
function checkRunProducedDeliverable(db: AppDatabase, runId: string, companyId: string, taskId: string): boolean {
return db.runProducedDeliverable(runId, companyId, taskId);
}
/** Returns important documents created during this run (matching IMPORTANT_DOCUMENT_TYPES). */
function findImportantDocsForRun(db: AppDatabase, runId: string, companyId: string): Array<{ id: string; type: string; title: string }> {
const placeholders = [...IMPORTANT_DOCUMENT_TYPES].map(() => "?").join(", ");
// Prefer causal binding, fall back to time-window for legacy data
const causal = db.queryAll<{ id: string; type: string; title: string }>(
`select id, type, title from documents where company_id = ? and type in (${placeholders}) and source_run_id = ?`,
companyId, ...IMPORTANT_DOCUMENT_TYPES, runId,
);
if (causal.length > 0) return causal;
return db.queryAll<{ id: string; type: string; title: string }>(
`select id, type, title from documents where company_id = ? and type in (${placeholders}) and source_run_id is null and created_at >= (select started_at from runs where id = ?)`,
companyId, ...IMPORTANT_DOCUMENT_TYPES, runId,
);
}
/** Creates a deliverable_review approval and fires OS notification. */
function createDeliverableReviewApproval(
deps: RunCompletionDeps,
companyId: string,
taskId: string,
agentId: string,
docs: Array<{ id: string; type: string; title: string }>,
reviewerNote?: string,
): string {
const { db, logger, publishDomainChanged, notify } = deps;
const docTitles = docs.map((d) => d.title).join(", ");
const approvalId = db.requestApproval({
companyId,
relatedTaskId: taskId,
requestedByAgentId: agentId,
type: "deliverable_review",
payloadSummary: `Review deliverable: ${docTitles}`.slice(0, 280),
impactSummary: reviewerNote ?? "Agent completed work with important deliverables. Awaiting your final review.",
payloadJson: JSON.stringify({ documentIds: docs.map((d) => d.id), documents: docs }),
});
db.setTaskRequiresUserReview(taskId, false);
const agent = db.getAgent(agentId);
if (agent.heartbeatEnabled) {
db.setHeartbeat(agentId, false, agent.heartbeatIntervalSec || 120);
logger.info(`[deliverable-review] Paused heartbeat for ${agent.name} — awaiting user review.`);
}
notify({
title: "Deliverable Ready for Review",
body: `${docTitles}`.slice(0, 200),
urgency: "informational",
navigation: { section: "approvals", entityId: approvalId },
batchKey: "deliverable_review",
});
publishDomainChanged();
return approvalId;
}
/** Advances a task to in_review and optionally flags deliverables for user review. */
function advanceToInReview(
deps: RunCompletionDeps,
callbacks: RunCompletionCallbacks,
run: RunRecord,
taskId: string,
statusMessage: string,
): void {
const { db, logger, publishDomainChanged } = deps;
db.advanceTaskStatus(taskId, "in_review", statusMessage);
callbacks.handleTaskStatusChange(taskId, run.companyId, "in_progress", "in_review");
const company = db.listSnapshot().companies.find((c) => c.id === run.companyId);
if (company?.reviewDeliverables) {
const importantDocs = findImportantDocsForRun(db, run.id, run.companyId);
if (importantDocs.length > 0) {
db.setTaskRequiresUserReview(taskId, true);
const agent = db.getAgent(run.agentId);
if (agent.heartbeatEnabled) {
db.setHeartbeat(agent.id, false, agent.heartbeatIntervalSec || 120);
logger.info(`[deliverable-review] Paused heartbeat for ${agent.name} — deliverable flagged for review.`);
}
const chain = db.getChainOfCommand(agent.id);
if (chain.length === 0) {
createDeliverableReviewApproval(deps, run.companyId, taskId, run.agentId, importantDocs);
}
}
}
publishDomainChanged();
}
export function processRunCompletion(
deps: RunCompletionDeps,
callbacks: RunCompletionCallbacks,
run: RunRecord,
event: RunFinishedEvent,
isQuitting: () => boolean,
): void {
if (isQuitting()) return;
const { db, logger: _logger, publishDomainChanged, notify, wakeAgentIfPossible } = deps;
const { dispatchAutomation, handleTaskStatusChange } = callbacks;
let task: ReturnType<typeof db.getTask> | null = null;
try {
task = db.getTask(run.taskId);
} catch (error) {
deps.logger.warn(`[automation] processRunCompletion: task ${run.taskId} not found for run ${run.id}:`, error);
return;
}
if (!task) return;
const isSuccess = event.status === "succeeded";
const isFailed = event.status === "failed" || event.status === "timed_out";
let effectiveStatus = task.status;
if (isSuccess && effectiveStatus === "in_progress") {
// Delegate the task-type → deliverable-required decision to the pure
// helper so production and tests share a single source of truth.
const taskType = task.taskType ?? "general";
if (needsDeliverableCheck(taskType)) {
const hasDeliverable = checkRunProducedDeliverable(db, run.id, run.companyId, task.id);
if (!hasDeliverable) {
// Don't auto-advance -- flag for manager review
db.advanceTaskStatus(task.id, "in_progress",
`${taskType} task run completed but no deliverable artifact was detected. Flagging for manager review.`);
handleTaskStatusChange(task.id, run.companyId, "in_progress", "in_progress");
// Wake manager for review
try {
const agent = db.getAgent(run.agentId);
const chain = db.getChainOfCommand(agent.id);
if (chain.length > 0) {
wakeAgentIfPossible(chain[0].id, run.companyId, "review_needed");
}
} catch (error) {
deps.logger.warn(
`[automation] deliverable-review manager wake failed for agent ${run.agentId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
publishDomainChanged();
// Skip the normal in_review advance
} else {
advanceToInReview(deps, callbacks, run, task.id, "Run completed with deliverable. Awaiting review.");
effectiveStatus = "in_review";
}
} else {
// Code/management/general tasks advance normally
advanceToInReview(deps, callbacks, run, task.id, "Run completed successfully. Awaiting review.");
effectiveStatus = "in_review";
}
}
if (isSuccess && task.isHeartbeat && effectiveStatus !== "done") {
db.advanceTaskStatus(task.id, "done", "Heartbeat check completed.");
handleTaskStatusChange(task.id, run.companyId, effectiveStatus, "done");
}
const automationContext: AutomationEventContext = {
companyId: run.companyId,
taskId: run.taskId,
agentId: run.agentId,
runId: run.id,
};
try {
const agent = db.getAgent(run.agentId);
automationContext.department = agent.department ?? undefined;
} catch (err) {
deps.logger.warn(`[automation] getAgent failed for ${run.agentId} during run completion; dropping department from context:`, err instanceof Error ? err.message : String(err));
}
if (isSuccess) {
dispatchAutomation("run_completed", automationContext);
if (task.parentId) {
try {
const siblings = db.listChildTasks(task.parentId, run.companyId);
const allSiblingsDoneOrReview = siblings.every((entry) => entry.status === "done" || entry.status === "in_review");
if (allSiblingsDoneOrReview) {
const parent = db.getTask(task.parentId);
if (parent.assigneeAgentId) {
wakeAgentIfPossible(parent.assigneeAgentId, run.companyId, "subtask_completed");
} else {
const assignedId = db.autoAssignTask(parent.id, run.companyId);
if (assignedId) {
wakeAgentIfPossible(assignedId, run.companyId, "subtask_completed");
}
}
}
} catch (err) {
deps.logger.warn(`[automation] Parent task ${task.parentId} cascade failed during run completion:`, err instanceof Error ? err.message : String(err));
}
}
}
if (isFailed) {
dispatchAutomation("run_failed", automationContext);
}
// Budget threshold check runs after every succeeded run; finishRun
// has already summed cost_entries and updated spentMonthlyUsd, so we
// just read the post-run spend and fire if it crosses 80%.
if (isSuccess) {
try {
const agent = db.getAgent(run.agentId);
if (agent.budgetMonthlyUsd > 0) {
const utilization = agent.spentMonthlyUsd / agent.budgetMonthlyUsd;
if (utilization >= 0.8) {
dispatchAutomation("budget_threshold", {
...automationContext,
agentId: agent.id,
});
}
}
const budgetLocale = db.listSnapshot().locale;
checkBudgetThreshold(
agent,
notify,
(en, zh) => localizeNotification(budgetLocale, en, zh),
);
} catch (err) {
deps.logger.warn(`[automation] Budget follow-up failed for agent ${run.agentId}:`, err instanceof Error ? err.message : String(err));
}
}
}