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:
ZacharyZhang-NY committed 2026-06-09 20:03:09 -04:00
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();