From 4e47da4d6b378c07cb79794ec39f5e23b7b44bb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 9 Jun 2026 20:38:35 -0400 Subject: [PATCH] Route goal kickoffs through task policy and revive waiting goals on hire Adversarial review of the kickoff feature surfaced two gaps. First, the kickoff task was inserted with raw SQL, so a workspace-bound kickoff carried requires_filesystem without the execution approval every other task receives - manual runs failed and review-mode orgs had no approval trail. Kickoffs (in-app and onboarding) now go through createTask and its policy gates. Second, a goal created before any agent existed stayed inert forever; creating an agent now operationalizes every active goal that has no task yet. Goal owners must be active agents: an archived owner falls back to the top-level agent and the owner dropdown only offers active agents. --- .../src/main/services/goal-kickoff.ts | 144 +++++++----------- .../src/main/services/onboarding-service.ts | 17 ++- .../src/main/services/organization-service.ts | 55 ++++++- .../renderer/src/pages/work/GoalDialog.tsx | 4 +- .../tests/unit/domain-services.test.ts | 9 +- 5 files changed, 125 insertions(+), 104 deletions(-) diff --git a/AgentCompany-New/src/main/services/goal-kickoff.ts b/AgentCompany-New/src/main/services/goal-kickoff.ts index 8712622c..6c9a07b8 100644 --- a/AgentCompany-New/src/main/services/goal-kickoff.ts +++ b/AgentCompany-New/src/main/services/goal-kickoff.ts @@ -1,36 +1,70 @@ import type { DatabaseSync } from "node:sqlite"; -import { RISK_LEVEL, TASK_STATUS } from "../../shared/domain"; -import { createId } from "../../shared/ids"; -export interface CreatedGoalKickoffInput { - orgId: string; - goalId: string; +export interface GoalKickoffPlan { + assigneeAgentId: string; + workspaceId: string | null; title: string; description: string; - ownerAgentId: string | null; } /** - * Routes a newly created goal to an accountable agent — the chosen owner, - * or the top-level agent (the CEO) — as a kickoff task bound to the org's - * primary workspace. Returns null when the org has no agent yet; the goal - * then waits until one exists. + * Plans the kickoff work for an active goal: the chosen owner when that agent + * is still active, otherwise the top-level active agent (the CEO), bound to + * the org's primary workspace. Returns null when the org has no eligible + * agent — creating the first agent then kicks off every untouched goal, so a + * goal never stays inert. */ -export function kickoffCreatedGoal(db: DatabaseSync, input: CreatedGoalKickoffInput): string | null { - const assigneeAgentId = input.ownerAgentId ?? topLevelActiveAgentId(db, input.orgId); +export function resolveGoalKickoff(db: DatabaseSync, input: { + orgId: string; + goalTitle: string; + goalDescription: string; + ownerAgentId: string | null; +}): GoalKickoffPlan | null { + const assigneeAgentId = activeAgentId(db, input.orgId, input.ownerAgentId) ?? topLevelActiveAgentId(db, input.orgId); if (!assigneeAgentId) return null; const organization = db.prepare("select name from organizations where id = ?") .get(input.orgId) as { name: string }; - return createGoalKickoffTask(db, { - orgId: input.orgId, - goalId: input.goalId, - goalTitle: input.title, - goalDescription: input.description, - companyName: organization.name, + return { assigneeAgentId, - projectId: null, workspaceId: primaryWorkspaceId(db, input.orgId), - }); + title: goalKickoffTitle(input.goalTitle), + description: goalKickoffDescription(organization.name, input.goalTitle, input.goalDescription), + }; +} + +export function goalKickoffTitle(goalTitle: string): string { + return `Operationalize goal: ${goalTitle.trim()}`; +} + +export function goalKickoffDescription(companyName: string, goalTitle: string, goalDescription: string): string { + return [ + `Company: ${companyName.trim()}`, + `Goal: ${goalTitle.trim()}`, + goalDescription.trim(), + "Read the bound workspace, create the first operating plan, delegate work, and route decisions through approvals and messages.", + ].filter((line) => line.length > 0).join("\n\n"); +} + +/** + * Waking an agent re-enables its heartbeat (same contract as the previous + * runtime), so a kickoff assigned to an agent hired without a provider still + * starts autonomously once a provider is configured. This also resumes the + * agent's other backlog work — waking an agent wakes all of its assignments. + */ +export function ensureAgentHeartbeat(db: DatabaseSync, orgId: string, agentId: string): void { + db.prepare(` + update agents + set heartbeat_enabled = 1, + heartbeat_interval_sec = case when heartbeat_interval_sec > 0 then heartbeat_interval_sec else 120 end + where id = ? and org_id = ? + `).run(agentId, orgId); +} + +function activeAgentId(db: DatabaseSync, orgId: string, agentId: string | null): string | null { + if (!agentId) return null; + const row = db.prepare("select id from agents where id = ? and org_id = ? and lifecycle = 'active'") + .get(agentId, orgId) as { id: string } | undefined; + return row?.id ?? null; } function topLevelActiveAgentId(db: DatabaseSync, orgId: string): string | null { @@ -48,73 +82,3 @@ function primaryWorkspaceId(db: DatabaseSync, orgId: string): string | null { .get(orgId) as { id: string } | undefined; return row?.id ?? null; } - -export interface GoalKickoffTaskInput { - orgId: string; - goalId: string; - goalTitle: string; - goalDescription: string; - companyName: string; - assigneeAgentId: string; - projectId: string | null; - workspaceId: string | null; -} - -/** - * Turns an active goal into its assignee's first actionable work item: a - * backlog kickoff task the autonomous heartbeat starts as soon as the agent - * has a runnable provider. Onboarding bootstrap and in-app goal creation - * share this contract, so a goal can never exist without an agent obligated - * to operationalize it. - */ -export function createGoalKickoffTask(db: DatabaseSync, input: GoalKickoffTaskInput): string { - ensureAgentHeartbeat(db, input.orgId, input.assigneeAgentId); - const taskId = createId("tsk"); - const now = new Date().toISOString(); - db.prepare(` - insert into tasks( - id, org_id, title, description, status, assignee_agent_id, risk, - project_id, goal_id, workspace_id, priority, requires_filesystem, created_at, updated_at - ) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - taskId, - input.orgId, - `Operationalize goal: ${input.goalTitle.trim()}`, - kickoffDescription(input), - TASK_STATUS.BACKLOG, - input.assigneeAgentId, - RISK_LEVEL.MEDIUM, - input.projectId, - input.goalId, - input.workspaceId, - "high", - input.workspaceId ? 1 : 0, - now, - now, - ); - return taskId; -} - -function kickoffDescription(input: GoalKickoffTaskInput): string { - return [ - `Company: ${input.companyName.trim()}`, - `Goal: ${input.goalTitle.trim()}`, - input.goalDescription.trim(), - "Read the bound workspace, create the first operating plan, delegate work, and route decisions through approvals and messages.", - ].filter((line) => line.length > 0).join("\n\n"); -} - -/** - * Waking an agent re-enables its heartbeat (same contract as the previous - * runtime), so a kickoff assigned to an agent hired without a provider still - * starts autonomously once a provider is configured. - */ -function ensureAgentHeartbeat(db: DatabaseSync, orgId: string, agentId: string): void { - db.prepare(` - update agents - set heartbeat_enabled = 1, - heartbeat_interval_sec = case when heartbeat_interval_sec > 0 then heartbeat_interval_sec else 120 end - where id = ? and org_id = ? - `).run(agentId, orgId); -} diff --git a/AgentCompany-New/src/main/services/onboarding-service.ts b/AgentCompany-New/src/main/services/onboarding-service.ts index a1e575ad..7f8a1068 100644 --- a/AgentCompany-New/src/main/services/onboarding-service.ts +++ b/AgentCompany-New/src/main/services/onboarding-service.ts @@ -4,11 +4,11 @@ import type { BootstrapOnboardingSummary, SaveProviderCredentialInput, } from "../../shared/commands"; -import { PROVIDER_AUTH_TYPE } from "../../shared/domain"; +import { PROVIDER_AUTH_TYPE, RISK_LEVEL, TASK_STATUS } from "../../shared/domain"; import { PROVIDER_CATALOG } from "../../shared/provider-catalog"; import { createId } from "../../shared/ids"; import { AuditService } from "./audit-service"; -import { createGoalKickoffTask } from "./goal-kickoff"; +import { goalKickoffDescription, goalKickoffTitle } from "./goal-kickoff"; import { OrganizationService } from "./organization-service"; import { ChatService } from "./chat-service"; @@ -71,15 +71,18 @@ export class OnboardingService { const projectId = this.createProject(orgId, goalId, ceoAgentId, input, now); this.createProjectChatSpace(orgId, projectName(input)); if (workspaceId) this.bindWorkspaceToProject(workspaceId, projectId, now); - const taskId = createGoalKickoffTask(this.deps.db, { + const taskId = this.organization.createTask({ orgId, - goalId, - goalTitle: input.goalTitle, - goalDescription: input.goalDescription, - companyName: input.companyName, + title: goalKickoffTitle(input.goalTitle), + description: goalKickoffDescription(input.companyName, input.goalTitle, input.goalDescription), + status: TASK_STATUS.BACKLOG, assigneeAgentId: ceoAgentId, + risk: RISK_LEVEL.MEDIUM, projectId, + goalId, workspaceId, + priority: "high", + requiresFilesystem: workspaceId !== null, }); const runId = this.createTaskRun(orgId, taskId, ceoAgentId, providerId, workspaceId, now); diff --git a/AgentCompany-New/src/main/services/organization-service.ts b/AgentCompany-New/src/main/services/organization-service.ts index 3ea91082..1c442a0d 100644 --- a/AgentCompany-New/src/main/services/organization-service.ts +++ b/AgentCompany-New/src/main/services/organization-service.ts @@ -4,7 +4,7 @@ import { PROVIDER_CATALOG } from "../../shared/provider-catalog"; import { createId } from "../../shared/ids"; import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, DeleteTaskInput, UpdateAgentInput, UpdateTaskInput } from "../../shared/commands"; import { AuditService } from "./audit-service"; -import { kickoffCreatedGoal } from "./goal-kickoff"; +import { ensureAgentHeartbeat, resolveGoalKickoff, type GoalKickoffPlan } from "./goal-kickoff"; import { PolicyService } from "./policy-service"; import { IdentityAccessService } from "./identity-access-service"; import { OrgGovernanceService } from "./org-governance-service"; @@ -213,6 +213,7 @@ export class OrganizationService { ); this.settleApproval(input.orgId, approvalId, "agent_hire", RISK_LEVEL.MEDIUM, autoApprove); } + this.kickoffUntouchedGoals(input.orgId); return id; } @@ -402,16 +403,60 @@ export class OrganizationService { action: "created", payload: { title: input.title.trim() }, }); - kickoffCreatedGoal(this.db, { + const plan = resolveGoalKickoff(this.db, { orgId: input.orgId, - goalId: id, - title: input.title, - description: input.description, + goalTitle: input.title, + goalDescription: input.description, ownerAgentId: input.ownerAgentId, }); + if (plan) this.createKickoffTask(input.orgId, id, plan); return id; } + /** + * Materializes a goal's kickoff plan as a first-class task: it passes the + * same policy/approval gates as any other task and wakes the assignee. + */ + private createKickoffTask(orgId: string, goalId: string, plan: GoalKickoffPlan): string { + ensureAgentHeartbeat(this.db, orgId, plan.assigneeAgentId); + return this.createTask({ + orgId, + title: plan.title, + description: plan.description, + status: TASK_STATUS.BACKLOG, + assigneeAgentId: plan.assigneeAgentId, + risk: RISK_LEVEL.MEDIUM, + projectId: null, + goalId, + workspaceId: plan.workspaceId, + priority: "high", + requiresFilesystem: plan.workspaceId !== null, + }); + } + + /** + * Goals created before any agent existed (or whose owner was archived) + * have no kickoff task yet; the moment an agent joins, each untouched + * active goal is operationalized so no goal stays inert. + */ + private kickoffUntouchedGoals(orgId: string): void { + const goals = this.db.prepare(` + select id, title, description, owner_agent_id + from goals g + where g.org_id = ? and g.status = 'active' + and not exists (select 1 from tasks t where t.goal_id = g.id) + `).all(orgId) as Array<{ id: string; title: string; description: string; owner_agent_id: string | null }>; + for (const goal of goals) { + const plan = resolveGoalKickoff(this.db, { + orgId, + goalTitle: goal.title, + goalDescription: goal.description, + ownerAgentId: goal.owner_agent_id, + }); + if (plan) this.createKickoffTask(orgId, goal.id, plan); + } + } + createProject(input: CreateProjectInput): string { const id = createId("prj"); const now = new Date().toISOString(); diff --git a/AgentCompany-New/src/renderer/src/pages/work/GoalDialog.tsx b/AgentCompany-New/src/renderer/src/pages/work/GoalDialog.tsx index 440f6b85..70278af8 100644 --- a/AgentCompany-New/src/renderer/src/pages/work/GoalDialog.tsx +++ b/AgentCompany-New/src/renderer/src/pages/work/GoalDialog.tsx @@ -43,7 +43,9 @@ export function GoalDialog({ diff --git a/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts index e3128fc8..36d2e61d 100644 --- a/AgentCompany-New/tests/unit/domain-services.test.ts +++ b/AgentCompany-New/tests/unit/domain-services.test.ts @@ -643,8 +643,9 @@ describe("domain services", () => { }); // Without any agent there is nobody to operationalize the goal yet. + let waitingGoalId = ""; connection.transaction(() => { - organization.createGoal({ orgId, title: "Open the Berlin market", description: "Win the first ten customers.", ownerAgentId: null }); + waitingGoalId = organization.createGoal({ orgId, title: "Open the Berlin market", description: "Win the first ten customers.", ownerAgentId: null }); }); expect(connection.db.prepare("select count(*) as n from tasks where org_id = ?").get(orgId)).toMatchObject({ n: 0 }); @@ -674,6 +675,12 @@ describe("domain services", () => { }); }); + // The first hire operationalizes the goal that was waiting, exactly once. + expect(connection.db.prepare("select count(*) as n from tasks where goal_id = ?").get(waitingGoalId)).toMatchObject({ n: 1 }); + const revived = connection.db.prepare("select status, assignee_agent_id from tasks where goal_id = ?") + .get(waitingGoalId) as { status: string; assignee_agent_id: string }; + expect(revived).toMatchObject({ status: TASK_STATUS.BACKLOG, assignee_agent_id: ceoId }); + // Ownerless goals land with the top-level agent; the kickoff re-enables its heartbeat. let ceoGoalId = ""; connection.transaction(() => {