From c695fdb0f686673862ed5e8a091ee8907daeb97f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 17:24:31 -0400 Subject: [PATCH] Q106: a hire awaiting approval is not on staff until approved, and a declined hire leaves Co-Authored-By: Claude Opus 5.5 --- src/main/services/agent-admission.ts | 32 ++++++++++++++++ src/main/services/agent-tools.ts | 2 + src/main/services/approval-service.ts | 6 +++ src/main/services/chat-service.ts | 7 +++- src/main/services/org-governance-service.ts | 6 +-- src/main/services/organization-service.ts | 42 ++++++++------------- src/main/services/task-run-preparation.ts | 7 +++- tests/unit/chat-governance.test.ts | 2 +- tests/unit/execution-modes.test.ts | 40 ++++++++++++++++++++ tests/unit/profile-identity.test.ts | 4 +- tests/unit/staffing-limits.test.ts | 4 ++ 11 files changed, 115 insertions(+), 37 deletions(-) create mode 100644 src/main/services/agent-admission.ts diff --git a/src/main/services/agent-admission.ts b/src/main/services/agent-admission.ts new file mode 100644 index 00000000..40f501a1 --- /dev/null +++ b/src/main/services/agent-admission.ts @@ -0,0 +1,32 @@ +import type { DatabaseSync } from "node:sqlite"; +import { IdentityAccessService } from "./identity-access-service"; + +/** Puts an agent on staff: a member of every workspace channel, with the service identity its runs act as. */ +export function admitAgent(db: DatabaseSync, orgId: string, agentId: string, name: string, now: string): void { + const spaces = db.prepare("select id from chat_spaces where org_id = ? and visibility = ? and space_type <> ?") + .all(orgId, "workspace", "direct") as Array<{ id: string }>; + for (const space of spaces) { + db.prepare(` + insert into chat_memberships(space_id, principal_type, principal_id, role, mute_state) + values (?, ?, ?, ?, ?) + on conflict(space_id, principal_type, principal_id) do nothing + `).run(space.id, "agent", agentId, "member", "unmuted"); + } + new IdentityAccessService(db).createAgentServiceIdentity(orgId, agentId, name, now); +} + +/** Carries out a hire decision: an approved pending hire joins the staff; a declined hire, pending or already working, leaves it. */ +export function settleHire(db: DatabaseSync, orgId: string, agentId: string, approved: boolean): void { + const agent = db.prepare("select name, lifecycle from agents where id = ? and org_id = ?") + .get(agentId, orgId) as { name: string; lifecycle: string } | undefined; + if (!agent) throw new Error("The agent of this hire review was not found."); + if (approved) { + if (agent.lifecycle !== "pending_hire") return; + db.prepare("update agents set lifecycle = 'active' where id = ?").run(agentId); + admitAgent(db, orgId, agentId, agent.name, new Date().toISOString()); + console.info(`[hiring] ${agent.name} (${agentId}) joined after approval`); + return; + } + db.prepare("update agents set lifecycle = 'declined' where id = ?").run(agentId); + console.info(`[hiring] ${agent.name} (${agentId}) was declined (was ${agent.lifecycle})`); +} diff --git a/src/main/services/agent-tools.ts b/src/main/services/agent-tools.ts index 26f70957..abfd8b35 100644 --- a/src/main/services/agent-tools.ts +++ b/src/main/services/agent-tools.ts @@ -337,6 +337,8 @@ function hireAgent(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord) const { granted, unknown } = bindSkillSlugs(deps, ctx.orgId, agentId, requestedSkills); const skillNote = granted.length > 0 ? ` They carry: ${granted.join(", ")} (plus the automatic humanizer house style).` : ""; const unknownNote = unknown.length > 0 ? ` Unknown skills skipped: ${unknown.join(", ")} — run list_skills and grant_skill to fix.` : ""; + const pending = (deps.db.prepare("select lifecycle from agents where id = ?").get(agentId) as { lifecycle: string }).lifecycle === "pending_hire"; + if (pending) return `Requested ${name} as ${title} (${seniority}) in ${department} (agent_id=${agentId}); the hire awaits the board's approval, and they take no work until it is approved.${skillNote}${unknownNote}`; return `Hired ${name} as ${title} (${seniority}) in ${department}, reporting to you (agent_id=${agentId}).${skillNote}${unknownNote}`; } diff --git a/src/main/services/approval-service.ts b/src/main/services/approval-service.ts index 743939d5..3d36644a 100644 --- a/src/main/services/approval-service.ts +++ b/src/main/services/approval-service.ts @@ -3,6 +3,7 @@ import type { CreateApprovalInput, DecideApprovalInput } from "../../shared/comm import { APPROVAL_STATE, ROLE_KEY } from "../../shared/domain"; import { createId } from "../../shared/ids"; import { AuditService } from "./audit-service"; +import { settleHire } from "./agent-admission"; import { IdentityAccessService } from "./identity-access-service"; import { OrgGovernanceService } from "./org-governance-service"; @@ -86,6 +87,11 @@ export class ApprovalService { where id = ? and org_id = ? and state = ? `).run(input.state, input.decidedByEmployeeId, now, notes, input.approvalId, input.orgId, APPROVAL_STATE.PENDING); if (result.changes === 0) throw new Error("Pending approval was not found."); + const approval = this.db.prepare("select kind, source_entity_type, source_entity_id from approvals where id = ?") + .get(input.approvalId) as { kind: string; source_entity_type: string | null; source_entity_id: string | null }; + if (approval.kind === "agent_hire" && approval.source_entity_type === "agent" && approval.source_entity_id) { + settleHire(this.db, input.orgId, approval.source_entity_id, input.state === APPROVAL_STATE.APPROVED); + } this.audit.record({ orgId: input.orgId, entityType: "approval", diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index d6c33071..f31f34fc 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -472,7 +472,8 @@ export class ChatService { private resolveWorkspaceMembers(orgId: string): PrincipalRef[] { const employees = this.db.prepare("select id from employees where org_id = ?").all(orgId) as Array<{ id: string }>; - const agents = this.db.prepare("select id from agents where org_id = ?").all(orgId) as Array<{ id: string }>; + // Only agents on staff join: a hire awaiting approval, a declined hire or an erased agent does not. + const agents = this.db.prepare("select id from agents where org_id = ? and lifecycle = 'active'").all(orgId) as Array<{ id: string }>; return [ ...employees.map((employee) => ({ type: "employee" as const, id: employee.id })), ...agents.map((agent) => ({ type: "agent" as const, id: agent.id })), @@ -486,7 +487,9 @@ export class ChatService { members.push({ type: "employee", id }); } for (const id of new Set(input.memberAgentIds ?? [])) { - this.requireOrgEntity("agents", id, input.orgId, "Chat member"); + const agent = this.db.prepare("select name, lifecycle from agents where id = ? and org_id = ?").get(id, input.orgId) as { name: string; lifecycle: string } | undefined; + if (!agent) throw new Error("Chat member was not found."); + if (agent.lifecycle !== "active") throw new Error(`${agent.name} is not on staff, so cannot join a channel.`); members.push({ type: "agent", id }); } return members; diff --git a/src/main/services/org-governance-service.ts b/src/main/services/org-governance-service.ts index 6a8cd123..d065d614 100644 --- a/src/main/services/org-governance-service.ts +++ b/src/main/services/org-governance-service.ts @@ -53,8 +53,8 @@ export class OrgGovernanceService { const usage = this.db.prepare(` select count(*) as headcount, coalesce(sum(budget_cents), 0) as budget_cents from agents - where org_id = ? and department = ? and lifecycle = ? - `).get(input.orgId, departmentName, "active") as { headcount: number; budget_cents: number }; + where org_id = ? and department = ? and lifecycle in ('active', 'pending_hire') + `).get(input.orgId, departmentName) as { headcount: number; budget_cents: number }; if (usage.headcount >= policy.max_headcount) throw new Error("Department headcount limit has been reached."); if (usage.budget_cents + input.budgetCents > policy.monthly_budget_limit_cents) { throw new Error("Department monthly budget limit has been reached."); @@ -69,7 +69,7 @@ export class OrgGovernanceService { if (!policy) throw new Error("Hiring policy is required for department."); const others = this.db.prepare(` select count(*) as headcount, coalesce(sum(budget_cents), 0) as budget_cents - from agents where org_id = ? and department = ? and lifecycle = 'active' and id <> ? + from agents where org_id = ? and department = ? and lifecycle in ('active', 'pending_hire') and id <> ? `).get(input.orgId, input.departmentName, input.agentId) as { headcount: number; budget_cents: number }; const current = this.db.prepare("select department, budget_cents from agents where id = ? and org_id = ?").get(input.agentId, input.orgId) as { department: string; budget_cents: number }; const moving = current.department !== input.departmentName; diff --git a/src/main/services/organization-service.ts b/src/main/services/organization-service.ts index 1b7765e9..15b6a00e 100644 --- a/src/main/services/organization-service.ts +++ b/src/main/services/organization-service.ts @@ -1,29 +1,27 @@ import type { DatabaseSync } from "node:sqlite"; -import { RISK_LEVEL, TASK_STATUS, WRITING_MODE } from "../../shared/domain"; +import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS, WRITING_MODE } from "../../shared/domain"; import { createId } from "../../shared/ids"; import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, DeleteTaskInput, UpdateAgentInput, UpdateEmployeeProfileInput, UpdateTaskInput } from "../../shared/commands"; import { AuditService } from "./audit-service"; 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"; import { assertUsableMentionName } from "../../shared/mention-handles"; import { seedOrganization } from "./organization-seed"; import { TaskMutations } from "./task-mutations"; import { normalizeAvatarColor, normalizeAvatarImage } from "./avatar-values"; import { approvalDecision, autonomy } from "./org-approvals"; +import { admitAgent } from "./agent-admission"; export class OrganizationService { private readonly audit: AuditService; private readonly policy: PolicyService; - private readonly identityAccess: IdentityAccessService; private readonly governance: OrgGovernanceService; private readonly tasks: TaskMutations; constructor(private readonly db: DatabaseSync) { this.audit = new AuditService(db); this.policy = new PolicyService(db); - this.identityAccess = new IdentityAccessService(db); this.governance = new OrgGovernanceService(db); this.tasks = new TaskMutations(db, (input) => this.createTask(input)); } @@ -99,6 +97,10 @@ export class OrganizationService { const hiringPolicy = this.governance.enforceHiringPolicy(input); const id = createId("agt"); const now = new Date().toISOString(); + const autoApprove = autonomy(this.db, input.orgId).autoApproveHires; + const review = hiringPolicy.reviewRequired ? approvalDecision(this.db, input.orgId, autoApprove, now) : null; + // A hire waiting for review is not on staff: no runs, no channels, no service identity until approved. + const waitsForReview = review?.state === APPROVAL_STATE.PENDING; this.db.prepare(` insert into agents( id, org_id, name, role, department, model_provider_id, model_id, @@ -121,7 +123,7 @@ export class OrganizationService { input.workspaceId ?? null, input.heartbeatEnabled === true ? 1 : 0, input.heartbeatIntervalSec ?? 0, - "active", + waitsForReview ? "pending_hire" : "active", now, ); this.audit.record({ @@ -133,12 +135,10 @@ export class OrganizationService { action: "created", payload: { role: input.role, department: hiringPolicy.departmentName }, }); - this.addAgentToWorkspaceSpaces(input.orgId, id); - this.identityAccess.createAgentServiceIdentity(input.orgId, id, input.name, now); - if (hiringPolicy.reviewRequired) { + if (waitsForReview) console.info(`[hiring] ${input.name.trim()} (${id}) awaits hire approval`); + else admitAgent(this.db, input.orgId, id, input.name, now); + if (review) { const approvalId = createId("app"); - const autoApprove = autonomy(this.db, input.orgId).autoApproveHires; - const decision = approvalDecision(this.db, input.orgId, autoApprove, now); this.db.prepare(` insert into approvals( id, org_id, kind, title, details, risk, requested_by_type, @@ -155,10 +155,10 @@ export class OrganizationService { null, "agent", id, - decision.state, - decision.decidedBy, + review.state, + review.decidedBy, now, - decision.decidedAt, + review.decidedAt, ); this.settleApproval(input.orgId, approvalId, "agent_hire", RISK_LEVEL.MEDIUM, autoApprove); } @@ -341,22 +341,10 @@ export class OrganizationService { private assertAgentNameAvailable(orgId: string, name: string, exceptAgentId: string | null): void { const clash = this.db.prepare(` select id from agents - where org_id = ? and lifecycle = 'active' and lower(name) = lower(?) and (? is null or id <> ?) + where org_id = ? and lifecycle in ('active', 'pending_hire') and lower(name) = lower(?) and (? is null or id <> ?) limit 1 `).get(orgId, name.trim(), exceptAgentId, exceptAgentId) as { id: string } | undefined; - if (clash) throw new Error(`An active agent named "${name.trim()}" already exists. Pick a different name.`); - } - - private addAgentToWorkspaceSpaces(orgId: string, agentId: string): void { - const spaces = this.db.prepare("select id from chat_spaces where org_id = ? and visibility = ? and space_type <> ?") - .all(orgId, "workspace", "direct") as Array<{ id: string }>; - for (const space of spaces) { - this.db.prepare(` - insert into chat_memberships(space_id, principal_type, principal_id, role, mute_state) - values (?, ?, ?, ?, ?) - on conflict(space_id, principal_type, principal_id) do nothing - `).run(space.id, "agent", agentId, "member", "unmuted"); - } + if (clash) throw new Error(`An agent named "${name.trim()}" is already on staff or awaiting approval. Pick a different name.`); } createGoal(input: CreateGoalInput): string { diff --git a/src/main/services/task-run-preparation.ts b/src/main/services/task-run-preparation.ts index 61be5993..8b7facbe 100644 --- a/src/main/services/task-run-preparation.ts +++ b/src/main/services/task-run-preparation.ts @@ -25,11 +25,14 @@ export function requireTask(db: DatabaseSync, orgId: string, taskId: string): Ta export function requireAssignedAgent(db: DatabaseSync, orgId: string, task: TaskExecutionTaskRow): TaskExecutionAgentRow { if (!task.assignee_agent_id) throw new Error("Task requires an assigned agent."); const agent = db.prepare(` - select id, name, role, department, title, role_level, model_provider_id, model_id, manager_agent_id + select id, name, role, department, title, role_level, model_provider_id, model_id, manager_agent_id, lifecycle from agents where id = ? and org_id = ? - `).get(task.assignee_agent_id, orgId) as TaskExecutionAgentRow | undefined; + `).get(task.assignee_agent_id, orgId) as (TaskExecutionAgentRow & { lifecycle: string }) | undefined; if (!agent) throw new Error("Task assignee was not found."); + if (agent.lifecycle === "pending_hire") throw new Error(`${agent.name} is not hired yet: the hire awaits approval.`); + if (agent.lifecycle === "declined") throw new Error(`${agent.name}'s hire was declined; reassign the task.`); + if (agent.lifecycle !== "active") throw new Error(`${agent.name} is no longer on staff; reassign the task.`); return agent; } diff --git a/tests/unit/chat-governance.test.ts b/tests/unit/chat-governance.test.ts index bed66a50..c64f9114 100644 --- a/tests/unit/chat-governance.test.ts +++ b/tests/unit/chat-governance.test.ts @@ -771,7 +771,7 @@ describe("chat governance", () => { organization.createAgent({ orgId, name: "Ada", role: "Reviewer", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 10000, managerAgentId: null }); // The front door refuses duplicate names outright... expect(() => organization.createAgent({ orgId, name: "ada", role: "Release lead", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 10000, managerAgentId: null })) - .toThrow("already exists"); + .toThrow("is already on staff or awaiting approval"); // ...but a legacy database may already carry duplicates; mentions must // still refuse to guess between them. const secondId = organization.createAgent({ orgId, name: "Ada Backup", role: "Release lead", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 10000, managerAgentId: null }); diff --git a/tests/unit/execution-modes.test.ts b/tests/unit/execution-modes.test.ts index 8cd9101e..b0963445 100644 --- a/tests/unit/execution-modes.test.ts +++ b/tests/unit/execution-modes.test.ts @@ -7,6 +7,9 @@ import { DatabaseConnection } from "../../src/main/database/connection"; import { OrganizationService } from "../../src/main/services/organization-service"; import { ChatService } from "../../src/main/services/chat-service"; import { dispatchAgentTool, type AgentToolContext } from "../../src/main/services/agent-tools"; +import { ApprovalService } from "../../src/main/services/approval-service"; +import { requireAssignedAgent } from "../../src/main/services/task-run-preparation"; +import type { TaskExecutionTaskRow } from "../../src/main/services/task-prompts"; function call(name: string, args: Record) { return { id: `call_${randomUUID()}`, name, arguments: args }; @@ -113,4 +116,41 @@ describe("execution modes", () => { ).get(ceoMode.orgId) as { state: string }; expect(cleared.state).toBe("approved"); }); + + test("a hire held for review is not on staff until approved, and a declined one never is", () => { + const h = harness("ai_cofounder"); + const db = h.connection.db; + const providerId = (db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(h.orgId) as { id: string }).id; + const hire = (name: string) => h.connection.transaction(() => h.deps.organization.createAgent({ + orgId: h.orgId, name, role: "Designer", department: "Product", + modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 600000, managerAgentId: h.ceoId, + })); + const lifecycle = (id: string) => (db.prepare("select lifecycle from agents where id = ?").get(id) as { lifecycle: string }).lifecycle; + const identities = (id: string) => (db.prepare("select count(*) as n from service_identities where agent_id = ?").get(id) as { n: number }).n; + const memberships = (id: string) => (db.prepare("select count(*) as n from chat_memberships where principal_type = 'agent' and principal_id = ?").get(id) as { n: number }).n; + const runAs = (id: string) => requireAssignedAgent(db, h.orgId, { assignee_agent_id: id } as TaskExecutionTaskRow); + const approvalOf = (name: string) => (db.prepare("select id from approvals where org_id = ? and kind = 'agent_hire' and title = ?").get(h.orgId, `Review hire: ${name}`) as { id: string }).id; + const employeeId = (db.prepare("select id from employees where org_id = ?").get(h.orgId) as { id: string }).id; + const approvals = new ApprovalService(db); + + const ava = hire("Ava"); + expect([lifecycle(ava), identities(ava), memberships(ava)]).toEqual(["pending_hire", 0, 0]); + expect(() => runAs(ava)).toThrow("Ava is not hired yet: the hire awaits approval."); + expect(() => hire("ava")).toThrow('An agent named "ava" is already on staff or awaiting approval. Pick a different name.'); + const channel = h.connection.transaction(() => h.deps.chat.createSpace({ orgId: h.orgId, name: "design", topic: "t", spaceType: "project", visibility: "workspace" })); + expect(memberships(ava)).toBe(0); + expect(() => h.connection.transaction(() => h.deps.chat.createSpace({ orgId: h.orgId, name: "ava-room", topic: "t", spaceType: "project", visibility: "restricted", memberAgentIds: [ava] }))) + .toThrow("Ava is not on staff, so cannot join a channel."); + expect(channel).toBeTruthy(); + h.connection.transaction(() => approvals.decide({ orgId: h.orgId, approvalId: approvalOf("Ava"), state: "approved", decidedByEmployeeId: employeeId })); + expect(lifecycle(ava)).toBe("active"); + expect(identities(ava)).toBe(1); + expect(memberships(ava)).toBeGreaterThan(0); + expect(runAs(ava).id).toBe(ava); + + const bo = hire("Bo"); + h.connection.transaction(() => approvals.decide({ orgId: h.orgId, approvalId: approvalOf("Bo"), state: "declined", decidedByEmployeeId: employeeId })); + expect([lifecycle(bo), identities(bo), memberships(bo)]).toEqual(["declined", 0, 0]); + expect(() => runAs(bo)).toThrow("Bo's hire was declined; reassign the task."); + }); }); diff --git a/tests/unit/profile-identity.test.ts b/tests/unit/profile-identity.test.ts index 7d444a16..1e464995 100644 --- a/tests/unit/profile-identity.test.ts +++ b/tests/unit/profile-identity.test.ts @@ -74,12 +74,12 @@ describe("identity editing", () => { expect(() => h.organization.createAgent({ orgId: h.orgId, name: "dana lee", role: "Designer", department: "Design", modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: h.agentId, - })).toThrow("already exists"); + })).toThrow("is already on staff or awaiting approval"); // Renaming the CEO onto that name is equally ambiguous. expect(() => h.organization.updateAgent({ orgId: h.orgId, agentId: h.agentId, name: "Dana Lee", role: "CEO", title: "CEO", roleLevel: "c_suite", department: "Executive", budgetCents: 25000, modelProviderId: null, modelId: null, - })).toThrow("already exists"); + })).toThrow("is already on staff or awaiting approval"); // Saving without changing the name stays legal. h.connection.transaction(() => h.organization.updateAgent({ orgId: h.orgId, agentId: h.agentId, name: "CEO", role: "CEO", title: "CEO", diff --git a/tests/unit/staffing-limits.test.ts b/tests/unit/staffing-limits.test.ts index 8f68d8d6..daea6ab6 100644 --- a/tests/unit/staffing-limits.test.ts +++ b/tests/unit/staffing-limits.test.ts @@ -31,6 +31,10 @@ describe("staffing limits on edits", () => { connection.db.prepare("update hiring_policies set monthly_budget_limit_cents = 100 where org_id = ? and department_name = 'Sales'").run(orgId); edit("Sales", 2000); expect(connection.db.prepare("select department, budget_cents from agents where id = ?").get(sam)).toEqual({ department: "Sales", budget_cents: 2000 }); + // A hire awaiting approval already holds its place in the department. + connection.db.prepare("update agents set department = 'Sales' where name = 'Dee'").run(); + connection.db.prepare("insert into agents(id, org_id, name, role, department, budget_cents, lifecycle, created_at) values ('agt_pending', ?, 'Pat', 'Staff', 'Design', 1000, 'pending_hire', ?)").run(orgId, new Date().toISOString()); + expect(() => edit("Design", 1000)).toThrow("Design is at its headcount limit (1)."); connection.close(); }); });