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:
1 parent
9e2c19259a
commit
d52985f61e
24 files changed
+737
-48
No files matched your search
+76
-2
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user