diff --git a/AgentCompany-New/src/main/services/goal-kickoff.ts b/AgentCompany-New/src/main/services/goal-kickoff.ts new file mode 100644 index 00000000..8667be5a --- /dev/null +++ b/AgentCompany-New/src/main/services/goal-kickoff.ts @@ -0,0 +1,73 @@ +import type { DatabaseSync } from "node:sqlite"; +import { RISK_LEVEL, TASK_STATUS } from "../../shared/domain"; +import { createId } from "../../shared/ids"; + +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 4c3722dd..a1e575ad 100644 --- a/AgentCompany-New/src/main/services/onboarding-service.ts +++ b/AgentCompany-New/src/main/services/onboarding-service.ts @@ -4,10 +4,11 @@ import type { BootstrapOnboardingSummary, SaveProviderCredentialInput, } from "../../shared/commands"; -import { PROVIDER_AUTH_TYPE, RISK_LEVEL, TASK_STATUS } from "../../shared/domain"; +import { PROVIDER_AUTH_TYPE } 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 { OrganizationService } from "./organization-service"; import { ChatService } from "./chat-service"; @@ -70,7 +71,16 @@ 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 = this.createKickoffTask(orgId, goalId, projectId, workspaceId, ceoAgentId, input, now); + const taskId = createGoalKickoffTask(this.deps.db, { + orgId, + goalId, + goalTitle: input.goalTitle, + goalDescription: input.goalDescription, + companyName: input.companyName, + assigneeAgentId: ceoAgentId, + projectId, + workspaceId, + }); const runId = this.createTaskRun(orgId, taskId, ceoAgentId, providerId, workspaceId, now); this.audit.record({ @@ -172,41 +182,6 @@ export class OnboardingService { .run(projectId, now, workspaceId); } - private createKickoffTask( - orgId: string, - goalId: string, - projectId: string, - workspaceId: string | null, - ceoAgentId: string, - input: BootstrapOnboardingInput, - now: string, - ): string { - const taskId = createId("tsk"); - this.deps.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, - orgId, - `Operationalize goal: ${input.goalTitle.trim()}`, - this.createKickoffTaskDescription(input), - TASK_STATUS.BACKLOG, - ceoAgentId, - RISK_LEVEL.MEDIUM, - projectId, - goalId, - workspaceId, - "high", - workspaceId ? 1 : 0, - now, - now, - ); - return taskId; - } - private createTaskRun(orgId: string, taskId: string, ceoAgentId: string, providerId: string, workspaceId: string | null, now: string): string { const runId = createId("run"); const executionContextId = createId("ctx"); @@ -254,15 +229,6 @@ export class OnboardingService { if (!row) throw new Error("Agent service identity was not found."); return row.id; } - - private createKickoffTaskDescription(input: BootstrapOnboardingInput): 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"); - } } function projectName(input: BootstrapOnboardingInput): string { diff --git a/AgentCompany-New/src/main/services/organization-service.ts b/AgentCompany-New/src/main/services/organization-service.ts index d2622163..ed661746 100644 --- a/AgentCompany-New/src/main/services/organization-service.ts +++ b/AgentCompany-New/src/main/services/organization-service.ts @@ -4,6 +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 { createGoalKickoffTask } from "./goal-kickoff"; import { PolicyService } from "./policy-service"; import { IdentityAccessService } from "./identity-access-service"; import { OrgGovernanceService } from "./org-governance-service"; @@ -401,9 +402,49 @@ export class OrganizationService { action: "created", payload: { title: input.title.trim() }, }); + this.kickoffGoal(input, id); return id; } + /** + * An active goal must reach an accountable agent — the chosen owner, or + * the top-level agent (the CEO) — as a kickoff task the autonomous + * heartbeat starts. Without this, a goal created outside onboarding would + * sit inert forever. + */ + private kickoffGoal(input: CreateGoalInput, goalId: string): void { + const assigneeAgentId = input.ownerAgentId ?? this.topLevelActiveAgentId(input.orgId); + if (!assigneeAgentId) return; + const organization = this.db.prepare("select name from organizations where id = ?") + .get(input.orgId) as { name: string }; + createGoalKickoffTask(this.db, { + orgId: input.orgId, + goalId, + goalTitle: input.title, + goalDescription: input.description, + companyName: organization.name, + assigneeAgentId, + projectId: null, + workspaceId: this.primaryWorkspaceId(input.orgId), + }); + } + + private topLevelActiveAgentId(orgId: string): string | null { + const row = this.db.prepare(` + select id from agents + where org_id = ? and manager_agent_id is null and lifecycle = 'active' + order by created_at asc + limit 1 + `).get(orgId) as { id: string } | undefined; + return row?.id ?? null; + } + + private primaryWorkspaceId(orgId: string): string | null { + const row = this.db.prepare("select id from workspaces where org_id = ? and is_primary = 1 order by created_at asc limit 1") + .get(orgId) as { id: string } | undefined; + return row?.id ?? null; + } + createProject(input: CreateProjectInput): string { const id = createId("prj"); const now = new Date().toISOString(); diff --git a/AgentCompany-New/tests/e2e/desktop.spec.ts b/AgentCompany-New/tests/e2e/desktop.spec.ts index 47d7c23d..36125948 100644 --- a/AgentCompany-New/tests/e2e/desktop.spec.ts +++ b/AgentCompany-New/tests/e2e/desktop.spec.ts @@ -38,6 +38,11 @@ test("onboards, hires, and creates work in the Electron shell", async () => { await expect(page.getByText("Projects", { exact: true })).toBeVisible(); await expect(page.getByText("Execution plan: Ship a verified control plane").first()).toBeVisible(); await expect(page.getByText("#execution-plan-ship-a-verified-control-plane").first()).toBeVisible(); + await page.getByRole("button", { name: "Goal", exact: true }).click(); + await page.getByLabel("Title").fill("Open the Berlin market"); + await page.getByLabel("Description").fill("Win the first ten customers."); + await page.locator(".modal").getByRole("button", { name: "Create" }).click(); + await expect(page.getByText("Operationalize goal: Open the Berlin market").first()).toBeVisible(); await page.getByRole("button", { name: "Team" }).click(); await page.getByRole("button", { name: "Hire" }).click(); await page.getByLabel("Name").fill("Alex Chen"); diff --git a/AgentCompany-New/tests/integration/autonomous-run.test.ts b/AgentCompany-New/tests/integration/autonomous-run.test.ts index ff3f82e8..40dfc298 100644 --- a/AgentCompany-New/tests/integration/autonomous-run.test.ts +++ b/AgentCompany-New/tests/integration/autonomous-run.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { describe, expect, test } from "vitest"; import { DatabaseConnection } from "../../src/main/database/connection"; import { OnboardingService } from "../../src/main/services/onboarding-service"; +import { OrganizationService } from "../../src/main/services/organization-service"; import { ProviderService } from "../../src/main/services/provider-service"; import { providerAdapters } from "../../src/main/services/provider-adapters"; import { TaskExecutionService } from "../../src/main/services/task-execution-service"; @@ -162,4 +163,67 @@ describe("autonomous bootstrap run", () => { connection.close(); }); + + test("a goal created after onboarding reaches the CEO and runs autonomously", async () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-goal-${randomUUID()}.db`)); + const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, async () => ({ + text: "Reviewed the goal and posted the operating plan.", + toolCalls: [], + rawResponseJson: JSON.stringify({ id: "resp_goal" }), + })); + const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) }); + const organization = new OrganizationService(connection.db); + const taskExecution = new TaskExecutionService(connection.db, providers); + + let orgId = ""; + let ceoAgentId = ""; + connection.transaction(() => { + const summary = onboarding.bootstrap({ + companyName: "OrbitOps AI", + companyDescription: "AI-run operations company.", + employeeName: "Yang", + workspacePath: "", + executionMode: "ai_ceo", + autoApproveHires: true, + reviewDeliverables: false, + goalTitle: "Stand up a local service business in 30 days.", + goalDescription: "Validate the market and win the first paying customers.", + providerKey: "openai", + providerSecret: "sk-test-key", + providerModel: "gpt-5.4", + }); + orgId = summary.orgId; + ceoAgentId = summary.ceoAgentId; + }); + + // Drain the bootstrap kickoff so only the new goal's work remains. + const bootstrapRun = claimNextQueuedRun(connection); + await taskExecution.executeRun(bootstrapRun!.org_id, bootstrapRun!.id); + + let goalId = ""; + connection.transaction(() => { + goalId = organization.createGoal({ + orgId, + title: "Open the Berlin market", + description: "Win the first ten customers.", + ownerAgentId: null, + }); + }); + + const kickoff = connection.db.prepare("select id, assignee_agent_id, status from tasks where goal_id = ?") + .get(goalId) as { id: string; assignee_agent_id: string; status: string }; + expect(kickoff.assignee_agent_id).toBe(ceoAgentId); + expect(kickoff.status).toBe("backlog"); + + // The autonomous worker's heartbeat picks the kickoff up and completes it. + const enqueued = connection.transaction(() => taskExecution.enqueueHeartbeatRuns()); + expect(enqueued).toHaveLength(1); + const claimed = claimNextQueuedRun(connection); + expect(claimed?.id).toBe(enqueued[0]); + await taskExecution.executeRun(claimed!.org_id, claimed!.id); + const run = connection.db.prepare("select state, task_id, trigger from task_runs where id = ?") + .get(claimed!.id) as { state: string; task_id: string; trigger: string }; + expect(run).toMatchObject({ state: "completed", task_id: kickoff.id, trigger: "heartbeat" }); + connection.close(); + }); }); diff --git a/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts index 356f5d5d..e3128fc8 100644 --- a/AgentCompany-New/tests/unit/domain-services.test.ts +++ b/AgentCompany-New/tests/unit/domain-services.test.ts @@ -627,4 +627,77 @@ describe("domain services", () => { expect(state.taskRuns[0]?.serviceIdentityId).toMatch(/^sid_/); connection.close(); }); + + test("creating a goal kicks off an operationalize task for its owner or the CEO", () => { + const connection = createConnection(); + const organization = new OrganizationService(connection.db); + let orgId = ""; + + connection.transaction(() => { + orgId = organization.createOrganization({ + organizationName: "Acme Operations", + employeeName: "Dana Lee", + mission: "Coordinate agents with auditable approvals.", + goal: "Ship a verified control plane.", + }); + }); + + // Without any agent there is nobody to operationalize the goal yet. + connection.transaction(() => { + 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 }); + + let ceoId = ""; + let ownerId = ""; + connection.transaction(() => { + ceoId = organization.createAgent({ + orgId, + name: "CEO", + role: "CEO", + department: "Executive", + modelProviderId: null, + modelId: null, + budgetCents: 25000, + managerAgentId: null, + roleLevel: "c_suite", + }); + ownerId = organization.createAgent({ + orgId, + name: "Mara Quinn", + role: "Growth Marketer", + department: "Marketing", + modelProviderId: null, + modelId: null, + budgetCents: 25000, + managerAgentId: ceoId, + }); + }); + + // Ownerless goals land with the top-level agent; the kickoff re-enables its heartbeat. + let ceoGoalId = ""; + connection.transaction(() => { + ceoGoalId = organization.createGoal({ orgId, title: "Launch the loyalty program", description: "Ship the first reward tier.", ownerAgentId: null }); + }); + const ceoKickoff = connection.db.prepare("select title, status, assignee_agent_id from tasks where goal_id = ?") + .get(ceoGoalId) as { title: string; status: string; assignee_agent_id: string }; + expect(ceoKickoff).toMatchObject({ + title: "Operationalize goal: Launch the loyalty program", + status: TASK_STATUS.BACKLOG, + assignee_agent_id: ceoId, + }); + const ceo = connection.db.prepare("select heartbeat_enabled, heartbeat_interval_sec from agents where id = ?") + .get(ceoId) as { heartbeat_enabled: number; heartbeat_interval_sec: number }; + expect(ceo).toMatchObject({ heartbeat_enabled: 1, heartbeat_interval_sec: 120 }); + + // Goals with an explicit owner land with that owner. + let ownedGoalId = ""; + connection.transaction(() => { + ownedGoalId = organization.createGoal({ orgId, title: "Win the spring campaign", description: "Double qualified leads.", ownerAgentId: ownerId }); + }); + const ownedKickoff = connection.db.prepare("select assignee_agent_id from tasks where goal_id = ?") + .get(ownedGoalId) as { assignee_agent_id: string }; + expect(ownedKickoff.assignee_agent_id).toBe(ownerId); + connection.close(); + }); });