Q106: a hire awaiting approval is not on staff until approved, and a declined hire leaves

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 17:24:31 -04:00
1 parent 170b977bf8
commit c695fdb0f6
11 files changed
+115 -37

No files matched your search

+32
View File
@@ -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})`);
}
+2
View File
@@ -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}`;
}
+6
View File
@@ -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",
+5 -2
View File
@@ -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;
+3 -3
View File
@@ -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;
+15 -27
View File
@@ -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 {
+5 -2
View File
@@ -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;
}
+1 -1
View File
@@ -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 });
+40
View File
@@ -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<string, unknown>) {
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.");
});
});
+2 -2
View File
@@ -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",
+4
View File
@@ -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();
});
});