Kick off goal execution when a goal is created
A goal created from the Work page only wrote a goals row; nothing ever read it, so the company never started working on it. Creating a goal now produces an "Operationalize goal" kickoff task for the chosen owner (or the top-level agent when unowned) and re-enables that agent's heartbeat, so the autonomous queue worker starts the run - the same activation contract the previous runtime had via goal_activated wakes and CEO bootstrap. Onboarding shares the same kickoff module instead of its own copy.
This commit is contained in:
1 parent
bd9807ca8d
commit
3704cec19b
6 files changed
+268
-46
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user