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(() => {