fix: make onboarding bootstrap atomic

This commit is contained in:
ZacharyZhang-NY committed 2026-04-23 20:43:44 -04:00
1 parent d21edb244f
commit c779fc2858
6 files changed
+286 -104

No files matched your search

+91 -79
View File
@@ -13,6 +13,10 @@ interface OnboardingBootstrapSummary {
runId: string | null;
}
interface InitialExecutionLoopResult extends OnboardingBootstrapSummary {
dispatchAfterCommit: () => void;
}
interface BootstrapInput {
companyName: string;
companyDescription: string;
@@ -33,9 +37,9 @@ interface BootstrapInput {
interface OnboardingDependencies {
db: AppDatabase;
seedDefaultAutomationRules: (companyId: string) => void;
queueTaskRun: (taskId: string, trigger: string, actor: string) => {
createTaskRunForDeferredDispatch: (taskId: string, trigger: string, actor: string) => {
ok: boolean;
data?: string;
data?: { runId: string; dispatchAfterCommit: () => void };
error?: { code: string; message: string };
};
publishDomainChanged: () => void;
@@ -78,7 +82,7 @@ function resolveOnboardingConnector(
export function createOnboardingService({
db,
seedDefaultAutomationRules,
queueTaskRun,
createTaskRunForDeferredDispatch,
publishDomainChanged,
scanAndAssignSkills,
logger,
@@ -123,7 +127,7 @@ export function createOnboardingService({
});
}
function createInitialExecutionLoop(companyId: string, workspaceId: string | null): OnboardingBootstrapSummary {
function createInitialExecutionLoop(companyId: string, workspaceId: string | null): InitialExecutionLoopResult {
const companyRows = db.queryAll<{ id: string; name: string; description: string }>(
"select id, name, description from companies where id = ? limit 1",
companyId,
@@ -205,7 +209,7 @@ export function createOnboardingService({
requiresFilesystem: true,
});
const startRunResult = queueTaskRun(taskId, "assignment", "system");
const startRunResult = createTaskRunForDeferredDispatch(taskId, "assignment", "system");
if (!startRunResult.ok && !DEFERRABLE_RUN_ERRORS.has(startRunResult.error?.code ?? "")) {
throw new Error(startRunResult.error?.message ?? "Failed to queue the initial execution loop.");
}
@@ -217,96 +221,104 @@ export function createOnboardingService({
leadAgentId: leadAgent.id,
projectId,
taskId,
runId: startRunResult.ok ? startRunResult.data ?? null : null,
runId: startRunResult.ok ? startRunResult.data?.runId ?? null : null,
dispatchAfterCommit: startRunResult.ok ? startRunResult.data?.dispatchAfterCommit ?? (() => {}) : () => {},
};
}
function bootstrapOnboarding(input: BootstrapInput): OnboardingBootstrapSummary {
const connectors = db.projection.snapshot.listSnapshot({ companyId: null }).connectors;
const selectedConnectorId = resolveOnboardingConnector(connectors, input.connectorId);
const result = db.withTransaction(() => {
const connectors = db.projection.snapshot.listSnapshot({ companyId: null }).connectors;
const selectedConnectorId = resolveOnboardingConnector(connectors, input.connectorId);
const companyId = db.core.company.saveCompany({
name: input.companyName.trim(),
description: input.companyDescription.trim(),
status: "active",
autoApproveHires: input.autoApproveHires,
reviewDeliverables: input.reviewDeliverables,
executionMode: input.executionMode ?? "ai_ceo",
});
// Persist the user's per-connector disabled-model selections
// against the freshly-created company id. Onboarding is entirely
// pre-commit; the pool settings arrive in the input payload and
// land in the DB only once a company exists to scope them.
for (const pool of input.connectorPoolSelections ?? []) {
db.core.settings.setDisabledModels(companyId, pool.connectorId, pool.disabledModels);
}
// Validate the CEO model selection against the now-persisted pool
// scoped to this new company. Must run AFTER the pool is stored,
// otherwise the validator sees an empty pool and accepts anything.
const modelSelection = validateConnectorModelSelection(connectorPolicyDepsFromDb(db), companyId, selectedConnectorId, input.connectorModel ?? null);
if (!modelSelection.ok) {
throw new Error(modelSelection.error);
}
const workspaceId = createDefaultWorkspace(companyId, input.workspacePath);
const goalTitle = input.goalTitle.trim();
if (goalTitle) {
db.core.entity.saveGoal({
companyId,
title: goalTitle,
description: input.goalDescription.trim() || `Achieve: ${goalTitle}`,
const companyId = db.core.company.saveCompany({
name: input.companyName.trim(),
description: input.companyDescription.trim(),
status: "active",
parentId: null,
ownerAgentId: null,
autoApproveHires: input.autoApproveHires,
reviewDeliverables: input.reviewDeliverables,
executionMode: input.executionMode ?? "ai_ceo",
});
}
const ceoLocale = getLocale();
const ceoId = db.core.agents.saveAgent({
companyId,
name: "CEO",
role: "CEO",
title: ceoLocale === "zh" ? "首席执行官" : "Chief Executive Officer",
reportsTo: null,
connectorId: selectedConnectorId,
workspaceId,
model: input.connectorModel?.trim() || null,
capabilities: ceoLocale === "zh"
? "高管领导力、战略规划、团队建设、目标分解、授权管理和组织运营。负责招募领导团队、制定战略,并协调公司所有运营活动。"
: "Executive leadership, strategic planning, team building, goal decomposition, delegation, and organizational management. Responsible for hiring the leadership team, setting strategy, and coordinating all company operations.",
budgetMonthlyUsd: 250,
status: "idle",
// CEO is pinned to the user's pick at onboarding. Never let the
// dispatcher re-route it.
modelStrategy: "explicit",
roleLevel: "c_suite",
// Persist the user's per-connector disabled-model selections
// against the freshly-created company id. Onboarding is entirely
// pre-commit; the pool settings arrive in the input payload and
// land in the DB only once a company exists to scope them.
for (const pool of input.connectorPoolSelections ?? []) {
db.core.settings.setDisabledModels(companyId, pool.connectorId, pool.disabledModels);
}
// Validate the CEO model selection against the now-persisted pool
// scoped to this new company. Must run AFTER the pool is stored,
// otherwise the validator sees an empty pool and accepts anything.
const modelSelection = validateConnectorModelSelection(connectorPolicyDepsFromDb(db), companyId, selectedConnectorId, input.connectorModel ?? null);
if (!modelSelection.ok) {
throw new Error(modelSelection.error);
}
const workspaceId = createDefaultWorkspace(companyId, input.workspacePath);
const goalTitle = input.goalTitle.trim();
if (goalTitle) {
db.core.entity.saveGoal({
companyId,
title: goalTitle,
description: input.goalDescription.trim() || `Achieve: ${goalTitle}`,
status: "active",
parentId: null,
ownerAgentId: null,
});
}
const ceoLocale = getLocale();
const ceoId = db.core.agents.saveAgent({
companyId,
name: "CEO",
role: "CEO",
title: ceoLocale === "zh" ? "首席执行官" : "Chief Executive Officer",
reportsTo: null,
connectorId: selectedConnectorId,
workspaceId,
model: input.connectorModel?.trim() || null,
capabilities: ceoLocale === "zh"
? "高管领导力、战略规划、团队建设、目标分解、授权管理和组织运营。负责招募领导团队、制定战略,并协调公司所有运营活动。"
: "Executive leadership, strategic planning, team building, goal decomposition, delegation, and organizational management. Responsible for hiring the leadership team, setting strategy, and coordinating all company operations.",
budgetMonthlyUsd: 250,
status: "idle",
// CEO is pinned to the user's pick at onboarding. Never let the
// dispatcher re-route it.
modelStrategy: "explicit",
roleLevel: "c_suite",
});
db.core.agents.setHeartbeat(ceoId, true, 120);
db.core.settings.setCurrentCompany(companyId);
seedDefaultAutomationRules(companyId);
const kickoff = createInitialExecutionLoop(companyId, workspaceId);
db.core.activity.addActivity({
companyId,
actor: "board",
action: "company.bootstrapped",
entityType: "company",
entityId: companyId,
detail: `Bootstrapped from onboarding with connector ${selectedConnectorId}.`,
});
return { kickoff, ceoId };
});
db.core.agents.setHeartbeat(ceoId, true, 120);
db.core.settings.setCurrentCompany(companyId);
seedDefaultAutomationRules(companyId);
result.kickoff.dispatchAfterCommit();
if (scanAndAssignSkills) {
void scanAndAssignSkills(companyId, ceoId).catch((error) => {
void scanAndAssignSkills(result.kickoff.companyId, result.ceoId).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
logger?.warn(`[onboarding] skill scan failed for agent ${ceoId}: ${message}`);
logger?.warn(`[onboarding] skill scan failed for agent ${result.ceoId}: ${message}`);
});
}
const kickoff = createInitialExecutionLoop(companyId, workspaceId);
db.core.activity.addActivity({
companyId,
actor: "board",
action: "company.bootstrapped",
entityType: "company",
entityId: companyId,
detail: `Bootstrapped from onboarding with connector ${selectedConnectorId}.`,
});
publishDomainChanged();
return kickoff;
const { dispatchAfterCommit: _dispatchAfterCommit, ...summary } = result.kickoff;
return summary;
}
return {
+1
View File
@@ -38,6 +38,7 @@ export function createOrchestrationService(deps: OrchestrationDependencies) {
wakeAgentIfPossible: heartbeat.wakeAgentIfPossible,
dispatchRunToWorker: runDispatch.dispatchRunToWorker,
queueTaskRun: taskRunner.queueTaskRun,
createTaskRunForDeferredDispatch: taskRunner.createTaskRunForDeferredDispatch,
resumeQueuedRuns: taskRunner.resumeQueuedRuns,
startAgentHeartbeatRun: heartbeat.startAgentHeartbeatRun,
startAllSchedulers: scheduler.startAllSchedulers,
+67 -22
View File
@@ -6,6 +6,12 @@ import type { ResolveWorkspaceFn } from "./run-dispatch";
import { WorkspaceConfigurationError } from "./resolve-workspace";
import { pickModelForAgent } from "../model-dispatch";
interface PreparedTaskRun {
runId: string;
taskTitle: string;
dispatchParams: RunDispatchParams;
}
export function createTaskRunner(
deps: OrchestrationDependencies,
resolveWorkspace: ResolveWorkspaceFn,
@@ -29,11 +35,15 @@ export function createTaskRunner(
return { ok: false, error: { code, message } };
}
function queueTaskRun(taskId: string, trigger: string, actor: string): DesktopResult<string> {
function prepareTaskRun(
taskId: string,
trigger: string,
options: { requireWorkerReady: boolean },
): DesktopResult<PreparedTaskRun> {
if (isQuitting()) {
return fail("APP_SHUTTING_DOWN", "Application is shutting down.");
}
if (!hasWorkerReady()) {
if (options.requireWorkerReady && !hasWorkerReady()) {
logger.warn(`[run] Worker not ready — cannot queue run for task ${taskId} (trigger: ${trigger})`);
return fail("WORKER_UNAVAILABLE", "Orchestration worker is not ready.");
}
@@ -112,34 +122,69 @@ export function createTaskRunner(
}
const companyBasic = db.core.company.getCompanyBasic(task.companyId);
dispatchRunToWorker({
return ok({
runId: run.id,
companyId: task.companyId,
agent,
task,
connector,
workspace,
workspaceMode: workspaceResolution.lockMode,
observationPath: workspaceResolution.observationPath,
companyName: companyBasic?.name ?? "Unknown",
companyDescription: companyBasic?.description ?? "",
snapshot: db.projection.snapshot.listSnapshot(),
trigger,
resolvedModel: assignment.modelId,
taskTitle: task.title,
dispatchParams: {
runId: run.id,
companyId: task.companyId,
agent,
task,
connector,
workspace,
workspaceMode: workspaceResolution.lockMode,
observationPath: workspaceResolution.observationPath,
companyName: companyBasic?.name ?? "Unknown",
companyDescription: companyBasic?.description ?? "",
snapshot: db.projection.snapshot.listSnapshot(),
trigger,
resolvedModel: assignment.modelId,
},
});
const postDispatchRun = db.core.runs.getRun(run.id);
}
function recordQueuedRun(prepared: PreparedTaskRun, actor: string) {
const postDispatchRun = db.core.runs.getRun(prepared.runId);
if (postDispatchRun.status === "queued" || postDispatchRun.status === "running") {
db.core.activity.addActivity({
companyId: task.companyId,
companyId: prepared.dispatchParams.companyId,
actor,
action: "run.queued",
entityType: "run",
entityId: run.id,
detail: task.title,
entityId: prepared.runId,
detail: prepared.taskTitle,
});
emitEvent({ type: "run-updated", runId: run.id, action: "created" });
emitEvent({ type: "run-updated", runId: prepared.runId, action: "created" });
}
return ok(run.id);
}
function dispatchPreparedTaskRun(prepared: PreparedTaskRun, actor: string) {
dispatchRunToWorker(prepared.dispatchParams);
recordQueuedRun(prepared, actor);
}
function queueTaskRun(taskId: string, trigger: string, actor: string): DesktopResult<string> {
const prepared = prepareTaskRun(taskId, trigger, { requireWorkerReady: true });
if (!prepared.ok) return prepared;
dispatchPreparedTaskRun(prepared.data, actor);
return ok(prepared.data.runId);
}
function createTaskRunForDeferredDispatch(taskId: string, trigger: string, actor: string): DesktopResult<{ runId: string; dispatchAfterCommit: () => void }> {
const prepared = prepareTaskRun(taskId, trigger, { requireWorkerReady: false });
if (!prepared.ok) return prepared;
return ok({
runId: prepared.data.runId,
dispatchAfterCommit: () => {
if (isQuitting()) return;
if (!hasWorkerReady()) {
logger.warn(`[run] Worker not ready — leaving queued run ${prepared.data.runId} for recovery (trigger: ${trigger})`);
recordQueuedRun(prepared.data, actor);
return;
}
dispatchPreparedTaskRun(prepared.data, actor);
},
});
}
function resumeQueuedRuns() {
@@ -236,5 +281,5 @@ export function createTaskRunner(
}
}
return { queueTaskRun, resumeQueuedRuns };
return { queueTaskRun, createTaskRunForDeferredDispatch, resumeQueuedRuns };
}
@@ -75,8 +75,8 @@ export function createBackgroundServices({
const onboardingService = createOnboardingService({
db,
seedDefaultAutomationRules,
queueTaskRun: (taskId, trigger, actor) =>
orchestrationService.queueTaskRun(taskId, trigger, actor),
createTaskRunForDeferredDispatch: (taskId, trigger, actor) =>
orchestrationService.createTaskRunForDeferredDispatch(taskId, trigger, actor),
publishDomainChanged,
scanAndAssignSkills: async (companyId: string, _agentId: string) => {
// All company skills are visible to every agent via progressive disclosure.
@@ -43,7 +43,10 @@ function createBootstrapHarness(db: AppDatabase) {
db,
seedDefaultAutomationRules: noop,
publishDomainChanged: noop,
queueTaskRun: () => ({ ok: true, data: "run-1" }),
createTaskRunForDeferredDispatch: () => ({
ok: true,
data: { runId: "run-1", dispatchAfterCommit: noop },
}),
});
}
@@ -0,0 +1,121 @@
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, describe, expect, it } from "vitest";
import { getModels } from "@mariozechner/pi-ai";
import { AppDatabase } from "../../src/main/database";
import { getConnectorDefinition } from "../../src/main/connectors";
import { createOnboardingService } from "../../src/main/onboarding-service";
const tempDirs: string[] = [];
async function createTempDir(prefix: string) {
const dir = await mkdtemp(join(tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
function markConnectorReady(db: AppDatabase, connectorId: "openai-codex") {
db.core.connectors.updateConnectorHealth({
id: connectorId,
status: "ready",
authState: "authenticated",
version: "test",
lastError: null,
});
}
function buildBootstrapInput(workspacePath: string, overrides: Partial<Parameters<ReturnType<typeof createOnboardingService>["bootstrapOnboarding"]>[0]> = {}) {
return {
companyName: "Atomic Onboarding Co",
companyDescription: "Verifies onboarding transaction boundaries",
workspacePath,
autoApproveHires: true,
reviewDeliverables: false,
executionMode: "ai_ceo",
goalTitle: "Create one atomic company",
goalDescription: "Every onboarding write commits together.",
connectorId: "openai-codex" as const,
connectorModel: "",
...overrides,
};
}
function expectNoOnboardingState(db: AppDatabase) {
expect(db.projection.snapshot.listSnapshot().companies).toHaveLength(0);
expect(db.core.settings.getCurrentCompanyId()).toBeNull();
expect(db.queryAll("select id from agents")).toHaveLength(0);
expect(db.queryAll("select id from goals")).toHaveLength(0);
expect(db.queryAll("select id from projects")).toHaveLength(0);
expect(db.queryAll("select id from tasks")).toHaveLength(0);
expect(db.queryAll("select id from runs")).toHaveLength(0);
}
describe("onboarding transaction", () => {
it("rolls back every persisted onboarding row when model validation rejects the selected model", async () => {
const profileDir = await createTempDir("agentcompany-onboarding-tx-model-");
const workspaceDir = await createTempDir("agentcompany-onboarding-tx-workspace-");
const db = new AppDatabase(profileDir);
db.init();
markConnectorReady(db, "openai-codex");
const definition = getConnectorDefinition("openai-codex");
const disabledModel = getModels(definition.provider as never)[0]?.id;
if (!disabledModel) {
throw new Error("openai-codex requires a model for onboarding transaction coverage.");
}
const service = createOnboardingService({
db,
seedDefaultAutomationRules: () => {},
publishDomainChanged: () => {},
createTaskRunForDeferredDispatch: () => ({
ok: true,
data: { runId: "run-1", dispatchAfterCommit: () => {} },
}),
});
expect(() => service.bootstrapOnboarding(buildBootstrapInput(workspaceDir, {
connectorModel: disabledModel,
connectorPoolSelections: [
{ connectorId: "openai-codex", disabledModels: [{ id: disabledModel, label: disabledModel }] },
],
}))).toThrow(`${definition.label} does not allow model "${disabledModel}".`);
expectNoOnboardingState(db);
db.close();
});
it("rolls back every persisted onboarding row and skips external side effects when initial run creation fails", async () => {
const profileDir = await createTempDir("agentcompany-onboarding-tx-run-");
const workspaceDir = await createTempDir("agentcompany-onboarding-tx-run-workspace-");
const db = new AppDatabase(profileDir);
db.init();
markConnectorReady(db, "openai-codex");
let publishCount = 0;
let scanCount = 0;
const service = createOnboardingService({
db,
seedDefaultAutomationRules: () => {},
publishDomainChanged: () => {
publishCount += 1;
},
scanAndAssignSkills: async () => {
scanCount += 1;
},
createTaskRunForDeferredDispatch: () => ({ ok: false, error: { code: "RUN_CREATE_FAILED", message: "run insert failed" } }),
});
expect(() => service.bootstrapOnboarding(buildBootstrapInput(workspaceDir))).toThrow("run insert failed");
expectNoOnboardingState(db);
expect(publishCount).toBe(0);
expect(scanCount).toBe(0);
db.close();
});
});