feat: deliverable review gate, app lifecycle fixes, CI/CD pipeline

- Add deliverable review gate: pause workflow when agents produce important
  documents (PRDs, design docs, etc.) for human review before continuing
- Add company-level reviewDeliverables setting with onboarding card selector
- Block agent heartbeats when deliverable review is pending (agent + CEO)
- Trigger review at document creation time in API server for reliability
- Fix autoApproveHires vs reviewDeliverables conflict in approval-policy
- Fix app quit: force-exit on all platforms (macOS Dock, Windows tray, Linux)
- Add multi-platform CI/CD pipeline (macOS arm64/x64, Windows x64/arm64,
  Linux x64/arm64 with deb/AppImage/rpm/snap) with code signing support

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 4.6 committed 2026-03-23 23:31:48 -04:00
1 parent 9e2c19259a
commit d52985f61e
24 files changed
+737 -48

No files matched your search

+76 -2
View File
@@ -390,6 +390,47 @@ export class AgentApiServer {
this.json(res, 403, { error: "Current agent is not allowed to assign this task to the selected agent" });
return;
}
// Deliverable review gate: intercept agent trying to advance a flagged task to "done"
let deliverableReviewIntercepted = false;
if (update.status === "done" && task.requiresUserReview) {
try {
const importantTypes = ["prd","technical_spec","design_doc","project_brief","proposal","budget_proposal","architecture_decision","contract","sop"];
const placeholders = importantTypes.map(() => "?").join(", ");
// Scope to documents authored by the task's agent, created during this task's lifetime
const docs = this.db.queryAll<{ id: string; type: string; title: string }>(
`select id, type, title from documents where company_id = ? and type in (${placeholders})
and (author_agent_id = ? or created_at >= (select created_at from tasks where id = ?))
order by created_at desc limit 10`,
task.companyId, ...importantTypes, task.assigneeAgentId ?? "", task.id,
);
const reviewerAgent = this.db.getAgent(token.agentId);
const docTitles = docs.map((d) => d.title).join(", ");
this.db.requestApproval({
companyId: task.companyId,
relatedTaskId: task.id,
requestedByAgentId: token.agentId,
type: "deliverable_review",
payloadSummary: `Review deliverable: ${docTitles || task.title}`.slice(0, 280),
impactSummary: `${reviewerAgent.name} reviewed and approved. Awaiting your final review.`.slice(0, 400),
payloadJson: JSON.stringify({ documentIds: docs.map((d) => d.id), documents: docs }),
});
this.db.setTaskRequiresUserReview(task.id, false);
// Pause the producing agent's heartbeat until user reviews
if (task.assigneeAgentId) {
try {
const prodAgent = this.db.getAgent(task.assigneeAgentId);
if (prodAgent.heartbeatEnabled) {
this.db.setHeartbeat(task.assigneeAgentId, false, prodAgent.heartbeatIntervalSec || 120);
}
} catch { /* best effort */ }
}
this.db.addComment({ companyId: task.companyId, taskId: task.id, authorAgentId: null, authorName: "System", body: "Deliverable submitted for board review." });
update.status = "in_review"; // Override: keep in review, don't advance to done
deliverableReviewIntercepted = true;
} catch { /* deliverable review interception is best-effort; fall through to normal flow */ }
}
const input: Record<string, unknown> = {
id: task.id,
companyId: task.companyId,
@@ -1393,10 +1434,12 @@ export class AgentApiServer {
this.json(res, 400, { error: "Goal does not belong to the current company" });
return;
}
const docType = String(data.type ?? "knowledge_article");
const docTitle = String(data.title ?? "Untitled");
const id = this.db.saveDocument({
companyId: token.companyId,
type: String(data.type ?? "knowledge_article"),
title: String(data.title ?? "Untitled"),
type: docType,
title: docTitle,
content: String(data.content ?? ""),
authorAgentId: token.agentId,
reviewerAgentId,
@@ -1412,6 +1455,37 @@ export class AgentApiServer {
if (reviewerAgentId) {
this.onWakeAgent(reviewerAgentId, token.companyId, "assignment");
}
// Deliverable review gate: check at document creation time (most reliable)
const IMPORTANT_DOC_TYPES = new Set(["prd","technical_spec","design_doc","project_brief","proposal","budget_proposal","architecture_decision","contract","sop"]);
if (IMPORTANT_DOC_TYPES.has(docType)) {
try {
const company = this.db.listSnapshot().companies.find((c) => c.id === token.companyId);
if (company?.reviewDeliverables) {
// Check if there's already a pending deliverable_review for this company
if (!this.db.hasPendingDeliverableReviews(token.companyId)) {
this.db.requestApproval({
companyId: token.companyId,
relatedTaskId: null,
requestedByAgentId: token.agentId,
type: "deliverable_review",
payloadSummary: `Review deliverable: ${docTitle}`.slice(0, 280),
impactSummary: `New ${docType.replaceAll("_", " ")} created. Awaiting your review before workflow continues.`.slice(0, 400),
payloadJson: JSON.stringify({ documentIds: [id], documents: [{ id, type: docType, title: docTitle }] }),
});
// Pause the producing agent's heartbeat
try {
const agent = this.db.getAgent(token.agentId);
if (agent.heartbeatEnabled) {
this.db.setHeartbeat(token.agentId, false, agent.heartbeatIntervalSec || 120);
}
} catch { /* best effort */ }
this.onDomainChanged();
}
}
} catch { /* deliverable review gate is best-effort */ }
}
this.json(res, 201, { id });
});
+4 -4
View File
@@ -10,7 +10,7 @@ interface AppLifecycleDependencies {
export function createAppLifecycleService({
getMainWindow,
ensureWindow,
shouldQuit: _shouldQuit,
shouldQuit,
onBeforeQuit,
}: AppLifecycleDependencies) {
function register(hasSingleInstanceLock: boolean) {
@@ -44,9 +44,9 @@ export function createAppLifecycleService({
});
app.on("window-all-closed", () => {
// On macOS, keep the app running when all windows are closed (standard behavior).
// On Windows and Linux, quit the app.
if (process.platform !== "darwin") {
// On macOS, keep the app running when all windows are closed (standard behavior)
// UNLESS the user explicitly chose Quit (before-quit already fired).
if (process.platform !== "darwin" || shouldQuit()) {
app.quit();
}
});
+11 -2
View File
@@ -11,10 +11,19 @@ export function shouldAutoApproveApproval(
return false;
}
return db
const company = db
.listSnapshot()
.companies
.some((company) => company.id === companyId && company.autoApproveHires);
.find((c) => c.id === companyId);
if (!company?.autoApproveHires) return false;
// When reviewDeliverables is ON, CEO strategy needs human review — don't auto-approve
if (approvalType === "approve_ceo_strategy" && company.reviewDeliverables) {
return false;
}
return true;
}
export function resolveApprovalDecision({
+133
View File
@@ -10,6 +10,12 @@ import type {
import { dispatchAgentMessage } from "./message-dispatch";
import type { NotificationOptions } from "./notification-service";
/** Document types considered important enough to gate behind user review when reviewDeliverables is enabled. */
const IMPORTANT_DOCUMENT_TYPES = new Set([
"prd", "technical_spec", "design_doc", "project_brief", "proposal",
"budget_proposal", "architecture_decision", "contract", "sop",
]);
const WORKFLOW_ACTIONS: ReadonlySet<AutomationAction> = new Set([
"assign_task",
"create_task",
@@ -801,6 +807,57 @@ export function createAutomationService({
return false;
}
/** Returns important documents created during this run (matching IMPORTANT_DOCUMENT_TYPES). */
function findImportantDocsForRun(runId: string, companyId: string): Array<{ id: string; type: string; title: string }> {
try {
const placeholders = [...IMPORTANT_DOCUMENT_TYPES].map(() => "?").join(", ");
return db.queryAll<{ id: string; type: string; title: string }>(
`select id, type, title from documents where company_id = ? and type in (${placeholders}) and created_at >= (select started_at from runs where id = ?)`,
companyId, ...IMPORTANT_DOCUMENT_TYPES, runId,
);
} catch { return []; }
}
/** Creates a deliverable_review approval and fires OS notification. */
function createDeliverableReviewApproval(
companyId: string,
taskId: string,
agentId: string,
docs: Array<{ id: string; type: string; title: string }>,
reviewerNote?: string,
): string {
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);
// Pause the producing agent's heartbeat until the user reviews
try {
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.`);
}
} catch { /* best effort */ }
try {
notify({
title: "Deliverable Ready for Review",
body: `${docTitles}`.slice(0, 200),
urgency: "informational",
navigation: { section: "approvals", entityId: approvalId },
batchKey: "deliverable_review",
});
} catch { /* best effort */ }
publishDomainChanged();
return approvalId;
}
function processRunCompletion(run: RunRecord, event: RunFinishedEvent) {
let task: ReturnType<typeof db.getTask> | null = null;
try {
@@ -841,6 +898,27 @@ export function createAutomationService({
db.advanceTaskStatus(task.id, "in_review", "Run completed with deliverable. Awaiting review.");
handleTaskStatusChange(task.id, run.companyId, "in_progress", "in_review");
effectiveStatus = "in_review";
// Check if deliverable review gate should activate
try {
const company = db.listSnapshot().companies.find((c) => c.id === run.companyId);
if (company?.reviewDeliverables) {
const importantDocs = findImportantDocsForRun(run.id, run.companyId);
if (importantDocs.length > 0) {
db.setTaskRequiresUserReview(task.id, true);
const agent = db.getAgent(run.agentId);
// Pause heartbeat immediately — agent should not run while deliverable awaits review
if (agent.heartbeatEnabled) {
db.setHeartbeat(agent.id, false, agent.heartbeatIntervalSec || 120);
logger.info(`[deliverable-review] Paused heartbeat for ${agent.name} — deliverable flagged for review.`);
}
// If agent has no manager (is CEO), create approval immediately
const chain = db.getChainOfCommand(agent.id);
if (chain.length === 0) {
createDeliverableReviewApproval(run.companyId, task.id, run.agentId, importantDocs);
}
}
}
} catch { /* deliverable review gate is best-effort */ }
publishDomainChanged();
}
} else {
@@ -848,6 +926,25 @@ export function createAutomationService({
db.advanceTaskStatus(task.id, "in_review", "Run completed successfully. Awaiting review.");
handleTaskStatusChange(task.id, run.companyId, "in_progress", "in_review");
effectiveStatus = "in_review";
// Check if deliverable review gate should activate for code/general tasks too
try {
const company = db.listSnapshot().companies.find((c) => c.id === run.companyId);
if (company?.reviewDeliverables) {
const importantDocs = findImportantDocsForRun(run.id, run.companyId);
if (importantDocs.length > 0) {
db.setTaskRequiresUserReview(task.id, 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(run.companyId, task.id, run.agentId, importantDocs);
}
}
}
} catch { /* deliverable review gate is best-effort */ }
publishDomainChanged();
}
}
@@ -976,6 +1073,42 @@ export function createAutomationService({
}
}
if (approval.type === "deliverable_review" && approval.relatedTaskId) {
try {
const task = db.getTask(approval.relatedTaskId);
// Re-enable heartbeat for the producing agent now that the user has decided
if (task.assigneeAgentId) {
try {
const agent = db.getAgent(task.assigneeAgentId);
if (!agent.heartbeatEnabled) {
db.setHeartbeat(task.assigneeAgentId, true, agent.heartbeatIntervalSec || 120);
logger.info(`[deliverable-review] Resumed heartbeat for ${agent.name} after user ${decision}.`);
}
} catch { /* best effort */ }
}
if (decision === "approved") {
db.advanceTaskStatus(task.id, "done", "Board approved deliverable.");
handleTaskStatusChange(task.id, companyId, task.status, "done");
db.addActivity({ companyId, actor: "board", action: "deliverable.approved", entityType: "task", entityId: task.id, detail: approval.decisionNote || "Deliverable approved by board." });
publishDomainChanged();
} else if (decision === "rejected") {
db.advanceTaskStatus(task.id, "in_progress", `Board rejected deliverable: ${approval.decisionNote || "No reason given."}`);
handleTaskStatusChange(task.id, companyId, task.status, "in_progress");
if (task.assigneeAgentId) {
wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed");
}
publishDomainChanged();
} else if (decision === "revision_requested") {
if (task.assigneeAgentId) {
wakeAgentIfPossible(task.assigneeAgentId, companyId, "revision_needed");
}
publishDomainChanged();
}
} catch {
// Deliverable review follow-up is best-effort.
}
}
const browserAction = db.getBrowserActionByApprovalId(approvalId, companyId);
if (browserAction) {
if (decision === "approved") {
+54 -10
View File
@@ -528,8 +528,8 @@ export class AppDatabase {
secretBackend: this.vault.getBackendLabel(),
},
companies: this.readAll<CompanyRecord>(
"select id, name, description, status, auto_approve_hires as autoApproveHires, created_at as createdAt, updated_at as updatedAt from companies order by created_at asc",
).map((record) => ({ ...record, autoApproveHires: toBoolean(record.autoApproveHires) })) as CompanyRecord[],
"select id, name, description, status, auto_approve_hires as autoApproveHires, review_deliverables as reviewDeliverables, created_at as createdAt, updated_at as updatedAt from companies order by created_at asc",
).map((record) => ({ ...record, autoApproveHires: toBoolean(record.autoApproveHires), reviewDeliverables: toBoolean(record.reviewDeliverables) })) as CompanyRecord[],
connectors,
workspaces: this.readAll<WorkspaceRecord>(
`select id, company_id as companyId, name, local_path as localPath, repo_url as repoUrl, repo_ref as repoRef, project_id as projectId, state, is_primary as isPrimary, created_at as createdAt, updated_at as updatedAt from workspaces${companyWhere} order by created_at desc`,
@@ -548,9 +548,9 @@ export class AppDatabase {
...companyArgs,
),
tasks: this.readAll<TaskRecord>(
`select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, created_at as createdAt, updated_at as updatedAt from tasks${companyWhere} order by created_at desc`,
`select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, requires_user_review as requiresUserReview, created_at as createdAt, updated_at as updatedAt from tasks${companyWhere} order by created_at desc`,
...companyArgs,
),
).map((record) => ({ ...record, requiresUserReview: toBoolean(record.requiresUserReview) })) as TaskRecord[],
approvals: this.readAll<ApprovalRecord>(
`select id, company_id as companyId, related_task_id as relatedTaskId, related_agent_id as relatedAgentId, requested_by_agent_id as requestedByAgentId, type, payload_summary as payloadSummary, impact_summary as impactSummary, payload_json as payloadJson, decision_note as decisionNote, state, created_at as createdAt, updated_at as updatedAt from approvals${companyWhere} order by created_at desc`,
...companyArgs,
@@ -675,19 +675,20 @@ export class AppDatabase {
return snapshot;
}
saveCompany(input: { id?: string | null; name: string; description: string; status: string; autoApproveHires?: boolean }) {
saveCompany(input: { id?: string | null; name: string; description: string; status: string; autoApproveHires?: boolean; reviewDeliverables?: boolean }) {
const id = ensureId(input.id ?? null);
const ts = nowIso();
const autoApprove = input.autoApproveHires ? 1 : 0;
const reviewDeliverables = input.reviewDeliverables ? 1 : 0;
const existing = this.db.prepare("select id from companies where id = ?").get(id) as { id: string } | undefined;
if (existing) {
this.db
.prepare("update companies set name = ?, description = ?, status = ?, auto_approve_hires = ?, updated_at = ? where id = ?")
.run(input.name, input.description, input.status, autoApprove, ts, id);
.prepare("update companies set name = ?, description = ?, status = ?, auto_approve_hires = ?, review_deliverables = ?, updated_at = ? where id = ?")
.run(input.name, input.description, input.status, autoApprove, reviewDeliverables, ts, id);
} else {
this.db
.prepare("insert into companies (id, name, description, status, auto_approve_hires, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?)")
.run(id, input.name, input.description, input.status, autoApprove, ts, ts);
.prepare("insert into companies (id, name, description, status, auto_approve_hires, review_deliverables, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)")
.run(id, input.name, input.description, input.status, autoApprove, reviewDeliverables, ts, ts);
if (!this.getCurrentCompanyId()) {
this.setCurrentCompany(id);
}
@@ -1098,10 +1099,11 @@ export class AppDatabase {
getTask(id: string): TaskRecord {
const row = this.db
.prepare(
"select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, created_at as createdAt, updated_at as updatedAt from tasks where id = ?",
"select id, company_id as companyId, project_id as projectId, goal_id as goalId, parent_id as parentId, title, description, assignee_agent_id as assigneeAgentId, workspace_id as workspaceId, status, priority, active_run_id as activeRunId, session_json as sessionJson, session_display_id as sessionDisplayId, cost_usd as costUsd, requires_user_review as requiresUserReview, created_at as createdAt, updated_at as updatedAt from tasks where id = ?",
)
.get(id) as TaskRecord | undefined;
if (!row) throw new Error(`Task not found: ${id}`);
row.requiresUserReview = Boolean(row.requiresUserReview);
return row;
}
@@ -2180,7 +2182,9 @@ export class AppDatabase {
addColumnIfMissing("alter table agents add column heartbeat_interval_sec integer not null default 120");
addColumnIfMissing("alter table agents add column last_heartbeat_at text");
addColumnIfMissing("alter table companies add column auto_approve_hires integer not null default 0");
addColumnIfMissing("alter table companies add column review_deliverables integer not null default 0");
addColumnIfMissing("alter table tasks add column task_type text not null default 'general'");
addColumnIfMissing("alter table tasks add column requires_user_review integer not null default 0");
addColumnIfMissing("alter table tasks add column retry_count integer not null default 0");
addColumnIfMissing("alter table tasks add column last_failure_at text");
this.db.exec(`
@@ -2275,6 +2279,7 @@ export class AppDatabase {
or (type = 'dangerous_command' and ${taskCondition})
or (type = 'secret_access' and ${taskCondition})
or (type = 'approve_ceo_strategy' and ${taskCondition})
or (type = 'deliverable_review' and (requested_by_agent_id = ? or (related_task_id is not null and ${taskCondition})))
)
order by created_at asc limit 1`,
companyId,
@@ -2282,6 +2287,8 @@ export class AppDatabase {
...taskParams,
...taskParams,
...taskParams,
agentId,
...taskParams,
);
return rows.length > 0 ? rows[0] : null;
}
@@ -2802,6 +2809,43 @@ export class AppDatabase {
return { previousStatus };
}
setTaskRequiresUserReview(taskId: string, value: boolean) {
const ts = nowIso();
this.db.prepare("update tasks set requires_user_review = ?, updated_at = ? where id = ?").run(value ? 1 : 0, ts, taskId);
}
/** Returns true if the company has any pending deliverable_review approvals or tasks awaiting review. */
hasPendingDeliverableReviews(companyId: string): boolean {
const approval = this.db.prepare(
"select 1 from approvals where company_id = ? and type = 'deliverable_review' and state = 'pending' limit 1",
).get(companyId);
if (approval) return true;
const flaggedTask = this.db.prepare(
"select 1 from tasks where company_id = ? and requires_user_review = 1 limit 1",
).get(companyId);
return !!flaggedTask;
}
/** Returns true if this specific agent has tasks with pending deliverable_review approvals. */
hasAgentPendingDeliverableReview(agentId: string, companyId: string): boolean {
const row = this.db.prepare(
`select 1 from approvals a
join tasks t on a.related_task_id = t.id
where a.company_id = ? and a.type = 'deliverable_review' and a.state = 'pending'
and t.assignee_agent_id = ?
limit 1`,
).get(companyId, agentId);
return !!row;
}
/** Returns true if agent has any tasks flagged requires_user_review (before approval is created). */
hasAgentTaskAwaitingReview(agentId: string, companyId: string): boolean {
const row = this.db.prepare(
"select 1 from tasks where assignee_agent_id = ? and company_id = ? and requires_user_review = 1 limit 1",
).get(agentId, companyId);
return !!row;
}
private incrementActiveSprintVelocity(companyId: string, delta: number) {
const ts = nowIso();
const today = ts.slice(0, 10);
+4
View File
@@ -346,6 +346,10 @@ export function registerCoreWorkGraphHandlers({
if (previousStatus && previousStatus !== parsed.status) {
handleTaskStatusChange(id, parsed.companyId, previousStatus, parsed.status);
}
// User (board) manually advancing a flagged task to done: clear the review flag
if (parsed.status === "done") {
try { db.setTaskRequiresUserReview(id, false); } catch { /* best effort */ }
}
// Wake new assignee when task is reassigned (matches api-server.ts behavior)
if (parsed.assigneeAgentId && parsed.assigneeAgentId !== previousAssigneeAgentId) {
wakeAgentIfPossible(parsed.assigneeAgentId, parsed.companyId, "assignment");
+2
View File
@@ -17,6 +17,7 @@ interface BootstrapInput {
companyDescription: string;
workspacePath: string;
autoApproveHires: boolean;
reviewDeliverables?: boolean;
goalTitle: string;
goalDescription: string;
connectorId?: ConnectorId;
@@ -188,6 +189,7 @@ export function createOnboardingService({
description: input.companyDescription.trim(),
status: "active",
autoApproveHires: input.autoApproveHires,
reviewDeliverables: input.reviewDeliverables,
});
const workspaceId = createDefaultWorkspace(companyId, input.workspacePath);
+14
View File
@@ -335,6 +335,20 @@ export function createOrchestrationService({
return null;
}
// Block heartbeat when agent has tasks awaiting user deliverable review
// Check both: pending approval (gate created) and requires_user_review flag (gap before approval)
if (db.hasAgentPendingDeliverableReview(agentId, companyId) || db.hasAgentTaskAwaitingReview(agentId, companyId)) {
logger.info(`[heartbeat] Skipping ${agent.name} — awaiting user deliverable review.`);
return null;
}
// Block CEO heartbeat when company has any pending deliverable reviews
// CEO should not create new work while the user hasn't reviewed existing deliverables
if (!agent.reportsTo && db.hasPendingDeliverableReviews(companyId)) {
logger.info(`[heartbeat] Skipping CEO ${agent.name} — company has pending deliverable reviews.`);
return null;
}
const assignedTasks = db.listAgentTasks(agentId, companyId, ["todo", "in_progress", "blocked"]);
const isTimer = trigger === "timer";
const candidateTasks = isTimer
+4
View File
@@ -1,3 +1,4 @@
import { app } from "electron";
import type { AgentApiServer } from "./api-server";
import type { AppDatabase } from "./database";
import { BrowserManager } from "./browser-manager";
@@ -225,6 +226,9 @@ export function createServiceGraph({
updaterService = null;
appLifecycleService = null;
db.close();
// Force exit after grace period — covers all platforms (macOS Dock, Windows tray, Linux)
// Worker child processes (claude/codex CLI) may keep the event loop alive indefinitely
setTimeout(() => { app.exit(0); }, 3000);
},
});
}
+9 -2
View File
@@ -426,8 +426,15 @@ export function createWorkerService({
}
function destroy() {
worker?.kill();
worker = null;
if (worker) {
worker.kill();
// Force-kill after 2s if SIGTERM didn't work (child CLI processes may keep it alive)
const ref = worker;
setTimeout(() => {
try { ref.kill(); } catch { /* already dead */ }
}, 2000);
worker = null;
}
}
return {