[codex] prevent invalid agents from receiving assignments and runs (#7663)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The control plane owns agent lifecycle, issue assignment, routine dispatch, heartbeat wakeups, and recovery paths > - Terminated, paused, pending-approval, or otherwise invalid agents should not receive new work or new execution attempts > - The old behavior left eligibility checks spread across routes and services, so assignment and run paths could drift apart > - This pull request centralizes agent lifecycle eligibility and applies it consistently to assignment, invocation, routines, recovery, and UI affordances > - The benefit is safer autonomy: terminated agents stay paused, invalid org-chain agents are surfaced, and active agents keep receiving valid work ## Linked Issues or Issue Description Refs #5103 Related: #1864 Bug fix context: - What happened: agent assignment and heartbeat/run paths did not share one eligibility contract, so invalid lifecycle states could still be considered in some paths. - Expected behavior: terminated agents must never receive new assignments or heartbeat runs, and paused or otherwise invalid agents should be treated as non-invokable consistently. - Steps to reproduce: create or select an agent in an invalid lifecycle state, then attempt assignment, routine dispatch, or heartbeat/recovery wake paths. - Paperclip version/commit: fixed on top of `paperclipai/paperclip` `master` at the PR base. - Deployment mode: applies to the server control plane in local and authenticated deployments. ## What Changed - Added shared agent lifecycle eligibility helpers and exported the related shared types. - Centralized server-side assignability and invokability checks for issue assignment, agent routes, heartbeat dispatch, routines, recovery, and liveness logic. - Hardened issue assignment so invalid assignees are rejected instead of queued for work. - Hardened heartbeat/routine/recovery paths so terminated and otherwise invalid agents are not woken for new runs. - Updated board UI affordances to disable invalid agent actions and surface org-chain warnings where relevant. - Added targeted shared, server, and UI tests for the new eligibility behavior. ## Verification - `pnpm exec vitest run packages/shared/src/agent-eligibility.test.ts server/src/__tests__/agent-invokability.test.ts server/src/__tests__/heartbeat-archived-company-guard.test.ts server/src/__tests__/issue-liveness.test.ts server/src/__tests__/issues-service.test.ts server/src/__tests__/routines-service.test.ts ui/src/lib/company-members.test.ts ui/src/pages/Agents.test.tsx` — 8 files, 144 tests passed. - `pnpm --filter @paperclipai/shared typecheck && pnpm --filter @paperclipai/server typecheck && pnpm --filter @paperclipai/ui typecheck` — passed. - Checked the PR diff does not include `pnpm-lock.yaml` or `.github/workflows` changes. - Checked `ROADMAP.md`; this is a targeted control-plane safety fix and does not duplicate a planned core feature. - Searched GitHub for duplicate or related PRs/issues; closest related items are linked above. - CI and Greptile verification are pending on the opened PR and will be followed up before requesting merge. ## Risks Low to moderate risk. The intended behavioral shift is that invalid agents are refused earlier and more consistently, which could expose existing data with paused, pending, terminated, or broken org-chain assignees. The added tests cover the critical assignment, heartbeat, routine, recovery, shared helper, and UI paths. No database migrations are included. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI GPT-5 Codex via the Paperclip `codex_local` adapter, with shell/git/GitHub CLI tool use. Reasoning mode and context window are managed by the adapter runtime and not exposed in this environment. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots (not applicable: no design screenshots requested; UI behavior is covered by tests) - [x] I have updated relevant documentation to reflect my changes (not applicable: no user-facing command or schema docs changed) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green (pending CI) - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups (pending Greptile) - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,158 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getAgentOrgChainHealth,
|
||||||
|
getAgentWorkEligibility,
|
||||||
|
isAgentAssignableToWork,
|
||||||
|
isAgentInvokable,
|
||||||
|
type AgentEligibilityAgent,
|
||||||
|
} from "./agent-eligibility.js";
|
||||||
|
|
||||||
|
const companyId = "company-1";
|
||||||
|
|
||||||
|
function agent(overrides: Partial<AgentEligibilityAgent> = {}): AgentEligibilityAgent {
|
||||||
|
return {
|
||||||
|
id: "agent-1",
|
||||||
|
companyId,
|
||||||
|
name: "Coder",
|
||||||
|
status: "active",
|
||||||
|
reportsTo: "manager-1",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("agent work eligibility", () => {
|
||||||
|
it("allows healthy active agents to accept work and be invoked", () => {
|
||||||
|
const agents = [
|
||||||
|
agent(),
|
||||||
|
agent({ id: "manager-1", name: "CTO", status: "active", reportsTo: null }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(isAgentAssignableToWork({ agent: agents[0]!, agents })).toBe(true);
|
||||||
|
expect(isAgentInvokable({ agent: agents[0]!, agents })).toBe(true);
|
||||||
|
expect(getAgentWorkEligibility({ agent: agents[0]!, agents })).toMatchObject({
|
||||||
|
assignable: true,
|
||||||
|
invokable: true,
|
||||||
|
assignabilityReason: "eligible",
|
||||||
|
invokabilityReason: "eligible",
|
||||||
|
orgChainHealth: { status: "healthy" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks terminated and pending approval agents from assignment and invocation", () => {
|
||||||
|
const manager = agent({ id: "manager-1", name: "CTO", status: "active", reportsTo: null });
|
||||||
|
|
||||||
|
for (const status of ["terminated", "pending_approval"]) {
|
||||||
|
const target = agent({ status });
|
||||||
|
const eligibility = getAgentWorkEligibility({ agent: target, agents: [target, manager] });
|
||||||
|
|
||||||
|
expect(eligibility.assignable).toBe(false);
|
||||||
|
expect(eligibility.invokable).toBe(false);
|
||||||
|
expect(eligibility.assignabilityReason).toBe(status);
|
||||||
|
expect(eligibility.invokabilityReason).toBe(status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows paused agents to keep assignments but blocks invocation", () => {
|
||||||
|
const target = agent({ status: "paused" });
|
||||||
|
const manager = agent({ id: "manager-1", name: "CTO", status: "active", reportsTo: null });
|
||||||
|
|
||||||
|
expect(getAgentWorkEligibility({ agent: target, agents: [target, manager] })).toMatchObject({
|
||||||
|
assignable: true,
|
||||||
|
invokable: false,
|
||||||
|
assignabilityReason: "eligible",
|
||||||
|
invokabilityReason: "paused",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports unknown lifecycle statuses explicitly", () => {
|
||||||
|
const target = agent({ status: "sabbatical" });
|
||||||
|
const manager = agent({ id: "manager-1", name: "CTO", status: "active", reportsTo: null });
|
||||||
|
|
||||||
|
expect(getAgentWorkEligibility({ agent: target, agents: [target, manager] })).toMatchObject({
|
||||||
|
assignable: false,
|
||||||
|
invokable: false,
|
||||||
|
assignabilityReason: "unknown_status",
|
||||||
|
invokabilityReason: "unknown_status",
|
||||||
|
orgChainHealth: { status: "healthy" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks active descendants of terminated ancestors and reports repair details", () => {
|
||||||
|
const target = agent({ id: "qa-2", name: "QA 2", status: "active", reportsTo: "cto-2" });
|
||||||
|
const terminatedManager = agent({
|
||||||
|
id: "cto-2",
|
||||||
|
name: "CTO 2",
|
||||||
|
status: "terminated",
|
||||||
|
reportsTo: "ceo-2",
|
||||||
|
});
|
||||||
|
const terminatedRoot = agent({
|
||||||
|
id: "ceo-2",
|
||||||
|
name: "CEO 2",
|
||||||
|
status: "terminated",
|
||||||
|
reportsTo: null,
|
||||||
|
});
|
||||||
|
const agents = [target, terminatedManager, terminatedRoot];
|
||||||
|
|
||||||
|
const health = getAgentOrgChainHealth({ agent: target, agents });
|
||||||
|
expect(health.status).toBe("invalid_org_chain");
|
||||||
|
expect(health.reason).toBe("terminated_ancestor");
|
||||||
|
expect(health.fullChain).toEqual([
|
||||||
|
expect.objectContaining({ id: "qa-2", name: "QA 2", relation: "self", depth: 0 }),
|
||||||
|
expect.objectContaining({ id: "cto-2", name: "CTO 2", status: "terminated", relation: "ancestor", depth: 1 }),
|
||||||
|
expect.objectContaining({ id: "ceo-2", name: "CEO 2", status: "terminated", relation: "ancestor", depth: 2 }),
|
||||||
|
]);
|
||||||
|
expect(health.firstInvalidAncestor).toEqual({ id: "cto-2", name: "CTO 2", status: "terminated" });
|
||||||
|
expect(health.invalidAncestors).toEqual([
|
||||||
|
{ id: "cto-2", name: "CTO 2", status: "terminated" },
|
||||||
|
{ id: "ceo-2", name: "CEO 2", status: "terminated" },
|
||||||
|
]);
|
||||||
|
expect(health.repairGuidance).toContain("QA 2 reports through terminated ancestor CTO 2");
|
||||||
|
|
||||||
|
const eligibility = getAgentWorkEligibility({ agent: target, agents });
|
||||||
|
expect(eligibility.assignable).toBe(false);
|
||||||
|
expect(eligibility.invokable).toBe(false);
|
||||||
|
expect(eligibility.assignabilityReason).toBe("invalid_org_chain");
|
||||||
|
expect(eligibility.invokabilityReason).toBe("invalid_org_chain");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks agents whose manager is missing from the company org", () => {
|
||||||
|
const target = agent({ id: "qa-3", name: "QA 3", status: "active", reportsTo: "missing-manager" });
|
||||||
|
|
||||||
|
const health = getAgentOrgChainHealth({ agent: target, agents: [target] });
|
||||||
|
expect(health.status).toBe("invalid_org_chain");
|
||||||
|
expect(health.reason).toBe("missing_manager");
|
||||||
|
expect(health.fullChain).toEqual([
|
||||||
|
expect.objectContaining({ id: "qa-3", relation: "self", depth: 0 }),
|
||||||
|
expect.objectContaining({ id: "missing-manager", status: "missing", relation: "ancestor", depth: 1 }),
|
||||||
|
]);
|
||||||
|
expect(health.repairGuidance).toContain("QA 3 reports to missing manager missing-manager");
|
||||||
|
|
||||||
|
const eligibility = getAgentWorkEligibility({ agent: target, agents: [target] });
|
||||||
|
expect(eligibility.assignable).toBe(false);
|
||||||
|
expect(eligibility.invokable).toBe(false);
|
||||||
|
expect(eligibility.assignabilityReason).toBe("invalid_org_chain");
|
||||||
|
expect(eligibility.invokabilityReason).toBe("invalid_org_chain");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks agents with reporting cycles", () => {
|
||||||
|
const target = agent({ id: "qa-4", name: "QA 4", status: "active", reportsTo: "cto-4" });
|
||||||
|
const manager = agent({ id: "cto-4", name: "CTO 4", status: "active", reportsTo: "qa-4" });
|
||||||
|
const agents = [target, manager];
|
||||||
|
|
||||||
|
const health = getAgentOrgChainHealth({ agent: target, agents });
|
||||||
|
expect(health.status).toBe("invalid_org_chain");
|
||||||
|
expect(health.reason).toBe("cycle");
|
||||||
|
expect(health.fullChain).toEqual([
|
||||||
|
expect.objectContaining({ id: "qa-4", relation: "self", depth: 0 }),
|
||||||
|
expect.objectContaining({ id: "cto-4", relation: "ancestor", depth: 1 }),
|
||||||
|
expect.objectContaining({ id: "qa-4", status: "cycle", relation: "ancestor", depth: 2 }),
|
||||||
|
]);
|
||||||
|
expect(health.repairGuidance).toContain("QA 4 has a cycle in its reporting chain");
|
||||||
|
|
||||||
|
const eligibility = getAgentWorkEligibility({ agent: target, agents });
|
||||||
|
expect(eligibility.assignable).toBe(false);
|
||||||
|
expect(eligibility.invokable).toBe(false);
|
||||||
|
expect(eligibility.assignabilityReason).toBe("invalid_org_chain");
|
||||||
|
expect(eligibility.invokabilityReason).toBe("invalid_org_chain");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import type { AgentStatus } from "./constants.js";
|
||||||
|
|
||||||
|
export type AgentEligibilityLifecycleReason =
|
||||||
|
| "eligible"
|
||||||
|
| "terminated"
|
||||||
|
| "pending_approval"
|
||||||
|
| "paused"
|
||||||
|
| "invalid_org_chain"
|
||||||
|
| "unknown_status";
|
||||||
|
|
||||||
|
export interface AgentEligibilityAgent {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
name: string;
|
||||||
|
status: AgentStatus | string;
|
||||||
|
reportsTo?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentOrgChainEntry {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
name: string;
|
||||||
|
status: AgentStatus | string;
|
||||||
|
reportsTo: string | null;
|
||||||
|
depth: number;
|
||||||
|
relation: "self" | "ancestor";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentInvalidOrgChainAncestor {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: AgentStatus | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AgentOrgChainInvalidReason =
|
||||||
|
| "healthy"
|
||||||
|
| "terminated_ancestor"
|
||||||
|
| "missing_manager"
|
||||||
|
| "cycle";
|
||||||
|
|
||||||
|
export interface AgentOrgChainHealth {
|
||||||
|
status: "healthy" | "invalid_org_chain";
|
||||||
|
reason: AgentOrgChainInvalidReason;
|
||||||
|
fullChain: AgentOrgChainEntry[];
|
||||||
|
firstInvalidAncestor: AgentInvalidOrgChainAncestor | null;
|
||||||
|
invalidAncestors: AgentInvalidOrgChainAncestor[];
|
||||||
|
repairGuidance: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentWorkEligibility {
|
||||||
|
assignable: boolean;
|
||||||
|
invokable: boolean;
|
||||||
|
assignabilityReason: AgentEligibilityLifecycleReason;
|
||||||
|
invokabilityReason: AgentEligibilityLifecycleReason;
|
||||||
|
orgChainHealth: AgentOrgChainHealth;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NON_ASSIGNABLE_AGENT_STATUSES = new Set<string>(["terminated", "pending_approval"]);
|
||||||
|
const NON_INVOKABLE_AGENT_STATUSES = new Set<string>(["terminated", "pending_approval", "paused"]);
|
||||||
|
const ASSIGNABLE_AGENT_STATUSES = new Set<string>(["active", "paused", "idle", "running", "error"]);
|
||||||
|
const INVOKABLE_AGENT_STATUSES = new Set<string>(["active", "idle", "running", "error"]);
|
||||||
|
|
||||||
|
export function isAgentStatusAssignableToWork(status: AgentStatus | string): boolean {
|
||||||
|
return ASSIGNABLE_AGENT_STATUSES.has(status) && !NON_ASSIGNABLE_AGENT_STATUSES.has(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAgentStatusInvokable(status: AgentStatus | string): boolean {
|
||||||
|
return INVOKABLE_AGENT_STATUSES.has(status) && !NON_INVOKABLE_AGENT_STATUSES.has(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chainEntry(
|
||||||
|
agent: AgentEligibilityAgent,
|
||||||
|
depth: number,
|
||||||
|
relation: AgentOrgChainEntry["relation"],
|
||||||
|
): AgentOrgChainEntry {
|
||||||
|
return {
|
||||||
|
id: agent.id,
|
||||||
|
companyId: agent.companyId,
|
||||||
|
name: agent.name,
|
||||||
|
status: agent.status,
|
||||||
|
reportsTo: agent.reportsTo ?? null,
|
||||||
|
depth,
|
||||||
|
relation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidAncestor(agent: AgentEligibilityAgent): AgentInvalidOrgChainAncestor {
|
||||||
|
return {
|
||||||
|
id: agent.id,
|
||||||
|
name: agent.name,
|
||||||
|
status: agent.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRepairGuidance(
|
||||||
|
agent: AgentEligibilityAgent,
|
||||||
|
firstInvalidAncestor: AgentInvalidOrgChainAncestor,
|
||||||
|
): string {
|
||||||
|
if (firstInvalidAncestor.status === "missing") {
|
||||||
|
return [
|
||||||
|
`${agent.name} reports to missing manager ${firstInvalidAncestor.id}.`,
|
||||||
|
`Reassign ${agent.name} or the nearest affected ancestor under an active manager/root, or explicitly pause or terminate the invalid subtree before assigning work or starting runs.`,
|
||||||
|
].join(" ");
|
||||||
|
}
|
||||||
|
if (firstInvalidAncestor.status === "cycle") {
|
||||||
|
return [
|
||||||
|
`${agent.name} has a cycle in its reporting chain at ${firstInvalidAncestor.name}.`,
|
||||||
|
`Break the cycle by assigning one affected agent to an active manager/root, or explicitly pause or terminate the invalid subtree before assigning work or starting runs.`,
|
||||||
|
].join(" ");
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
`${agent.name} reports through terminated ancestor ${firstInvalidAncestor.name}.`,
|
||||||
|
`Reassign ${agent.name} or the nearest affected ancestor under an active manager/root, or explicitly pause or terminate the invalid subtree before assigning work or starting runs.`,
|
||||||
|
].join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAgentOrgChainHealth(input: {
|
||||||
|
agent: AgentEligibilityAgent;
|
||||||
|
agents: AgentEligibilityAgent[];
|
||||||
|
}): AgentOrgChainHealth {
|
||||||
|
const byId = new Map(input.agents.map((agent) => [agent.id, agent]));
|
||||||
|
const fullChain: AgentOrgChainEntry[] = [chainEntry(input.agent, 0, "self")];
|
||||||
|
const invalidAncestors: AgentInvalidOrgChainAncestor[] = [];
|
||||||
|
const seen = new Set<string>([input.agent.id]);
|
||||||
|
|
||||||
|
let current = input.agent;
|
||||||
|
let depth = 1;
|
||||||
|
while (current.reportsTo) {
|
||||||
|
if (seen.has(current.reportsTo)) {
|
||||||
|
const cycleAgent = byId.get(current.reportsTo);
|
||||||
|
const invalid = {
|
||||||
|
id: current.reportsTo,
|
||||||
|
name: cycleAgent?.name ?? current.reportsTo,
|
||||||
|
status: "cycle",
|
||||||
|
};
|
||||||
|
fullChain.push({
|
||||||
|
id: invalid.id,
|
||||||
|
companyId: input.agent.companyId,
|
||||||
|
name: invalid.name,
|
||||||
|
status: invalid.status,
|
||||||
|
reportsTo: cycleAgent?.reportsTo ?? null,
|
||||||
|
depth,
|
||||||
|
relation: "ancestor",
|
||||||
|
});
|
||||||
|
invalidAncestors.push(invalid);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
seen.add(current.reportsTo);
|
||||||
|
|
||||||
|
const parent = byId.get(current.reportsTo);
|
||||||
|
if (!parent || parent.companyId !== input.agent.companyId) {
|
||||||
|
const invalid = {
|
||||||
|
id: current.reportsTo,
|
||||||
|
name: current.reportsTo,
|
||||||
|
status: "missing",
|
||||||
|
};
|
||||||
|
fullChain.push({
|
||||||
|
id: invalid.id,
|
||||||
|
companyId: input.agent.companyId,
|
||||||
|
name: invalid.name,
|
||||||
|
status: invalid.status,
|
||||||
|
reportsTo: null,
|
||||||
|
depth,
|
||||||
|
relation: "ancestor",
|
||||||
|
});
|
||||||
|
invalidAncestors.push(invalid);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
fullChain.push(chainEntry(parent, depth, "ancestor"));
|
||||||
|
if (parent.status === "terminated") {
|
||||||
|
invalidAncestors.push(invalidAncestor(parent));
|
||||||
|
}
|
||||||
|
|
||||||
|
current = parent;
|
||||||
|
depth += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstInvalidAncestor = invalidAncestors[0] ?? null;
|
||||||
|
return {
|
||||||
|
status: firstInvalidAncestor ? "invalid_org_chain" : "healthy",
|
||||||
|
reason: firstInvalidAncestor
|
||||||
|
? firstInvalidAncestor.status === "missing"
|
||||||
|
? "missing_manager"
|
||||||
|
: firstInvalidAncestor.status === "cycle"
|
||||||
|
? "cycle"
|
||||||
|
: "terminated_ancestor"
|
||||||
|
: "healthy",
|
||||||
|
fullChain,
|
||||||
|
firstInvalidAncestor,
|
||||||
|
invalidAncestors,
|
||||||
|
repairGuidance: firstInvalidAncestor
|
||||||
|
? buildRepairGuidance(input.agent, firstInvalidAncestor)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAgentWorkEligibility(input: {
|
||||||
|
agent: AgentEligibilityAgent;
|
||||||
|
agents: AgentEligibilityAgent[];
|
||||||
|
}): AgentWorkEligibility {
|
||||||
|
const orgChainHealth = getAgentOrgChainHealth(input);
|
||||||
|
const assignabilityReason: AgentEligibilityLifecycleReason = !isAgentStatusAssignableToWork(input.agent.status)
|
||||||
|
? input.agent.status === "terminated"
|
||||||
|
? "terminated"
|
||||||
|
: input.agent.status === "pending_approval"
|
||||||
|
? "pending_approval"
|
||||||
|
: "unknown_status"
|
||||||
|
: orgChainHealth.status === "invalid_org_chain"
|
||||||
|
? "invalid_org_chain"
|
||||||
|
: "eligible";
|
||||||
|
const invokabilityReason: AgentEligibilityLifecycleReason = !isAgentStatusInvokable(input.agent.status)
|
||||||
|
? input.agent.status === "terminated"
|
||||||
|
? "terminated"
|
||||||
|
: input.agent.status === "pending_approval"
|
||||||
|
? "pending_approval"
|
||||||
|
: input.agent.status === "paused"
|
||||||
|
? "paused"
|
||||||
|
: "unknown_status"
|
||||||
|
: orgChainHealth.status === "invalid_org_chain"
|
||||||
|
? "invalid_org_chain"
|
||||||
|
: "eligible";
|
||||||
|
|
||||||
|
return {
|
||||||
|
assignable: assignabilityReason === "eligible",
|
||||||
|
invokable: invokabilityReason === "eligible",
|
||||||
|
assignabilityReason,
|
||||||
|
invokabilityReason,
|
||||||
|
orgChainHealth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAgentAssignableToWork(input: {
|
||||||
|
agent: AgentEligibilityAgent;
|
||||||
|
agents: AgentEligibilityAgent[];
|
||||||
|
}): boolean {
|
||||||
|
return getAgentWorkEligibility(input).assignable;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAgentInvokable(input: {
|
||||||
|
agent: AgentEligibilityAgent;
|
||||||
|
agents: AgentEligibilityAgent[];
|
||||||
|
}): boolean {
|
||||||
|
return getAgentWorkEligibility(input).invokable;
|
||||||
|
}
|
||||||
@@ -1,4 +1,19 @@
|
|||||||
export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js";
|
export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js";
|
||||||
|
export {
|
||||||
|
getAgentOrgChainHealth,
|
||||||
|
getAgentWorkEligibility,
|
||||||
|
isAgentAssignableToWork,
|
||||||
|
isAgentInvokable,
|
||||||
|
isAgentStatusAssignableToWork,
|
||||||
|
isAgentStatusInvokable,
|
||||||
|
type AgentEligibilityAgent,
|
||||||
|
type AgentEligibilityLifecycleReason,
|
||||||
|
type AgentInvalidOrgChainAncestor,
|
||||||
|
type AgentOrgChainEntry,
|
||||||
|
type AgentOrgChainHealth,
|
||||||
|
type AgentOrgChainInvalidReason,
|
||||||
|
type AgentWorkEligibility,
|
||||||
|
} from "./agent-eligibility.js";
|
||||||
export {
|
export {
|
||||||
asBoolean,
|
asBoolean,
|
||||||
asString,
|
asString,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
TrustAuthorizationPolicy,
|
TrustAuthorizationPolicy,
|
||||||
TrustPreset,
|
TrustPreset,
|
||||||
} from "../trust-policy.js";
|
} from "../trust-policy.js";
|
||||||
|
import type { AgentOrgChainHealth } from "../agent-eligibility.js";
|
||||||
|
|
||||||
export interface AgentPermissions extends Record<string, unknown> {
|
export interface AgentPermissions extends Record<string, unknown> {
|
||||||
canCreateAgents: boolean;
|
canCreateAgents: boolean;
|
||||||
@@ -98,6 +99,7 @@ export interface Agent {
|
|||||||
permissions: AgentPermissions;
|
permissions: AgentPermissions;
|
||||||
lastHeartbeatAt: Date | null;
|
lastHeartbeatAt: Date | null;
|
||||||
metadata: Record<string, unknown> | null;
|
metadata: Record<string, unknown> | null;
|
||||||
|
orgChainHealth?: AgentOrgChainHealth;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,15 @@ export type {
|
|||||||
AdapterEnvironmentCheck,
|
AdapterEnvironmentCheck,
|
||||||
AdapterEnvironmentTestResult,
|
AdapterEnvironmentTestResult,
|
||||||
} from "./agent.js";
|
} from "./agent.js";
|
||||||
|
export type {
|
||||||
|
AgentEligibilityAgent,
|
||||||
|
AgentEligibilityLifecycleReason,
|
||||||
|
AgentInvalidOrgChainAncestor,
|
||||||
|
AgentOrgChainEntry,
|
||||||
|
AgentOrgChainHealth,
|
||||||
|
AgentOrgChainInvalidReason,
|
||||||
|
AgentWorkEligibility,
|
||||||
|
} from "../agent-eligibility.js";
|
||||||
export type { AssetImage } from "./asset.js";
|
export type { AssetImage } from "./asset.js";
|
||||||
export type {
|
export type {
|
||||||
CreateDocumentAnnotationCommentRequest,
|
CreateDocumentAnnotationCommentRequest,
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
evaluateAgentInvokability,
|
||||||
|
listInvalidOrgChainDescendantIds,
|
||||||
|
type AgentOrgRow,
|
||||||
|
} from "../services/agent-invokability.ts";
|
||||||
|
|
||||||
|
function agent(partial: Partial<AgentOrgRow> & Pick<AgentOrgRow, "id">): AgentOrgRow {
|
||||||
|
return {
|
||||||
|
companyId: "company-1",
|
||||||
|
name: partial.id,
|
||||||
|
reportsTo: null,
|
||||||
|
status: "active",
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("agent invokability", () => {
|
||||||
|
it("blocks active descendants under a terminated manager as invalid-org-chain", () => {
|
||||||
|
const rows = [
|
||||||
|
agent({ id: "ceo", status: "terminated" }),
|
||||||
|
agent({ id: "cto", reportsTo: "ceo" }),
|
||||||
|
agent({ id: "coder", reportsTo: "cto" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = evaluateAgentInvokability(rows[2], rows);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
invokable: false,
|
||||||
|
reason: "manager_terminated",
|
||||||
|
invalidOrgChain: true,
|
||||||
|
details: {
|
||||||
|
managerId: "ceo",
|
||||||
|
reportingChainAgentIds: ["cto", "ceo"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports missing managers and cycles as invalid-org-chain", () => {
|
||||||
|
const missingManager = [agent({ id: "coder", reportsTo: "missing" })];
|
||||||
|
expect(evaluateAgentInvokability(missingManager[0], missingManager)).toMatchObject({
|
||||||
|
invokable: false,
|
||||||
|
reason: "manager_missing",
|
||||||
|
invalidOrgChain: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cycle = [
|
||||||
|
agent({ id: "a", reportsTo: "b" }),
|
||||||
|
agent({ id: "b", reportsTo: "a" }),
|
||||||
|
];
|
||||||
|
expect(evaluateAgentInvokability(cycle[0], cycle)).toMatchObject({
|
||||||
|
invokable: false,
|
||||||
|
reason: "reporting_cycle",
|
||||||
|
invalidOrgChain: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists non-terminated descendants made invalid by a terminated root", () => {
|
||||||
|
const rows = [
|
||||||
|
agent({ id: "ceo", status: "terminated" }),
|
||||||
|
agent({ id: "cto", reportsTo: "ceo" }),
|
||||||
|
agent({ id: "coder", reportsTo: "cto" }),
|
||||||
|
agent({ id: "old-coder", reportsTo: "cto", status: "terminated" }),
|
||||||
|
agent({ id: "other-root" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(listInvalidOrgChainDescendantIds("ceo", rows).sort()).toEqual(["coder", "cto"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
agentWakeupRequests,
|
agentWakeupRequests,
|
||||||
companies,
|
companies,
|
||||||
createDb,
|
createDb,
|
||||||
|
heartbeatRunEvents,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
issues,
|
issues,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
@@ -33,6 +34,7 @@ describeEmbeddedPostgres("heartbeat archived-company guard", () => {
|
|||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
await db.delete(heartbeatRunEvents);
|
||||||
await db.delete(heartbeatRuns);
|
await db.delete(heartbeatRuns);
|
||||||
await db.delete(agentWakeupRequests);
|
await db.delete(agentWakeupRequests);
|
||||||
await db.delete(issues);
|
await db.delete(issues);
|
||||||
@@ -77,6 +79,60 @@ describeEmbeddedPostgres("heartbeat archived-company guard", () => {
|
|||||||
return { companyId, agentId };
|
return { companyId, agentId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function insertInvalidOrgChainAgent() {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const managerId = randomUUID();
|
||||||
|
const childId = randomUUID();
|
||||||
|
|
||||||
|
await db.insert(companies).values({
|
||||||
|
id: companyId,
|
||||||
|
name: "Invalid Org Co",
|
||||||
|
status: "active",
|
||||||
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(agents).values([
|
||||||
|
{
|
||||||
|
id: managerId,
|
||||||
|
companyId,
|
||||||
|
name: "Terminated Manager",
|
||||||
|
role: "cto",
|
||||||
|
status: "terminated",
|
||||||
|
adapterType: "codex_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {
|
||||||
|
heartbeat: {
|
||||||
|
enabled: true,
|
||||||
|
intervalSec: 60,
|
||||||
|
wakeOnDemand: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
permissions: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: childId,
|
||||||
|
companyId,
|
||||||
|
name: "Invalid Chain Child",
|
||||||
|
role: "engineer",
|
||||||
|
reportsTo: managerId,
|
||||||
|
status: "idle",
|
||||||
|
adapterType: "codex_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {
|
||||||
|
heartbeat: {
|
||||||
|
enabled: true,
|
||||||
|
intervalSec: 60,
|
||||||
|
wakeOnDemand: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
permissions: {},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { companyId, managerId, childId };
|
||||||
|
}
|
||||||
|
|
||||||
it("does not iterate archived-company agents in tickTimers", async () => {
|
it("does not iterate archived-company agents in tickTimers", async () => {
|
||||||
const { agentId } = await insertArchivedAgent();
|
const { agentId } = await insertArchivedAgent();
|
||||||
|
|
||||||
@@ -205,4 +261,122 @@ describeEmbeddedPostgres("heartbeat archived-company guard", () => {
|
|||||||
.then((rows) => rows.filter((row) => row.agentId === agentId).length);
|
.then((rows) => rows.filter((row) => row.agentId === agentId).length);
|
||||||
expect(runCount).toBe(0);
|
expect(runCount).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects explicit user invokes for invalid-org-chain agents", async () => {
|
||||||
|
const { childId } = await insertInvalidOrgChainAgent();
|
||||||
|
|
||||||
|
const heartbeat = heartbeatService(db);
|
||||||
|
|
||||||
|
await expect(heartbeat.wakeup(childId, {
|
||||||
|
source: "on_demand",
|
||||||
|
triggerDetail: "manual",
|
||||||
|
requestedByActorType: "user",
|
||||||
|
requestedByActorId: "board-user",
|
||||||
|
})).rejects.toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
details: {
|
||||||
|
reason: "manager_terminated",
|
||||||
|
invalidOrgChain: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const runCount = await db
|
||||||
|
.select()
|
||||||
|
.from(heartbeatRuns)
|
||||||
|
.then((rows) => rows.filter((row) => row.agentId === childId).length);
|
||||||
|
expect(runCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels existing queued runs for invalid-org-chain agents instead of starting them", async () => {
|
||||||
|
const { companyId, childId } = await insertInvalidOrgChainAgent();
|
||||||
|
const wakeupRequestId = randomUUID();
|
||||||
|
const runId = randomUUID();
|
||||||
|
|
||||||
|
await db.insert(agentWakeupRequests).values({
|
||||||
|
id: wakeupRequestId,
|
||||||
|
companyId,
|
||||||
|
agentId: childId,
|
||||||
|
source: "assignment",
|
||||||
|
status: "queued",
|
||||||
|
});
|
||||||
|
await db.insert(heartbeatRuns).values({
|
||||||
|
id: runId,
|
||||||
|
companyId,
|
||||||
|
agentId: childId,
|
||||||
|
invocationSource: "assignment",
|
||||||
|
status: "queued",
|
||||||
|
wakeupRequestId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const heartbeat = heartbeatService(db);
|
||||||
|
await heartbeat.resumeQueuedRuns();
|
||||||
|
|
||||||
|
const run = await db
|
||||||
|
.select({
|
||||||
|
status: heartbeatRuns.status,
|
||||||
|
error: heartbeatRuns.error,
|
||||||
|
})
|
||||||
|
.from(heartbeatRuns)
|
||||||
|
.then((rows) => rows.find((row) => row.status === "cancelled") ?? null);
|
||||||
|
expect(run).toMatchObject({
|
||||||
|
status: "cancelled",
|
||||||
|
error: "Cancelled because the agent is not invokable: manager_terminated",
|
||||||
|
});
|
||||||
|
|
||||||
|
const wakeup = await db
|
||||||
|
.select({
|
||||||
|
status: agentWakeupRequests.status,
|
||||||
|
error: agentWakeupRequests.error,
|
||||||
|
})
|
||||||
|
.from(agentWakeupRequests)
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
expect(wakeup).toMatchObject({
|
||||||
|
status: "cancelled",
|
||||||
|
error: "Cancelled because the agent is not invokable: manager_terminated",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses due scheduled retries for invalid-org-chain agents", async () => {
|
||||||
|
const { companyId, childId } = await insertInvalidOrgChainAgent();
|
||||||
|
const wakeupRequestId = randomUUID();
|
||||||
|
const runId = randomUUID();
|
||||||
|
const now = new Date("2026-06-04T00:10:00Z");
|
||||||
|
|
||||||
|
await db.insert(agentWakeupRequests).values({
|
||||||
|
id: wakeupRequestId,
|
||||||
|
companyId,
|
||||||
|
agentId: childId,
|
||||||
|
source: "automation",
|
||||||
|
status: "queued",
|
||||||
|
});
|
||||||
|
await db.insert(heartbeatRuns).values({
|
||||||
|
id: runId,
|
||||||
|
companyId,
|
||||||
|
agentId: childId,
|
||||||
|
invocationSource: "automation",
|
||||||
|
status: "scheduled_retry",
|
||||||
|
wakeupRequestId,
|
||||||
|
scheduledRetryAt: new Date("2026-06-04T00:00:00Z"),
|
||||||
|
scheduledRetryReason: "transient_failure",
|
||||||
|
scheduledRetryAttempt: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const heartbeat = heartbeatService(db);
|
||||||
|
const promoted = await heartbeat.promoteDueScheduledRetries(now);
|
||||||
|
|
||||||
|
expect(promoted).toEqual({ promoted: 0, runIds: [] });
|
||||||
|
const run = await db
|
||||||
|
.select({
|
||||||
|
status: heartbeatRuns.status,
|
||||||
|
errorCode: heartbeatRuns.errorCode,
|
||||||
|
error: heartbeatRuns.error,
|
||||||
|
})
|
||||||
|
.from(heartbeatRuns)
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
expect(run).toMatchObject({
|
||||||
|
status: "cancelled",
|
||||||
|
errorCode: "agent_not_invokable",
|
||||||
|
error: "Scheduled retry suppressed because the agent is not invokable",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -957,6 +957,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
|||||||
|
|
||||||
it("queues exactly one retry when the recorded local pid is dead", async () => {
|
it("queues exactly one retry when the recorded local pid is dead", async () => {
|
||||||
const { agentId, runId, issueId } = await seedRunFixture({
|
const { agentId, runId, issueId } = await seedRunFixture({
|
||||||
|
agentStatus: "idle",
|
||||||
processPid: 999_999_999,
|
processPid: 999_999_999,
|
||||||
contextSnapshot: {
|
contextSnapshot: {
|
||||||
modelProfile: "cheap",
|
modelProfile: "cheap",
|
||||||
@@ -988,7 +989,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
|||||||
timeoutConfigured: false,
|
timeoutConfigured: false,
|
||||||
timeoutFired: false,
|
timeoutFired: false,
|
||||||
});
|
});
|
||||||
expect(retryRun?.status).toBe("queued");
|
expect(["queued", "running"]).toContain(retryRun?.status);
|
||||||
expect(retryRun?.retryOfRunId).toBe(runId);
|
expect(retryRun?.retryOfRunId).toBe(runId);
|
||||||
expect(retryRun?.processLossRetryCount).toBe(1);
|
expect(retryRun?.processLossRetryCount).toBe(1);
|
||||||
expect(retryRun?.contextSnapshot as Record<string, unknown>).not.toHaveProperty("modelProfile");
|
expect(retryRun?.contextSnapshot as Record<string, unknown>).not.toHaveProperty("modelProfile");
|
||||||
@@ -1032,6 +1033,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
|||||||
expect(isPidAlive(orphan.descendantPid)).toBe(true);
|
expect(isPidAlive(orphan.descendantPid)).toBe(true);
|
||||||
|
|
||||||
const { agentId, runId, issueId } = await seedRunFixture({
|
const { agentId, runId, issueId } = await seedRunFixture({
|
||||||
|
agentStatus: "idle",
|
||||||
processPid: orphan.processPid,
|
processPid: orphan.processPid,
|
||||||
processGroupId: orphan.processGroupId,
|
processGroupId: orphan.processGroupId,
|
||||||
});
|
});
|
||||||
@@ -1055,7 +1057,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
|||||||
expect(failedRun?.error).toContain("descendant process group");
|
expect(failedRun?.error).toContain("descendant process group");
|
||||||
|
|
||||||
const retryRun = runs.find((row) => row.id !== runId);
|
const retryRun = runs.find((row) => row.id !== runId);
|
||||||
expect(retryRun?.status).toBe("queued");
|
expect(["queued", "running"]).toContain(retryRun?.status);
|
||||||
|
|
||||||
const issue = await db
|
const issue = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ vi.mock("../services/index.js", () => ({
|
|||||||
}),
|
}),
|
||||||
agentService: () => ({
|
agentService: () => ({
|
||||||
getById: vi.fn(async () => null),
|
getById: vi.fn(async () => null),
|
||||||
|
resolveByReference: vi.fn(async (_companyId: string, reference: string) => ({
|
||||||
|
ambiguous: false,
|
||||||
|
agent: {
|
||||||
|
id: reference,
|
||||||
|
companyId: "company-1",
|
||||||
|
status: "active",
|
||||||
|
orgChainHealth: { status: "healthy" },
|
||||||
|
},
|
||||||
|
})),
|
||||||
}),
|
}),
|
||||||
companyService: () => ({
|
companyService: () => ({
|
||||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||||
|
|||||||
@@ -65,6 +65,15 @@ function registerModuleMocks() {
|
|||||||
companyId: "company-1",
|
companyId: "company-1",
|
||||||
permissions: null,
|
permissions: null,
|
||||||
})),
|
})),
|
||||||
|
resolveByReference: vi.fn(async (_companyId: string, reference: string) => ({
|
||||||
|
ambiguous: false,
|
||||||
|
agent: {
|
||||||
|
id: reference,
|
||||||
|
companyId: "company-1",
|
||||||
|
status: "idle",
|
||||||
|
orgChainHealth: { status: "healthy" },
|
||||||
|
},
|
||||||
|
})),
|
||||||
}),
|
}),
|
||||||
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||||
documentService: () => ({}),
|
documentService: () => ({}),
|
||||||
|
|||||||
@@ -273,6 +273,36 @@ describe("issue graph liveness classifier", () => {
|
|||||||
expect(paused[0]?.state).toBe("blocked_by_uninvokable_assignee");
|
expect(paused[0]?.state).toBe("blocked_by_uninvokable_assignee");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("detects blocker assignees under terminated org ancestors as uninvokable", () => {
|
||||||
|
const findings = classifyIssueGraphLiveness({
|
||||||
|
issues: [
|
||||||
|
issue(),
|
||||||
|
issue({
|
||||||
|
id: blockerId,
|
||||||
|
identifier: "PAP-1704",
|
||||||
|
title: "Invalid tree unblock work",
|
||||||
|
status: "todo",
|
||||||
|
assigneeAgentId: "qa-2",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
relations: blocks,
|
||||||
|
agents: [
|
||||||
|
agent(),
|
||||||
|
manager,
|
||||||
|
agent({ id: "qa-2", name: "QA 2", status: "active", reportsTo: "cto-2" }),
|
||||||
|
agent({ id: "cto-2", name: "CTO 2", status: "terminated", reportsTo: "ceo-2" }),
|
||||||
|
agent({ id: "ceo-2", name: "CEO 2", status: "terminated", reportsTo: null }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(findings).toHaveLength(1);
|
||||||
|
expect(findings[0]).toMatchObject({
|
||||||
|
state: "blocked_by_uninvokable_assignee",
|
||||||
|
reason: "PAP-1703 is blocked by PAP-1704, but its assignee is in an invalid org chain.",
|
||||||
|
recommendedOwnerAgentId: managerId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("detects invalid in_review execution participant", () => {
|
it("detects invalid in_review execution participant", () => {
|
||||||
const findings = classifyIssueGraphLiveness({
|
const findings = classifyIssueGraphLiveness({
|
||||||
issues: [
|
issues: [
|
||||||
|
|||||||
@@ -168,6 +168,183 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
|
|||||||
await tempDb?.cleanup();
|
await tempDb?.cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function seedAssignableAgentCompany() {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
await db.insert(companies).values({
|
||||||
|
id: companyId,
|
||||||
|
name: "Paperclip",
|
||||||
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
});
|
||||||
|
return companyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentRow(companyId: string, input: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status?: string;
|
||||||
|
reportsTo?: string | null;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: input.id,
|
||||||
|
companyId,
|
||||||
|
name: input.name,
|
||||||
|
role: "engineer",
|
||||||
|
status: input.status ?? "active",
|
||||||
|
reportsTo: input.reportsTo ?? null,
|
||||||
|
adapterType: "codex_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
permissions: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("rejects direct terminated assignees with structured conflict details", async () => {
|
||||||
|
const companyId = await seedAssignableAgentCompany();
|
||||||
|
const terminatedAgentId = randomUUID();
|
||||||
|
await db.insert(agents).values(agentRow(companyId, {
|
||||||
|
id: terminatedAgentId,
|
||||||
|
name: "TerminatedCoder",
|
||||||
|
status: "terminated",
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(svc.create(companyId, {
|
||||||
|
title: "Do not assign this",
|
||||||
|
description: null,
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: terminatedAgentId,
|
||||||
|
})).rejects.toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
details: {
|
||||||
|
code: "agent_not_assignable",
|
||||||
|
reason: "assignee_terminated",
|
||||||
|
assigneeAgentId: terminatedAgentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid ancestor-chain assignees and preserves the existing assignment", async () => {
|
||||||
|
const companyId = await seedAssignableAgentCompany();
|
||||||
|
const activeAgentId = randomUUID();
|
||||||
|
const terminatedManagerId = randomUUID();
|
||||||
|
const blockedAgentId = randomUUID();
|
||||||
|
await db.insert(agents).values([
|
||||||
|
agentRow(companyId, { id: activeAgentId, name: "ActiveCoder" }),
|
||||||
|
agentRow(companyId, {
|
||||||
|
id: terminatedManagerId,
|
||||||
|
name: "TerminatedManager",
|
||||||
|
status: "terminated",
|
||||||
|
}),
|
||||||
|
agentRow(companyId, {
|
||||||
|
id: blockedAgentId,
|
||||||
|
name: "BlockedCoder",
|
||||||
|
reportsTo: terminatedManagerId,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const issue = await svc.create(companyId, {
|
||||||
|
title: "Keep current assignment",
|
||||||
|
description: null,
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: activeAgentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(svc.update(issue.id, {
|
||||||
|
assigneeAgentId: blockedAgentId,
|
||||||
|
})).rejects.toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
details: {
|
||||||
|
code: "agent_not_assignable",
|
||||||
|
reason: "ancestor_terminated",
|
||||||
|
assigneeAgentId: blockedAgentId,
|
||||||
|
invalidAncestorAgentId: terminatedManagerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const persisted = await db
|
||||||
|
.select({ assigneeAgentId: issues.assigneeAgentId })
|
||||||
|
.from(issues)
|
||||||
|
.where(eq(issues.id, issue.id))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
expect(persisted?.assigneeAgentId).toBe(activeAgentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects checkout by a terminated agent before assigning the issue", async () => {
|
||||||
|
const companyId = await seedAssignableAgentCompany();
|
||||||
|
const terminatedAgentId = randomUUID();
|
||||||
|
await db.insert(agents).values(agentRow(companyId, {
|
||||||
|
id: terminatedAgentId,
|
||||||
|
name: "TerminatedCheckoutCoder",
|
||||||
|
status: "terminated",
|
||||||
|
}));
|
||||||
|
const issue = await svc.create(companyId, {
|
||||||
|
title: "Checkout must stay unassigned",
|
||||||
|
description: null,
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(svc.checkout(issue.id, terminatedAgentId, ["todo"], randomUUID()))
|
||||||
|
.rejects.toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
details: {
|
||||||
|
code: "agent_not_assignable",
|
||||||
|
reason: "assignee_terminated",
|
||||||
|
assigneeAgentId: terminatedAgentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const persisted = await db
|
||||||
|
.select({ assigneeAgentId: issues.assigneeAgentId, status: issues.status })
|
||||||
|
.from(issues)
|
||||||
|
.where(eq(issues.id, issue.id))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
expect(persisted).toMatchObject({
|
||||||
|
assigneeAgentId: null,
|
||||||
|
status: "todo",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects moving an existing terminated assignment into progress without clearing it", async () => {
|
||||||
|
const companyId = await seedAssignableAgentCompany();
|
||||||
|
const assigneeAgentId = randomUUID();
|
||||||
|
await db.insert(agents).values(agentRow(companyId, {
|
||||||
|
id: assigneeAgentId,
|
||||||
|
name: "SoonTerminatedCoder",
|
||||||
|
}));
|
||||||
|
const issue = await svc.create(companyId, {
|
||||||
|
title: "Do not restart after termination",
|
||||||
|
description: null,
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId,
|
||||||
|
});
|
||||||
|
await db.update(agents).set({ status: "terminated" }).where(eq(agents.id, assigneeAgentId));
|
||||||
|
|
||||||
|
await expect(svc.update(issue.id, {
|
||||||
|
status: "in_progress",
|
||||||
|
})).rejects.toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
details: {
|
||||||
|
code: "agent_not_assignable",
|
||||||
|
reason: "assignee_terminated",
|
||||||
|
assigneeAgentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const persisted = await db
|
||||||
|
.select({ assigneeAgentId: issues.assigneeAgentId, status: issues.status })
|
||||||
|
.from(issues)
|
||||||
|
.where(eq(issues.id, issue.id))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
expect(persisted).toMatchObject({
|
||||||
|
assigneeAgentId,
|
||||||
|
status: "todo",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("returns issues an agent participated in across the supported signals", async () => {
|
it("returns issues an agent participated in across the supported signals", async () => {
|
||||||
const companyId = randomUUID();
|
const companyId = randomUUID();
|
||||||
const agentId = randomUUID();
|
const agentId = randomUUID();
|
||||||
|
|||||||
@@ -55,6 +55,27 @@ describe("monthly spend hydration", () => {
|
|||||||
|
|
||||||
it("recomputes agent spentMonthlyCents from the current utc month instead of returning stale stored values", async () => {
|
it("recomputes agent spentMonthlyCents from the current utc month instead of returning stale stored values", async () => {
|
||||||
const dbStub = createSelectSequenceDb([
|
const dbStub = createSelectSequenceDb([
|
||||||
|
[{
|
||||||
|
id: "agent-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
name: "Budget Agent",
|
||||||
|
role: "general",
|
||||||
|
title: null,
|
||||||
|
reportsTo: null,
|
||||||
|
capabilities: null,
|
||||||
|
adapterType: "claude-local",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
budgetMonthlyCents: 5000,
|
||||||
|
spentMonthlyCents: 999999,
|
||||||
|
metadata: null,
|
||||||
|
permissions: null,
|
||||||
|
status: "idle",
|
||||||
|
pauseReason: null,
|
||||||
|
pausedAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}],
|
||||||
[{
|
[{
|
||||||
id: "agent-1",
|
id: "agent-1",
|
||||||
companyId: "company-1",
|
companyId: "company-1",
|
||||||
|
|||||||
@@ -539,6 +539,11 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
|||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
status: 409,
|
status: 409,
|
||||||
message: "Cannot assign routines to terminated agents",
|
message: "Cannot assign routines to terminated agents",
|
||||||
|
details: {
|
||||||
|
code: "agent_not_assignable",
|
||||||
|
reason: "assignee_terminated",
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await expect(svc.get(routine.id)).resolves.toMatchObject({
|
await expect(svc.get(routine.id)).resolves.toMatchObject({
|
||||||
description: "revision 2",
|
description: "revision 2",
|
||||||
@@ -546,6 +551,74 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks routine reassignment to agents under terminated managers", async () => {
|
||||||
|
const { agentId, companyId, routine, svc } = await seedFixture();
|
||||||
|
const terminatedManagerId = randomUUID();
|
||||||
|
const blockedAgentId = randomUUID();
|
||||||
|
await db.insert(agents).values([
|
||||||
|
{
|
||||||
|
id: terminatedManagerId,
|
||||||
|
companyId,
|
||||||
|
name: "TerminatedManager",
|
||||||
|
role: "manager",
|
||||||
|
status: "terminated",
|
||||||
|
adapterType: "codex_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
permissions: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: blockedAgentId,
|
||||||
|
companyId,
|
||||||
|
name: "BlockedRoutineCoder",
|
||||||
|
role: "engineer",
|
||||||
|
status: "active",
|
||||||
|
reportsTo: terminatedManagerId,
|
||||||
|
adapterType: "codex_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
permissions: {},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(svc.update(routine.id, {
|
||||||
|
assigneeAgentId: blockedAgentId,
|
||||||
|
}, { userId: "board-user" })).rejects.toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
details: {
|
||||||
|
code: "agent_not_assignable",
|
||||||
|
reason: "ancestor_terminated",
|
||||||
|
assigneeAgentId: blockedAgentId,
|
||||||
|
invalidAncestorAgentId: terminatedManagerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(svc.get(routine.id)).resolves.toMatchObject({
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks manual routine runs when the persisted assignee is no longer assignable", async () => {
|
||||||
|
const { agentId, routine, svc } = await seedFixture();
|
||||||
|
await db
|
||||||
|
.update(agents)
|
||||||
|
.set({ status: "terminated" })
|
||||||
|
.where(eq(agents.id, agentId));
|
||||||
|
|
||||||
|
await expect(svc.runRoutine(routine.id, {
|
||||||
|
source: "manual",
|
||||||
|
payload: null,
|
||||||
|
variables: null,
|
||||||
|
}, { userId: "board-user" })).rejects.toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
details: {
|
||||||
|
code: "agent_not_assignable",
|
||||||
|
reason: "assignee_terminated",
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("appends safe trigger metadata revisions without leaking webhook secrets", async () => {
|
it("appends safe trigger metadata revisions without leaking webhook secrets", async () => {
|
||||||
const { routine, svc } = await seedFixture();
|
const { routine, svc } = await seedFixture();
|
||||||
const created = await svc.createTrigger(routine.id, {
|
const created = await svc.createTrigger(routine.id, {
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ import { assertEnvironmentSelectionForCompany } from "./environment-selection.js
|
|||||||
import { recoveryService } from "../services/recovery/service.js";
|
import { recoveryService } from "../services/recovery/service.js";
|
||||||
import { resolveCoreTrustPreset } from "../services/trust-preset-resolver.js";
|
import { resolveCoreTrustPreset } from "../services/trust-preset-resolver.js";
|
||||||
import { readObject } from "../lib/objects.js";
|
import { readObject } from "../lib/objects.js";
|
||||||
|
import { listInvalidOrgChainDescendantIds } from "../services/agent-invokability.js";
|
||||||
|
|
||||||
const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000;
|
const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000;
|
||||||
const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024;
|
const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024;
|
||||||
@@ -2850,7 +2851,14 @@ export function agentRoutes(
|
|||||||
router.post("/agents/:id/resume", async (req, res) => {
|
router.post("/agents/:id/resume", async (req, res) => {
|
||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
if (!(await getAccessibleAgent(req, res, id))) {
|
const existing = await getAccessibleAgent(req, res, id);
|
||||||
|
if (!existing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (existing.orgChainHealth?.status === "invalid_org_chain") {
|
||||||
|
res.status(409).json({
|
||||||
|
error: existing.orgChainHealth?.repairGuidance ?? "Repair this agent's reporting chain before resuming it",
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const agent = await svc.resume(id);
|
const agent = await svc.resume(id);
|
||||||
@@ -2918,7 +2926,21 @@ export function agentRoutes(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await heartbeat.cancelActiveForAgent(id);
|
const companyAgentRows = await db
|
||||||
|
.select({
|
||||||
|
id: agentsTable.id,
|
||||||
|
companyId: agentsTable.companyId,
|
||||||
|
name: agentsTable.name,
|
||||||
|
reportsTo: agentsTable.reportsTo,
|
||||||
|
status: agentsTable.status,
|
||||||
|
})
|
||||||
|
.from(agentsTable)
|
||||||
|
.where(eq(agentsTable.companyId, agent.companyId));
|
||||||
|
const invalidOrgChainDescendantIds = listInvalidOrgChainDescendantIds(id, companyAgentRows);
|
||||||
|
const cancellation = await heartbeat.cancelInvocationsForAgents(
|
||||||
|
[id, ...invalidOrgChainDescendantIds],
|
||||||
|
"Cancelled because the agent was terminated or became invalid-org-chain under a terminated manager",
|
||||||
|
);
|
||||||
|
|
||||||
await logActivity(db, {
|
await logActivity(db, {
|
||||||
companyId: agent.companyId,
|
companyId: agent.companyId,
|
||||||
@@ -2927,6 +2949,18 @@ export function agentRoutes(
|
|||||||
action: "agent.terminated",
|
action: "agent.terminated",
|
||||||
entityType: "agent",
|
entityType: "agent",
|
||||||
entityId: agent.id,
|
entityId: agent.id,
|
||||||
|
details: {
|
||||||
|
invalidOrgChain: {
|
||||||
|
descendantCount: invalidOrgChainDescendantIds.length,
|
||||||
|
descendantIds: invalidOrgChainDescendantIds,
|
||||||
|
state: invalidOrgChainDescendantIds.length > 0 ? "descendants_invalid_under_terminated_manager" : "none",
|
||||||
|
},
|
||||||
|
cancellation: {
|
||||||
|
agentIds: cancellation.agentIds,
|
||||||
|
runsCancelled: cancellation.runsCancelled,
|
||||||
|
wakeupsCancelled: cancellation.wakeupsCancelled,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(agent);
|
res.json(agent);
|
||||||
@@ -3058,6 +3092,12 @@ export function agentRoutes(
|
|||||||
} else {
|
} else {
|
||||||
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
|
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
|
||||||
}
|
}
|
||||||
|
if (agent.orgChainHealth?.status === "invalid_org_chain") {
|
||||||
|
res.status(409).json({
|
||||||
|
error: agent.orgChainHealth?.repairGuidance ?? "Repair this agent's reporting chain before starting runs",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const run = await heartbeat.wakeup(id, {
|
const run = await heartbeat.wakeup(id, {
|
||||||
source: opts.source,
|
source: opts.source,
|
||||||
@@ -3126,6 +3166,12 @@ export function agentRoutes(
|
|||||||
} else {
|
} else {
|
||||||
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
|
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
|
||||||
}
|
}
|
||||||
|
if (agent.orgChainHealth?.status === "invalid_org_chain") {
|
||||||
|
res.status(409).json({
|
||||||
|
error: agent.orgChainHealth?.repairGuidance ?? "Repair this agent's reporting chain before starting runs",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const body = (req.body ?? {}) as Partial<{
|
const body = (req.body ?? {}) as Partial<{
|
||||||
reason: unknown;
|
reason: unknown;
|
||||||
|
|||||||
+62
-24
@@ -2166,6 +2166,18 @@ export function issueRoutes(
|
|||||||
if (!resolved.agent) {
|
if (!resolved.agent) {
|
||||||
throw notFound("Agent not found");
|
throw notFound("Agent not found");
|
||||||
}
|
}
|
||||||
|
if (resolved.agent.status === "pending_approval") {
|
||||||
|
throw conflict("Cannot assign work to pending approval agents");
|
||||||
|
}
|
||||||
|
if (resolved.agent.status === "terminated") {
|
||||||
|
throw conflict("Cannot assign work to terminated agents");
|
||||||
|
}
|
||||||
|
if (resolved.agent.orgChainHealth?.status === "invalid_org_chain") {
|
||||||
|
throw conflict(
|
||||||
|
resolved.agent.orgChainHealth?.repairGuidance ??
|
||||||
|
"Cannot assign work to agents with invalid org chains",
|
||||||
|
);
|
||||||
|
}
|
||||||
return resolved.agent.id;
|
return resolved.agent.id;
|
||||||
}
|
}
|
||||||
function toValidTimestamp(value: Date | string | null | undefined) {
|
function toValidTimestamp(value: Date | string | null | undefined) {
|
||||||
@@ -4227,7 +4239,15 @@ export function issueRoutes(
|
|||||||
}
|
}
|
||||||
if (!(await assertIssueReadAllowed(req, res, parent))) return;
|
if (!(await assertIssueReadAllowed(req, res, parent))) return;
|
||||||
}
|
}
|
||||||
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, { companyId }, req.body))) return;
|
const normalizedAssigneeAgentId = await normalizeIssueAssigneeAgentReference(
|
||||||
|
companyId,
|
||||||
|
req.body.assigneeAgentId as string | null | undefined,
|
||||||
|
);
|
||||||
|
const createBody = {
|
||||||
|
...req.body,
|
||||||
|
...(normalizedAssigneeAgentId !== undefined ? { assigneeAgentId: normalizedAssigneeAgentId } : {}),
|
||||||
|
};
|
||||||
|
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, { companyId }, createBody))) return;
|
||||||
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
|
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
|
||||||
await assertCanAssignTasks(req, companyId, {
|
await assertCanAssignTasks(req, companyId, {
|
||||||
projectId: await resolveAssignmentProjectId({
|
projectId: await resolveAssignmentProjectId({
|
||||||
@@ -4236,27 +4256,27 @@ export function issueRoutes(
|
|||||||
parentIssueId: req.body.parentId,
|
parentIssueId: req.body.parentId,
|
||||||
}),
|
}),
|
||||||
parentIssueId: req.body.parentId ?? null,
|
parentIssueId: req.body.parentId ?? null,
|
||||||
assigneeAgentId: req.body.assigneeAgentId ?? null,
|
assigneeAgentId: createBody.assigneeAgentId ?? null,
|
||||||
assigneeUserId: req.body.assigneeUserId ?? null,
|
assigneeUserId: req.body.assigneeUserId ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await assertIssueEnvironmentSelection(companyId, req.body.executionWorkspaceSettings?.environmentId);
|
await assertIssueEnvironmentSelection(companyId, createBody.executionWorkspaceSettings?.environmentId);
|
||||||
|
|
||||||
const actor = getActorInfo(req);
|
const actor = getActorInfo(req);
|
||||||
const executionPolicy = applyActorMonitorScheduledBy(
|
const executionPolicy = applyActorMonitorScheduledBy(
|
||||||
normalizeIssueExecutionPolicy(req.body.executionPolicy),
|
normalizeIssueExecutionPolicy(createBody.executionPolicy),
|
||||||
actor.actorType,
|
actor.actorType,
|
||||||
);
|
);
|
||||||
await assertCanManageIssueMonitor(access, req, companyId, req.body.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor));
|
await assertCanManageIssueMonitor(access, req, companyId, createBody.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor));
|
||||||
const issueId = randomUUID();
|
const issueId = randomUUID();
|
||||||
const sourceTrust = await sourceTrustForActorWrite({
|
const sourceTrust = await sourceTrustForActorWrite({
|
||||||
id: issueId,
|
id: issueId,
|
||||||
companyId,
|
companyId,
|
||||||
projectId: req.body.projectId ?? null,
|
projectId: createBody.projectId ?? null,
|
||||||
executionPolicy,
|
executionPolicy,
|
||||||
}, actor);
|
}, actor);
|
||||||
const issue = await svc.create(companyId, {
|
const issue = await svc.create(companyId, {
|
||||||
...req.body,
|
...createBody,
|
||||||
id: issueId,
|
id: issueId,
|
||||||
executionPolicy,
|
executionPolicy,
|
||||||
...(sourceTrust ? { sourceTrust } : {}),
|
...(sourceTrust ? { sourceTrust } : {}),
|
||||||
@@ -4343,32 +4363,40 @@ export function issueRoutes(
|
|||||||
if (!(await assertIssueReadAllowed(req, res, parent))) return;
|
if (!(await assertIssueReadAllowed(req, res, parent))) return;
|
||||||
if (await assertLowTrustControlPlaneDenied(req, res, parent.companyId, parent)) return;
|
if (await assertLowTrustControlPlaneDenied(req, res, parent.companyId, parent)) return;
|
||||||
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
|
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
|
||||||
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, parent, req.body))) return;
|
const normalizedAssigneeAgentId = await normalizeIssueAssigneeAgentReference(
|
||||||
|
parent.companyId,
|
||||||
|
req.body.assigneeAgentId as string | null | undefined,
|
||||||
|
);
|
||||||
|
const createBody = {
|
||||||
|
...req.body,
|
||||||
|
...(normalizedAssigneeAgentId !== undefined ? { assigneeAgentId: normalizedAssigneeAgentId } : {}),
|
||||||
|
};
|
||||||
|
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, parent, createBody))) return;
|
||||||
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
|
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
|
||||||
await assertCanAssignTasks(req, parent.companyId, {
|
await assertCanAssignTasks(req, parent.companyId, {
|
||||||
projectId: req.body.projectId ?? parent.projectId ?? null,
|
projectId: createBody.projectId ?? parent.projectId ?? null,
|
||||||
parentIssueId: parent.id,
|
parentIssueId: parent.id,
|
||||||
assigneeAgentId: req.body.assigneeAgentId ?? null,
|
assigneeAgentId: createBody.assigneeAgentId ?? null,
|
||||||
assigneeUserId: req.body.assigneeUserId ?? null,
|
assigneeUserId: createBody.assigneeUserId ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await assertIssueEnvironmentSelection(parent.companyId, req.body.executionWorkspaceSettings?.environmentId);
|
await assertIssueEnvironmentSelection(parent.companyId, createBody.executionWorkspaceSettings?.environmentId);
|
||||||
|
|
||||||
const actor = getActorInfo(req);
|
const actor = getActorInfo(req);
|
||||||
const executionPolicy = applyActorMonitorScheduledBy(
|
const executionPolicy = applyActorMonitorScheduledBy(
|
||||||
normalizeIssueExecutionPolicy(req.body.executionPolicy),
|
normalizeIssueExecutionPolicy(createBody.executionPolicy),
|
||||||
actor.actorType,
|
actor.actorType,
|
||||||
);
|
);
|
||||||
await assertCanManageIssueMonitor(access, req, parent.companyId, req.body.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor));
|
await assertCanManageIssueMonitor(access, req, parent.companyId, createBody.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor));
|
||||||
const issueId = randomUUID();
|
const issueId = randomUUID();
|
||||||
const sourceTrust = await sourceTrustForActorWrite({
|
const sourceTrust = await sourceTrustForActorWrite({
|
||||||
id: issueId,
|
id: issueId,
|
||||||
companyId: parent.companyId,
|
companyId: parent.companyId,
|
||||||
projectId: req.body.projectId ?? parent.projectId ?? null,
|
projectId: createBody.projectId ?? parent.projectId ?? null,
|
||||||
executionPolicy,
|
executionPolicy,
|
||||||
}, actor);
|
}, actor);
|
||||||
const { issue, parentBlockerAdded } = await svc.createChild(parent.id, {
|
const { issue, parentBlockerAdded } = await svc.createChild(parent.id, {
|
||||||
...req.body,
|
...createBody,
|
||||||
id: issueId,
|
id: issueId,
|
||||||
executionPolicy,
|
executionPolicy,
|
||||||
...(sourceTrust ? { sourceTrust } : {}),
|
...(sourceTrust ? { sourceTrust } : {}),
|
||||||
@@ -4457,23 +4485,33 @@ export function issueRoutes(
|
|||||||
assertCompanyAccess(req, sourceIssue.companyId);
|
assertCompanyAccess(req, sourceIssue.companyId);
|
||||||
if (!(await assertAgentIssueMutationAllowed(req, res, sourceIssue))) return;
|
if (!(await assertAgentIssueMutationAllowed(req, res, sourceIssue))) return;
|
||||||
|
|
||||||
|
const requestedChildren = [];
|
||||||
for (const child of req.body.children as Array<typeof req.body.children[number]>) {
|
for (const child of req.body.children as Array<typeof req.body.children[number]>) {
|
||||||
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(child));
|
const normalizedAssigneeAgentId = await normalizeIssueAssigneeAgentReference(
|
||||||
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, sourceIssue, child))) return;
|
sourceIssue.companyId,
|
||||||
if (child.assigneeAgentId || child.assigneeUserId) {
|
child.assigneeAgentId as string | null | undefined,
|
||||||
|
);
|
||||||
|
const childBody = {
|
||||||
|
...child,
|
||||||
|
...(normalizedAssigneeAgentId !== undefined ? { assigneeAgentId: normalizedAssigneeAgentId } : {}),
|
||||||
|
};
|
||||||
|
requestedChildren.push(childBody);
|
||||||
|
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(childBody));
|
||||||
|
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, sourceIssue, childBody))) return;
|
||||||
|
if (childBody.assigneeAgentId || childBody.assigneeUserId) {
|
||||||
await assertCanAssignTasks(req, sourceIssue.companyId, {
|
await assertCanAssignTasks(req, sourceIssue.companyId, {
|
||||||
projectId: child.projectId ?? sourceIssue.projectId ?? null,
|
projectId: childBody.projectId ?? sourceIssue.projectId ?? null,
|
||||||
parentIssueId: sourceIssue.id,
|
parentIssueId: sourceIssue.id,
|
||||||
assigneeAgentId: child.assigneeAgentId ?? null,
|
assigneeAgentId: childBody.assigneeAgentId ?? null,
|
||||||
assigneeUserId: child.assigneeUserId ?? null,
|
assigneeUserId: childBody.assigneeUserId ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await assertIssueEnvironmentSelection(sourceIssue.companyId, child.executionWorkspaceSettings?.environmentId);
|
await assertIssueEnvironmentSelection(sourceIssue.companyId, childBody.executionWorkspaceSettings?.environmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const actor = getActorInfo(req);
|
const actor = getActorInfo(req);
|
||||||
const normalizedChildren = [];
|
const normalizedChildren = [];
|
||||||
for (const child of req.body.children as Array<typeof req.body.children[number]>) {
|
for (const child of requestedChildren) {
|
||||||
const executionPolicy = applyActorMonitorScheduledBy(
|
const executionPolicy = applyActorMonitorScheduledBy(
|
||||||
normalizeIssueExecutionPolicy(child.executionPolicy),
|
normalizeIssueExecutionPolicy(child.executionPolicy),
|
||||||
actor.actorType,
|
actor.actorType,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { and, eq, inArray, ne, sql } from "drizzle-orm";
|
import { and, eq, inArray, ne, sql } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
agents,
|
|
||||||
companyMemberships,
|
companyMemberships,
|
||||||
instanceUserRoles,
|
instanceUserRoles,
|
||||||
issues,
|
issues,
|
||||||
@@ -9,6 +8,7 @@ import {
|
|||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import type { PermissionKey, PrincipalType } from "@paperclipai/shared";
|
import type { PermissionKey, PrincipalType } from "@paperclipai/shared";
|
||||||
import { conflict } from "../errors.js";
|
import { conflict } from "../errors.js";
|
||||||
|
import { assertAssignableAgent } from "./agent-assignability.js";
|
||||||
import { authorizationService, type AuthorizationActor, type AuthorizationResource } from "./authorization.js";
|
import { authorizationService, type AuthorizationActor, type AuthorizationResource } from "./authorization.js";
|
||||||
import { ensureHumanRoleDefaultGrants } from "./principal-access-compatibility.js";
|
import { ensureHumanRoleDefaultGrants } from "./principal-access-compatibility.js";
|
||||||
|
|
||||||
@@ -315,21 +315,7 @@ export function accessService(db: Db) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const agent = await tx
|
await assertAssignableAgent(tx as Db, companyId, input.assigneeAgentId, { kind: "work" });
|
||||||
.select({
|
|
||||||
id: agents.id,
|
|
||||||
companyId: agents.companyId,
|
|
||||||
status: agents.status,
|
|
||||||
})
|
|
||||||
.from(agents)
|
|
||||||
.where(eq(agents.id, input.assigneeAgentId!))
|
|
||||||
.then((rows) => rows[0] ?? null);
|
|
||||||
if (!agent || agent.companyId !== companyId) {
|
|
||||||
throw conflict("Replacement agent must belong to the same company");
|
|
||||||
}
|
|
||||||
if (agent.status === "pending_approval" || agent.status === "terminated") {
|
|
||||||
throw conflict("Replacement agent must be assignable");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function archiveMember(companyId: string, memberId: string, input: MemberArchiveInput = {}) {
|
async function archiveMember(companyId: string, memberId: string, input: MemberArchiveInput = {}) {
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { Db } from "@paperclipai/db";
|
||||||
|
import { agents } from "@paperclipai/db";
|
||||||
|
import {
|
||||||
|
getAgentWorkEligibility,
|
||||||
|
type AgentEligibilityAgent,
|
||||||
|
type AgentOrgChainHealth,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||||
|
|
||||||
|
type AgentAssignmentKind = "work" | "routine";
|
||||||
|
|
||||||
|
type AssignabilityAgent = AgentEligibilityAgent;
|
||||||
|
|
||||||
|
type AgentAssignmentConflictReason =
|
||||||
|
| "pending_approval"
|
||||||
|
| "assignee_terminated"
|
||||||
|
| "assignee_unknown_status"
|
||||||
|
| "ancestor_terminated"
|
||||||
|
| "ancestor_missing"
|
||||||
|
| "ancestor_cross_company"
|
||||||
|
| "ancestor_cycle"
|
||||||
|
| "ancestor_depth_exceeded";
|
||||||
|
|
||||||
|
function assignmentMessage(kind: AgentAssignmentKind, reason: AgentAssignmentConflictReason) {
|
||||||
|
if (reason === "pending_approval") {
|
||||||
|
return kind === "routine"
|
||||||
|
? "Cannot assign routines to pending approval agents"
|
||||||
|
: "Cannot assign work to pending approval agents";
|
||||||
|
}
|
||||||
|
if (reason === "assignee_terminated") {
|
||||||
|
return kind === "routine"
|
||||||
|
? "Cannot assign routines to terminated agents"
|
||||||
|
: "Cannot assign work to terminated agents";
|
||||||
|
}
|
||||||
|
if (reason === "assignee_unknown_status") {
|
||||||
|
return kind === "routine"
|
||||||
|
? "Cannot assign routines to agents with an unsupported lifecycle status"
|
||||||
|
: "Cannot assign work to agents with an unsupported lifecycle status";
|
||||||
|
}
|
||||||
|
return kind === "routine"
|
||||||
|
? "Cannot assign routines to agents with an invalid org chain"
|
||||||
|
: "Cannot assign work to agents with an invalid org chain";
|
||||||
|
}
|
||||||
|
|
||||||
|
function conflictDetails(input: {
|
||||||
|
companyId: string;
|
||||||
|
assigneeAgentId: string;
|
||||||
|
reason: AgentAssignmentConflictReason;
|
||||||
|
chain: AssignabilityAgent[];
|
||||||
|
invalidAncestorAgentId?: string | null;
|
||||||
|
missingAncestorAgentId?: string | null;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
code: "agent_not_assignable",
|
||||||
|
reason: input.reason,
|
||||||
|
companyId: input.companyId,
|
||||||
|
assigneeAgentId: input.assigneeAgentId,
|
||||||
|
invalidAncestorAgentId: input.invalidAncestorAgentId ?? null,
|
||||||
|
missingAncestorAgentId: input.missingAncestorAgentId ?? null,
|
||||||
|
ancestorChain: input.chain.map((agent) => ({
|
||||||
|
id: agent.id,
|
||||||
|
companyId: agent.companyId,
|
||||||
|
status: agent.status,
|
||||||
|
reportsTo: agent.reportsTo,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAgent(db: Db, agentId: string): Promise<AssignabilityAgent | null> {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
id: agents.id,
|
||||||
|
companyId: agents.companyId,
|
||||||
|
name: agents.name,
|
||||||
|
status: agents.status,
|
||||||
|
reportsTo: agents.reportsTo,
|
||||||
|
})
|
||||||
|
.from(agents)
|
||||||
|
.where(eq(agents.id, agentId))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listCompanyAgents(db: Db, companyId: string): Promise<AssignabilityAgent[]> {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
id: agents.id,
|
||||||
|
companyId: agents.companyId,
|
||||||
|
name: agents.name,
|
||||||
|
status: agents.status,
|
||||||
|
reportsTo: agents.reportsTo,
|
||||||
|
})
|
||||||
|
.from(agents)
|
||||||
|
.where(eq(agents.companyId, companyId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignmentReasonFromHealth(health: AgentOrgChainHealth): AgentAssignmentConflictReason {
|
||||||
|
if (health.reason === "terminated_ancestor") return "ancestor_terminated";
|
||||||
|
if (health.reason === "missing_manager") return "ancestor_missing";
|
||||||
|
if (health.reason === "cycle") return "ancestor_cycle";
|
||||||
|
return "ancestor_missing";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertAssignableAgent(
|
||||||
|
db: Db,
|
||||||
|
companyId: string,
|
||||||
|
agentId: string | null | undefined,
|
||||||
|
options: { kind?: AgentAssignmentKind } = {},
|
||||||
|
) {
|
||||||
|
if (!agentId) return;
|
||||||
|
const kind = options.kind ?? "work";
|
||||||
|
const assignee = await getAgent(db, agentId);
|
||||||
|
if (!assignee) throw notFound("Assignee agent not found");
|
||||||
|
if (assignee.companyId !== companyId) {
|
||||||
|
throw unprocessable("Assignee must belong to same company");
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyAgents = await listCompanyAgents(db, companyId);
|
||||||
|
const eligibility = getAgentWorkEligibility({ agent: assignee, agents: companyAgents });
|
||||||
|
const chain = eligibility.orgChainHealth.fullChain.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
companyId: entry.companyId,
|
||||||
|
name: entry.name,
|
||||||
|
status: entry.status,
|
||||||
|
reportsTo: entry.reportsTo,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (eligibility.assignable) return;
|
||||||
|
|
||||||
|
if (eligibility.assignabilityReason === "pending_approval") {
|
||||||
|
throw conflict(assignmentMessage(kind, "pending_approval"), conflictDetails({
|
||||||
|
companyId,
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
reason: "pending_approval",
|
||||||
|
chain,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (eligibility.assignabilityReason === "terminated") {
|
||||||
|
throw conflict(assignmentMessage(kind, "assignee_terminated"), conflictDetails({
|
||||||
|
companyId,
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
reason: "assignee_terminated",
|
||||||
|
chain,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (eligibility.assignabilityReason === "unknown_status") {
|
||||||
|
throw conflict(assignmentMessage(kind, "assignee_unknown_status"), conflictDetails({
|
||||||
|
companyId,
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
reason: "assignee_unknown_status",
|
||||||
|
chain,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = assignmentReasonFromHealth(eligibility.orgChainHealth);
|
||||||
|
const firstInvalidAncestor = eligibility.orgChainHealth.firstInvalidAncestor;
|
||||||
|
throw conflict(assignmentMessage(kind, reason), conflictDetails({
|
||||||
|
companyId,
|
||||||
|
assigneeAgentId: agentId,
|
||||||
|
reason,
|
||||||
|
chain,
|
||||||
|
invalidAncestorAgentId:
|
||||||
|
firstInvalidAncestor && firstInvalidAncestor.status !== "missing"
|
||||||
|
? firstInvalidAncestor.id
|
||||||
|
: null,
|
||||||
|
missingAncestorAgentId:
|
||||||
|
firstInvalidAncestor?.status === "missing"
|
||||||
|
? firstInvalidAncestor.id
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import type { Db } from "@paperclipai/db";
|
||||||
|
import { agents } from "@paperclipai/db";
|
||||||
|
import { getAgentWorkEligibility, type AgentEligibilityAgent, type AgentOrgChainHealth } from "@paperclipai/shared";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
type AgentStatus = (typeof agents.$inferSelect)["status"];
|
||||||
|
|
||||||
|
export type AgentOrgRow = Pick<
|
||||||
|
typeof agents.$inferSelect,
|
||||||
|
"id" | "companyId" | "name" | "reportsTo" | "status"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type AgentInvokabilityBlockReason =
|
||||||
|
| "missing"
|
||||||
|
| "paused"
|
||||||
|
| "terminated"
|
||||||
|
| "pending_approval"
|
||||||
|
| "unknown_status"
|
||||||
|
| "manager_missing"
|
||||||
|
| "manager_company_mismatch"
|
||||||
|
| "manager_terminated"
|
||||||
|
| "reporting_cycle"
|
||||||
|
| "reporting_chain_too_deep";
|
||||||
|
|
||||||
|
export type AgentInvokability =
|
||||||
|
| { invokable: true }
|
||||||
|
| {
|
||||||
|
invokable: false;
|
||||||
|
reason: AgentInvokabilityBlockReason;
|
||||||
|
message: string;
|
||||||
|
details: Record<string, unknown>;
|
||||||
|
invalidOrgChain: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIRECT_NON_INVOKABLE_STATUSES = new Set<AgentStatus>([
|
||||||
|
"paused",
|
||||||
|
"terminated",
|
||||||
|
"pending_approval",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function blocked(
|
||||||
|
reason: AgentInvokabilityBlockReason,
|
||||||
|
message: string,
|
||||||
|
details: Record<string, unknown>,
|
||||||
|
invalidOrgChain = false,
|
||||||
|
): AgentInvokability {
|
||||||
|
return { invokable: false, reason, message, details, invalidOrgChain };
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBlockReason(status: AgentStatus): AgentInvokabilityBlockReason | null {
|
||||||
|
if (status === "paused") return "paused";
|
||||||
|
if (status === "terminated") return "terminated";
|
||||||
|
if (status === "pending_approval") return "pending_approval";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEligibilityAgent(row: AgentOrgRow): AgentEligibilityAgent {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
companyId: row.companyId,
|
||||||
|
name: row.name,
|
||||||
|
status: row.status,
|
||||||
|
reportsTo: row.reportsTo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidChainReason(health: AgentOrgChainHealth): AgentInvokabilityBlockReason {
|
||||||
|
if (health.reason === "terminated_ancestor") return "manager_terminated";
|
||||||
|
if (health.reason === "cycle") return "reporting_cycle";
|
||||||
|
return "manager_missing";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateAgentInvokability(
|
||||||
|
agent: AgentOrgRow | null | undefined,
|
||||||
|
companyAgents: AgentOrgRow[],
|
||||||
|
): AgentInvokability {
|
||||||
|
if (!agent) {
|
||||||
|
return blocked("missing", "Agent no longer exists", {}, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eligibility = getAgentWorkEligibility({
|
||||||
|
agent: toEligibilityAgent(agent),
|
||||||
|
agents: companyAgents.map(toEligibilityAgent),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (eligibility.invokable) return { invokable: true };
|
||||||
|
|
||||||
|
const directStatusReason = eligibility.invokabilityReason === "unknown_status"
|
||||||
|
? "unknown_status"
|
||||||
|
: statusBlockReason(agent.status);
|
||||||
|
if (directStatusReason) {
|
||||||
|
return blocked(
|
||||||
|
directStatusReason,
|
||||||
|
"Agent is not invokable in its current state",
|
||||||
|
{ agentId: agent.id, agentStatus: agent.status },
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const health = eligibility.orgChainHealth;
|
||||||
|
const firstInvalidAncestor = health.firstInvalidAncestor;
|
||||||
|
return blocked(
|
||||||
|
invalidChainReason(health),
|
||||||
|
"Agent is not invokable because its reporting chain is invalid",
|
||||||
|
{
|
||||||
|
agentId: agent.id,
|
||||||
|
managerId: firstInvalidAncestor?.id ?? null,
|
||||||
|
managerStatus: firstInvalidAncestor?.status ?? null,
|
||||||
|
reportingChainAgentIds: health.fullChain
|
||||||
|
.filter((entry) => entry.relation === "ancestor")
|
||||||
|
.map((entry) => entry.id),
|
||||||
|
orgChainHealth: health,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function evaluateAgentInvokabilityFromDb(
|
||||||
|
db: Db,
|
||||||
|
agent: AgentOrgRow | null | undefined,
|
||||||
|
): Promise<AgentInvokability> {
|
||||||
|
if (!agent) return evaluateAgentInvokability(agent, []);
|
||||||
|
const companyAgents = await db
|
||||||
|
.select({
|
||||||
|
id: agents.id,
|
||||||
|
companyId: agents.companyId,
|
||||||
|
name: agents.name,
|
||||||
|
reportsTo: agents.reportsTo,
|
||||||
|
status: agents.status,
|
||||||
|
})
|
||||||
|
.from(agents)
|
||||||
|
.where(eq(agents.companyId, agent.companyId));
|
||||||
|
return evaluateAgentInvokability(agent, companyAgents);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listInvalidOrgChainDescendantIds(
|
||||||
|
terminatedAgentId: string,
|
||||||
|
companyAgents: AgentOrgRow[],
|
||||||
|
): string[] {
|
||||||
|
const byManager = new Map<string | null, AgentOrgRow[]>();
|
||||||
|
for (const row of companyAgents) {
|
||||||
|
const siblings = byManager.get(row.reportsTo ?? null) ?? [];
|
||||||
|
siblings.push(row);
|
||||||
|
byManager.set(row.reportsTo ?? null, siblings);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidDescendantIds: string[] = [];
|
||||||
|
const stack = [...(byManager.get(terminatedAgentId) ?? [])];
|
||||||
|
const seen = new Set<string>([terminatedAgentId]);
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop();
|
||||||
|
if (!current || seen.has(current.id)) continue;
|
||||||
|
seen.add(current.id);
|
||||||
|
if (current.status !== "terminated") {
|
||||||
|
invalidDescendantIds.push(current.id);
|
||||||
|
}
|
||||||
|
stack.push(...(byManager.get(current.id) ?? []));
|
||||||
|
}
|
||||||
|
return invalidDescendantIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldCancelRunsForNonInvokableAgent(result: AgentInvokability) {
|
||||||
|
return !result.invokable && (result.reason === "terminated" || result.invalidOrgChain);
|
||||||
|
}
|
||||||
@@ -16,7 +16,13 @@ import {
|
|||||||
issues,
|
issues,
|
||||||
issueComments,
|
issueComments,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, isUuidLike, normalizeAgentUrlKey } from "@paperclipai/shared";
|
import {
|
||||||
|
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
|
||||||
|
getAgentWorkEligibility,
|
||||||
|
isUuidLike,
|
||||||
|
normalizeAgentUrlKey,
|
||||||
|
type AgentEligibilityAgent,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||||
import { normalizeAgentPermissions } from "./agent-permissions.js";
|
import { normalizeAgentPermissions } from "./agent-permissions.js";
|
||||||
import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js";
|
import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js";
|
||||||
@@ -228,13 +234,45 @@ export function agentService(db: Db) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAgentRow(row: typeof agents.$inferSelect) {
|
function normalizeAgentBaseRow(row: typeof agents.$inferSelect) {
|
||||||
return withUrlKey({
|
return withUrlKey({
|
||||||
...row,
|
...row,
|
||||||
permissions: normalizeAgentPermissions(row.permissions, row.role),
|
permissions: normalizeAgentPermissions(row.permissions, row.role),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toEligibilityAgent(row: Pick<typeof agents.$inferSelect, "id" | "companyId" | "name" | "status" | "reportsTo">): AgentEligibilityAgent {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
companyId: row.companyId,
|
||||||
|
name: row.name,
|
||||||
|
status: row.status,
|
||||||
|
reportsTo: row.reportsTo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAgentRows(rows: (typeof agents.$inferSelect)[], allCompanyRows = rows) {
|
||||||
|
const eligibilityAgents = allCompanyRows.map(toEligibilityAgent);
|
||||||
|
return rows.map((row) => {
|
||||||
|
const base = normalizeAgentBaseRow(row);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
orgChainHealth: getAgentWorkEligibility({
|
||||||
|
agent: toEligibilityAgent(row),
|
||||||
|
agents: eligibilityAgents,
|
||||||
|
}).orgChainHealth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAgentRow(row: typeof agents.$inferSelect, allCompanyRows?: (typeof agents.$inferSelect)[]) {
|
||||||
|
return normalizeAgentRows([row], allCompanyRows)[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listCompanyAgentRows(companyId: string) {
|
||||||
|
return db.select().from(agents).where(eq(agents.companyId, companyId));
|
||||||
|
}
|
||||||
|
|
||||||
async function getMonthlySpendByAgentIds(companyId: string, agentIds: string[]) {
|
async function getMonthlySpendByAgentIds(companyId: string, agentIds: string[]) {
|
||||||
if (agentIds.length === 0) return new Map<string, number>();
|
if (agentIds.length === 0) return new Map<string, number>();
|
||||||
const { start, end } = currentUtcMonthWindow();
|
const { start, end } = currentUtcMonthWindow();
|
||||||
@@ -274,8 +312,17 @@ export function agentService(db: Db) {
|
|||||||
.where(eq(agents.id, id))
|
.where(eq(agents.id, id))
|
||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
const [hydrated] = await hydrateAgentSpend([row]);
|
const [companyRows, hydrated] = await Promise.all([
|
||||||
return normalizeAgentRow(hydrated);
|
listCompanyAgentRows(row.companyId),
|
||||||
|
hydrateAgentSpend([row]).then((rows) => rows[0]!),
|
||||||
|
]);
|
||||||
|
return normalizeAgentRow(hydrated, companyRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireGetById(id: string) {
|
||||||
|
const agent = await getById(id);
|
||||||
|
if (!agent) throw notFound("Agent not found");
|
||||||
|
return agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureManager(companyId: string, managerId: string) {
|
async function ensureManager(companyId: string, managerId: string) {
|
||||||
@@ -374,7 +421,7 @@ export function agentService(db: Db) {
|
|||||||
.where(eq(agents.id, id))
|
.where(eq(agents.id, id))
|
||||||
.returning()
|
.returning()
|
||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
const normalizedUpdated = updated ? normalizeAgentRow(updated) : null;
|
const normalizedUpdated = updated ? await getById(updated.id) : null;
|
||||||
|
|
||||||
if (normalizedUpdated && shouldRecordRevision && beforeConfig) {
|
if (normalizedUpdated && shouldRecordRevision && beforeConfig) {
|
||||||
const afterConfig = buildConfigSnapshot(normalizedUpdated);
|
const afterConfig = buildConfigSnapshot(normalizedUpdated);
|
||||||
@@ -403,9 +450,12 @@ export function agentService(db: Db) {
|
|||||||
if (!options?.includeTerminated) {
|
if (!options?.includeTerminated) {
|
||||||
conditions.push(ne(agents.status, "terminated"));
|
conditions.push(ne(agents.status, "terminated"));
|
||||||
}
|
}
|
||||||
const rows = await db.select().from(agents).where(and(...conditions));
|
const [rows, allCompanyRows] = await Promise.all([
|
||||||
|
db.select().from(agents).where(and(...conditions)),
|
||||||
|
listCompanyAgentRows(companyId),
|
||||||
|
]);
|
||||||
const hydrated = await hydrateAgentSpend(rows);
|
const hydrated = await hydrateAgentSpend(rows);
|
||||||
return hydrated.map(normalizeAgentRow);
|
return normalizeAgentRows(hydrated, allCompanyRows);
|
||||||
},
|
},
|
||||||
|
|
||||||
getById,
|
getById,
|
||||||
@@ -430,7 +480,7 @@ export function agentService(db: Db) {
|
|||||||
.returning()
|
.returning()
|
||||||
.then((rows) => rows[0]);
|
.then((rows) => rows[0]);
|
||||||
|
|
||||||
return normalizeAgentRow(created);
|
return requireGetById(created.id);
|
||||||
},
|
},
|
||||||
|
|
||||||
update: updateAgent,
|
update: updateAgent,
|
||||||
@@ -451,7 +501,7 @@ export function agentService(db: Db) {
|
|||||||
.where(eq(agents.id, id))
|
.where(eq(agents.id, id))
|
||||||
.returning()
|
.returning()
|
||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
return updated ? normalizeAgentRow(updated) : null;
|
return updated ? getById(updated.id) : null;
|
||||||
},
|
},
|
||||||
|
|
||||||
resume: async (id: string) => {
|
resume: async (id: string) => {
|
||||||
@@ -473,7 +523,7 @@ export function agentService(db: Db) {
|
|||||||
.where(eq(agents.id, id))
|
.where(eq(agents.id, id))
|
||||||
.returning()
|
.returning()
|
||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
return updated ? normalizeAgentRow(updated) : null;
|
return updated ? getById(updated.id) : null;
|
||||||
},
|
},
|
||||||
|
|
||||||
terminate: async (id: string) => {
|
terminate: async (id: string) => {
|
||||||
@@ -540,7 +590,7 @@ export function agentService(db: Db) {
|
|||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
|
|
||||||
if (updated) {
|
if (updated) {
|
||||||
return { agent: normalizeAgentRow(updated), activated: true };
|
return { agent: await requireGetById(updated.id), activated: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await getById(id);
|
const existing = await getById(id);
|
||||||
@@ -561,7 +611,7 @@ export function agentService(db: Db) {
|
|||||||
.returning()
|
.returning()
|
||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
|
|
||||||
return updated ? normalizeAgentRow(updated) : null;
|
return updated ? getById(updated.id) : null;
|
||||||
},
|
},
|
||||||
|
|
||||||
listConfigRevisions: async (id: string) =>
|
listConfigRevisions: async (id: string) =>
|
||||||
@@ -670,14 +720,12 @@ export function agentService(db: Db) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
orgForCompany: async (companyId: string) => {
|
orgForCompany: async (companyId: string) => {
|
||||||
const rows = await db
|
const allCompanyRows = await listCompanyAgentRows(companyId);
|
||||||
.select()
|
const rows = allCompanyRows.filter((row) => row.status !== "terminated");
|
||||||
.from(agents)
|
const normalizedRows = normalizeAgentRows(rows, allCompanyRows);
|
||||||
.where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated")));
|
|
||||||
const normalizedRows = rows.map(normalizeAgentRow);
|
|
||||||
const byManager = new Map<string | null, typeof normalizedRows>();
|
const byManager = new Map<string | null, typeof normalizedRows>();
|
||||||
for (const row of normalizedRows) {
|
for (const row of normalizedRows) {
|
||||||
const key = row.reportsTo ?? null;
|
const key = row.reportsTo && rows.some((candidate) => candidate.id === row.reportsTo) ? row.reportsTo : null;
|
||||||
const group = byManager.get(key) ?? [];
|
const group = byManager.get(key) ?? [];
|
||||||
group.push(row);
|
group.push(row);
|
||||||
byManager.set(key, group);
|
byManager.set(key, group);
|
||||||
@@ -735,8 +783,7 @@ export function agentService(db: Db) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rows = await db.select().from(agents).where(eq(agents.companyId, companyId));
|
const rows = await db.select().from(agents).where(eq(agents.companyId, companyId));
|
||||||
const matches = rows
|
const matches = normalizeAgentRows(rows, rows)
|
||||||
.map(normalizeAgentRow)
|
|
||||||
.filter((agent) => agent.urlKey === urlKey && agent.status !== "terminated");
|
.filter((agent) => agent.urlKey === urlKey && agent.status !== "terminated");
|
||||||
if (matches.length === 1) {
|
if (matches.length === 1) {
|
||||||
return { agent: matches[0] ?? null, ambiguous: false } as const;
|
return { agent: matches[0] ?? null, ambiguous: false } as const;
|
||||||
|
|||||||
@@ -155,6 +155,12 @@ import {
|
|||||||
import { recoveryService } from "./recovery/service.js";
|
import { recoveryService } from "./recovery/service.js";
|
||||||
import { productivityReviewService } from "./productivity-review.js";
|
import { productivityReviewService } from "./productivity-review.js";
|
||||||
import { withAgentStartLock } from "./agent-start-lock.js";
|
import { withAgentStartLock } from "./agent-start-lock.js";
|
||||||
|
import {
|
||||||
|
evaluateAgentInvokability,
|
||||||
|
evaluateAgentInvokabilityFromDb,
|
||||||
|
shouldCancelRunsForNonInvokableAgent,
|
||||||
|
type AgentOrgRow,
|
||||||
|
} from "./agent-invokability.js";
|
||||||
import {
|
import {
|
||||||
redactQuarantinedBodyForHigherTrust,
|
redactQuarantinedBodyForHigherTrust,
|
||||||
sanitizeQuarantinedCommentForHigherTrust,
|
sanitizeQuarantinedCommentForHigherTrust,
|
||||||
@@ -3061,6 +3067,46 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getAgentInvokability(agent: typeof agents.$inferSelect | null | undefined) {
|
||||||
|
return evaluateAgentInvokabilityFromDb(db, agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAgentOrgRow(agent: Pick<typeof agents.$inferSelect, "id" | "companyId" | "name" | "reportsTo" | "status">): AgentOrgRow {
|
||||||
|
return {
|
||||||
|
id: agent.id,
|
||||||
|
companyId: agent.companyId,
|
||||||
|
name: agent.name,
|
||||||
|
reportsTo: agent.reportsTo,
|
||||||
|
status: agent.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listCompanyAgentOrgRows(companyId: string): Promise<AgentOrgRow[]> {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
id: agents.id,
|
||||||
|
companyId: agents.companyId,
|
||||||
|
name: agents.name,
|
||||||
|
reportsTo: agents.reportsTo,
|
||||||
|
status: agents.status,
|
||||||
|
})
|
||||||
|
.from(agents)
|
||||||
|
.where(eq(agents.companyId, companyId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupAgentOrgRowsByCompany(agentRows: AgentOrgRow[]) {
|
||||||
|
const byCompany = new Map<string, AgentOrgRow[]>();
|
||||||
|
for (const agent of agentRows) {
|
||||||
|
const companyAgents = byCompany.get(agent.companyId);
|
||||||
|
if (companyAgents) {
|
||||||
|
companyAgents.push(agent);
|
||||||
|
} else {
|
||||||
|
byCompany.set(agent.companyId, [agent]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return byCompany;
|
||||||
|
}
|
||||||
|
|
||||||
async function getRun(runId: string, opts?: { unsafeFullResultJson?: boolean }) {
|
async function getRun(runId: string, opts?: { unsafeFullResultJson?: boolean }) {
|
||||||
const safeForLegacyEncoding = !opts?.unsafeFullResultJson && await hasUnsafeTextProjectionDatabase();
|
const safeForLegacyEncoding = !opts?.unsafeFullResultJson && await hasUnsafeTextProjectionDatabase();
|
||||||
return db
|
return db
|
||||||
@@ -5146,6 +5192,22 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
agent: typeof agents.$inferSelect,
|
agent: typeof agents.$inferSelect,
|
||||||
issueId: string,
|
issueId: string,
|
||||||
) {
|
) {
|
||||||
|
const invokability = await getAgentInvokability(agent);
|
||||||
|
if (!invokability.invokable) {
|
||||||
|
await appendRunEvent(run, await nextRunEventSeq(run.id), {
|
||||||
|
eventType: "lifecycle",
|
||||||
|
stream: "system",
|
||||||
|
level: "warn",
|
||||||
|
message: "Missing-comment retry suppressed because the agent is not invokable",
|
||||||
|
payload: {
|
||||||
|
reason: invokability.reason,
|
||||||
|
invalidOrgChain: invokability.invalidOrgChain,
|
||||||
|
...invokability.details,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const contextSnapshot = parseObject(run.contextSnapshot);
|
const contextSnapshot = parseObject(run.contextSnapshot);
|
||||||
const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null);
|
const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null);
|
||||||
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
|
const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey);
|
||||||
@@ -5366,6 +5428,23 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
agent: typeof agents.$inferSelect,
|
agent: typeof agents.$inferSelect,
|
||||||
now: Date,
|
now: Date,
|
||||||
) {
|
) {
|
||||||
|
const invokability = await getAgentInvokability(agent);
|
||||||
|
if (!invokability.invokable) {
|
||||||
|
await appendRunEvent(run, await nextRunEventSeq(run.id), {
|
||||||
|
eventType: "lifecycle",
|
||||||
|
stream: "system",
|
||||||
|
level: "warn",
|
||||||
|
message: "Process-loss retry suppressed because the agent is not invokable",
|
||||||
|
payload: {
|
||||||
|
reason: invokability.reason,
|
||||||
|
invalidOrgChain: invokability.invalidOrgChain,
|
||||||
|
...invokability.details,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await releaseIssueExecutionAndPromote(run);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const contextSnapshot = parseObject(run.contextSnapshot);
|
const contextSnapshot = parseObject(run.contextSnapshot);
|
||||||
const issueId = readNonEmptyString(contextSnapshot.issueId);
|
const issueId = readNonEmptyString(contextSnapshot.issueId);
|
||||||
const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null);
|
const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null);
|
||||||
@@ -5516,15 +5595,16 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (agent.status === "paused" || agent.status === "terminated" || agent.status === "pending_approval") {
|
const agentInvokability = await getAgentInvokability(agent);
|
||||||
|
if (!agentInvokability.invokable) {
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
reason: "Scheduled retry suppressed because the agent is not invokable",
|
reason: "Scheduled retry suppressed because the agent is not invokable",
|
||||||
errorCode: "agent_not_invokable",
|
errorCode: "agent_not_invokable",
|
||||||
issueId,
|
issueId,
|
||||||
details: {
|
details: {
|
||||||
agentId: agent.id,
|
...agentInvokability.details,
|
||||||
agentStatus: agent.status,
|
invalidOrgChain: agentInvokability.invalidOrgChain,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -5896,6 +5976,35 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
maxAttempts,
|
maxAttempts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (retryReason !== MAX_TURN_CONTINUATION_RETRY_REASON) {
|
||||||
|
const invokability = await getAgentInvokability(agent);
|
||||||
|
if (!invokability.invokable) {
|
||||||
|
const contextSnapshot = parseObject(run.contextSnapshot);
|
||||||
|
const issueId = readNonEmptyString(contextSnapshot.issueId);
|
||||||
|
await appendRunEvent(run, await nextRunEventSeq(run.id), {
|
||||||
|
eventType: "lifecycle",
|
||||||
|
stream: "system",
|
||||||
|
level: "warn",
|
||||||
|
message: "Scheduled retry suppressed because the agent is not invokable",
|
||||||
|
payload: {
|
||||||
|
retryReason,
|
||||||
|
scheduledRetryAttempt: nextAttempt,
|
||||||
|
maxAttempts,
|
||||||
|
reason: invokability.reason,
|
||||||
|
invalidOrgChain: invokability.invalidOrgChain,
|
||||||
|
...invokability.details,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
outcome: "not_scheduled" as const,
|
||||||
|
reason: "Scheduled retry suppressed because the agent is not invokable",
|
||||||
|
errorCode: "agent_not_invokable" as const,
|
||||||
|
issueId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const schedule =
|
const schedule =
|
||||||
transientRetryNotBefore && transientRetryNotBefore.getTime() > baseSchedule.dueAt.getTime()
|
transientRetryNotBefore && transientRetryNotBefore.getTime() > baseSchedule.dueAt.getTime()
|
||||||
? {
|
? {
|
||||||
@@ -6514,15 +6623,18 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
return Number(count ?? 0);
|
return Number(count ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function claimQueuedRun(run: typeof heartbeatRuns.$inferSelect) {
|
async function claimQueuedRun(run: typeof heartbeatRuns.$inferSelect, companyAgents?: AgentOrgRow[]) {
|
||||||
if (run.status !== "queued") return run;
|
if (run.status !== "queued") return run;
|
||||||
const agent = await getAgent(run.agentId);
|
const agent = await getAgent(run.agentId);
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
await cancelRunInternal(run.id, "Cancelled because the agent no longer exists");
|
await cancelRunInternal(run.id, "Cancelled because the agent no longer exists");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (agent.status === "paused" || agent.status === "terminated" || agent.status === "pending_approval") {
|
const invokability = companyAgents
|
||||||
await cancelRunInternal(run.id, "Cancelled because the agent is not invokable");
|
? evaluateAgentInvokability(toAgentOrgRow(agent), companyAgents)
|
||||||
|
: await getAgentInvokability(agent);
|
||||||
|
if (!invokability.invokable) {
|
||||||
|
await cancelRunInternal(run.id, `Cancelled because the agent is not invokable: ${invokability.reason}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7433,7 +7545,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
return withAgentStartLock(agentId, async () => {
|
return withAgentStartLock(agentId, async () => {
|
||||||
const agent = await getAgent(agentId);
|
const agent = await getAgent(agentId);
|
||||||
if (!agent) return [];
|
if (!agent) return [];
|
||||||
if (agent.status === "paused" || agent.status === "terminated" || agent.status === "pending_approval") {
|
const invokability = await getAgentInvokability(agent);
|
||||||
|
if (!invokability.invokable) {
|
||||||
|
if (shouldCancelRunsForNonInvokableAgent(invokability)) {
|
||||||
|
await cancelActiveForAgentInternal(agentId, `Cancelled because the agent is not invokable: ${invokability.reason}`);
|
||||||
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const policy = parseHeartbeatPolicy(agent);
|
const policy = parseHeartbeatPolicy(agent);
|
||||||
@@ -7467,6 +7583,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
: sql`false`,
|
: sql`false`,
|
||||||
);
|
);
|
||||||
const issueById = new Map(issueRows.map((row) => [row.id, row]));
|
const issueById = new Map(issueRows.map((row) => [row.id, row]));
|
||||||
|
const companyAgents = await listCompanyAgentOrgRows(agent.companyId);
|
||||||
const prioritizedRuns = [...queuedRuns].sort((left, right) => {
|
const prioritizedRuns = [...queuedRuns].sort((left, right) => {
|
||||||
const leftIssueId = readNonEmptyString(parseObject(left.contextSnapshot).issueId);
|
const leftIssueId = readNonEmptyString(parseObject(left.contextSnapshot).issueId);
|
||||||
const rightIssueId = readNonEmptyString(parseObject(right.contextSnapshot).issueId);
|
const rightIssueId = readNonEmptyString(parseObject(right.contextSnapshot).issueId);
|
||||||
@@ -7488,7 +7605,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
const claimedRuns: Array<typeof heartbeatRuns.$inferSelect> = [];
|
const claimedRuns: Array<typeof heartbeatRuns.$inferSelect> = [];
|
||||||
for (const queuedRun of prioritizedRuns) {
|
for (const queuedRun of prioritizedRuns) {
|
||||||
if (claimedRuns.length >= availableSlots) break;
|
if (claimedRuns.length >= availableSlots) break;
|
||||||
const claimed = await claimQueuedRun(queuedRun);
|
const claimed = await claimQueuedRun(queuedRun, companyAgents);
|
||||||
if (claimed) claimedRuns.push(claimed);
|
if (claimed) claimedRuns.push(claimed);
|
||||||
}
|
}
|
||||||
if (claimedRuns.length === 0) return [];
|
if (claimedRuns.length === 0) return [];
|
||||||
@@ -9278,13 +9395,24 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
.where(eq(agents.id, deferred.agentId))
|
.where(eq(agents.id, deferred.agentId))
|
||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
|
|
||||||
if (
|
const companyAgents = deferredAgent
|
||||||
!deferredAgent ||
|
? await tx
|
||||||
deferredAgent.companyId !== issue.companyId ||
|
.select({
|
||||||
deferredAgent.status === "paused" ||
|
id: agents.id,
|
||||||
deferredAgent.status === "terminated" ||
|
companyId: agents.companyId,
|
||||||
deferredAgent.status === "pending_approval"
|
name: agents.name,
|
||||||
) {
|
reportsTo: agents.reportsTo,
|
||||||
|
status: agents.status,
|
||||||
|
})
|
||||||
|
.from(agents)
|
||||||
|
.where(eq(agents.companyId, issue.companyId))
|
||||||
|
: [];
|
||||||
|
const deferredInvokability =
|
||||||
|
deferredAgent?.companyId === issue.companyId
|
||||||
|
? evaluateAgentInvokability(deferredAgent, companyAgents)
|
||||||
|
: evaluateAgentInvokability(null, companyAgents);
|
||||||
|
|
||||||
|
if (!deferredAgent || deferredAgent.companyId !== issue.companyId || !deferredInvokability.invokable) {
|
||||||
await tx
|
await tx
|
||||||
.update(agentWakeupRequests)
|
.update(agentWakeupRequests)
|
||||||
.set({
|
.set({
|
||||||
@@ -9767,12 +9895,19 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const invokability = await getAgentInvokability(agent);
|
||||||
agent.status === "paused" ||
|
if (!invokability.invokable) {
|
||||||
agent.status === "terminated" ||
|
if (opts.requestedByActorType !== "user") {
|
||||||
agent.status === "pending_approval"
|
await writeSkippedRequest("agent.not_invokable", {
|
||||||
) {
|
error: invokability.message,
|
||||||
throw conflict("Agent is not invokable in its current state", { status: agent.status });
|
});
|
||||||
|
}
|
||||||
|
throw conflict(invokability.message, {
|
||||||
|
status: agent.status,
|
||||||
|
reason: invokability.reason,
|
||||||
|
invalidOrgChain: invokability.invalidOrgChain,
|
||||||
|
...invokability.details,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const policy = parseHeartbeatPolicy(agent);
|
const policy = parseHeartbeatPolicy(agent);
|
||||||
@@ -10576,6 +10711,52 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
return runs.length;
|
return runs.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function cancelPendingWakeupsForAgentsInternal(agentIds: string[], reason: string) {
|
||||||
|
const uniqueAgentIds = [...new Set(agentIds)].filter((agentId) => agentId.length > 0);
|
||||||
|
if (uniqueAgentIds.length === 0) return 0;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const wakeupIds = await db
|
||||||
|
.select({ id: agentWakeupRequests.id })
|
||||||
|
.from(agentWakeupRequests)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(agentWakeupRequests.agentId, uniqueAgentIds),
|
||||||
|
inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution"]),
|
||||||
|
sql`${agentWakeupRequests.runId} is null`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then((rows) => rows.map((row) => row.id));
|
||||||
|
|
||||||
|
if (wakeupIds.length === 0) return 0;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(agentWakeupRequests)
|
||||||
|
.set({
|
||||||
|
status: "cancelled",
|
||||||
|
finishedAt: now,
|
||||||
|
error: reason,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(inArray(agentWakeupRequests.id, wakeupIds));
|
||||||
|
|
||||||
|
return wakeupIds.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelInvocationsForAgentsInternal(agentIds: string[], reason: string) {
|
||||||
|
const uniqueAgentIds = [...new Set(agentIds)].filter((agentId) => agentId.length > 0);
|
||||||
|
let runsCancelled = 0;
|
||||||
|
for (const agentId of uniqueAgentIds) {
|
||||||
|
runsCancelled += await cancelActiveForAgentInternal(agentId, reason);
|
||||||
|
}
|
||||||
|
const wakeupsCancelled = await cancelPendingWakeupsForAgentsInternal(uniqueAgentIds, reason);
|
||||||
|
return {
|
||||||
|
agentIds: uniqueAgentIds,
|
||||||
|
runsCancelled,
|
||||||
|
wakeupsCancelled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function cancelBudgetScopeWork(scope: BudgetEnforcementScope) {
|
async function cancelBudgetScopeWork(scope: BudgetEnforcementScope) {
|
||||||
if (scope.scopeType === "agent") {
|
if (scope.scopeType === "agent") {
|
||||||
await cancelActiveForAgentInternal(scope.scopeId, "Cancelled due to budget pause");
|
await cancelActiveForAgentInternal(scope.scopeId, "Cancelled due to budget pause");
|
||||||
@@ -10877,12 +11058,14 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
.from(agents)
|
.from(agents)
|
||||||
.innerJoin(companies, eq(companies.id, agents.companyId))
|
.innerJoin(companies, eq(companies.id, agents.companyId))
|
||||||
.where(eq(companies.status, "active"));
|
.where(eq(companies.status, "active"));
|
||||||
|
const agentsByCompany = groupAgentOrgRowsByCompany(allAgents.map(toAgentOrgRow));
|
||||||
let checked = 0;
|
let checked = 0;
|
||||||
let enqueued = 0;
|
let enqueued = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
|
|
||||||
for (const agent of allAgents) {
|
for (const agent of allAgents) {
|
||||||
if (agent.status === "paused" || agent.status === "terminated" || agent.status === "pending_approval") continue;
|
const invokability = evaluateAgentInvokability(toAgentOrgRow(agent), agentsByCompany.get(agent.companyId) ?? []);
|
||||||
|
if (!invokability.invokable) continue;
|
||||||
const policy = parseHeartbeatPolicy(agent);
|
const policy = parseHeartbeatPolicy(agent);
|
||||||
if (!policy.enabled || policy.intervalSec <= 0) continue;
|
if (!policy.enabled || policy.intervalSec <= 0) continue;
|
||||||
|
|
||||||
@@ -10920,6 +11103,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||||||
|
|
||||||
cancelActiveForAgent: (agentId: string, reason?: string) => cancelActiveForAgentInternal(agentId, reason),
|
cancelActiveForAgent: (agentId: string, reason?: string) => cancelActiveForAgentInternal(agentId, reason),
|
||||||
|
|
||||||
|
cancelInvocationsForAgents: (agentIds: string[], reason: string) =>
|
||||||
|
cancelInvocationsForAgentsInternal(agentIds, reason),
|
||||||
|
|
||||||
cancelBudgetScopeWork,
|
cancelBudgetScopeWork,
|
||||||
|
|
||||||
getRunIssueSummary: async (runId: string) => {
|
getRunIssueSummary: async (runId: string) => {
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ import { redactSensitiveText } from "../redaction.js";
|
|||||||
import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallback.js";
|
import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallback.js";
|
||||||
import { getRunLogStore } from "./run-log-store.js";
|
import { getRunLogStore } from "./run-log-store.js";
|
||||||
import { getDefaultCompanyGoal } from "./goals.js";
|
import { getDefaultCompanyGoal } from "./goals.js";
|
||||||
|
import { assertAssignableAgent } from "./agent-assignability.js";
|
||||||
import {
|
import {
|
||||||
isVerifiedIssueTreeControlInteractionWake,
|
isVerifiedIssueTreeControlInteractionWake,
|
||||||
issueTreeControlService,
|
issueTreeControlService,
|
||||||
@@ -3372,29 +3373,6 @@ export function issueService(db: Db) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assertAssignableAgent(companyId: string, agentId: string) {
|
|
||||||
const assignee = await db
|
|
||||||
.select({
|
|
||||||
id: agents.id,
|
|
||||||
companyId: agents.companyId,
|
|
||||||
status: agents.status,
|
|
||||||
})
|
|
||||||
.from(agents)
|
|
||||||
.where(eq(agents.id, agentId))
|
|
||||||
.then((rows) => rows[0] ?? null);
|
|
||||||
|
|
||||||
if (!assignee) throw notFound("Assignee agent not found");
|
|
||||||
if (assignee.companyId !== companyId) {
|
|
||||||
throw unprocessable("Assignee must belong to same company");
|
|
||||||
}
|
|
||||||
if (assignee.status === "pending_approval") {
|
|
||||||
throw conflict("Cannot assign work to pending approval agents");
|
|
||||||
}
|
|
||||||
if (assignee.status === "terminated") {
|
|
||||||
throw conflict("Cannot assign work to terminated agents");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isTreeHoldInteractionCheckoutAllowed(
|
async function isTreeHoldInteractionCheckoutAllowed(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
checkoutRunId: string | null,
|
checkoutRunId: string | null,
|
||||||
@@ -4733,7 +4711,7 @@ export function issueService(db: Db) {
|
|||||||
throw unprocessable("Issue can only have one assignee");
|
throw unprocessable("Issue can only have one assignee");
|
||||||
}
|
}
|
||||||
if (data.assigneeAgentId) {
|
if (data.assigneeAgentId) {
|
||||||
await assertAssignableAgent(companyId, data.assigneeAgentId);
|
await assertAssignableAgent(db, companyId, data.assigneeAgentId, { kind: "work" });
|
||||||
}
|
}
|
||||||
if (data.assigneeUserId) {
|
if (data.assigneeUserId) {
|
||||||
await assertAssignableUser(companyId, data.assigneeUserId);
|
await assertAssignableUser(companyId, data.assigneeUserId);
|
||||||
@@ -5021,8 +4999,11 @@ export function issueService(db: Db) {
|
|||||||
throw unprocessable("Issue is blocked by unresolved blockers", { unresolvedBlockerIssueIds });
|
throw unprocessable("Issue is blocked by unresolved blockers", { unresolvedBlockerIssueIds });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (issueData.assigneeAgentId) {
|
const shouldValidateNextAssignee =
|
||||||
await assertAssignableAgent(existing.companyId, issueData.assigneeAgentId);
|
Boolean(nextAssigneeAgentId) &&
|
||||||
|
(issueData.assigneeAgentId !== undefined || patch.status === "in_progress");
|
||||||
|
if (shouldValidateNextAssignee) {
|
||||||
|
await assertAssignableAgent(dbOrTx as Db, existing.companyId, nextAssigneeAgentId, { kind: "work" });
|
||||||
}
|
}
|
||||||
if (issueData.assigneeUserId) {
|
if (issueData.assigneeUserId) {
|
||||||
await assertAssignableUser(existing.companyId, issueData.assigneeUserId);
|
await assertAssignableUser(existing.companyId, issueData.assigneeUserId);
|
||||||
@@ -5347,7 +5328,7 @@ export function issueService(db: Db) {
|
|||||||
.where(eq(issues.id, id))
|
.where(eq(issues.id, id))
|
||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
if (!issueCompany) throw notFound("Issue not found");
|
if (!issueCompany) throw notFound("Issue not found");
|
||||||
await assertAssignableAgent(issueCompany.companyId, agentId);
|
await assertAssignableAgent(db, issueCompany.companyId, agentId, { kind: "work" });
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const activePauseHold = await treeControlSvc.getActivePauseHoldGate(issueCompany.companyId, id);
|
const activePauseHold = await treeControlSvc.getActivePauseHoldGate(issueCompany.companyId, id);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getAgentWorkEligibility, isAgentInvokable } from "@paperclipai/shared";
|
||||||
import { buildIssueGraphLivenessIncidentKey } from "./origins.js";
|
import { buildIssueGraphLivenessIncidentKey } from "./origins.js";
|
||||||
|
|
||||||
export type IssueLivenessSeverity = "warning" | "critical";
|
export type IssueLivenessSeverity = "warning" | "critical";
|
||||||
@@ -106,9 +107,6 @@ export interface IssueGraphLivenessInput {
|
|||||||
now?: Date | string;
|
now?: Date | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const INVOKABLE_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]);
|
|
||||||
const BLOCKING_AGENT_STATUSES = new Set(["paused", "terminated", "pending_approval"]);
|
|
||||||
|
|
||||||
function issueLabel(issue: IssueLivenessIssueInput) {
|
function issueLabel(issue: IssueLivenessIssueInput) {
|
||||||
return issue.identifier ?? issue.id;
|
return issue.identifier ?? issue.id;
|
||||||
}
|
}
|
||||||
@@ -122,8 +120,11 @@ function pathEntry(issue: IssueLivenessIssueInput): IssueLivenessDependencyPathE
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isInvokableAgent(agent: IssueLivenessAgentInput | null | undefined) {
|
function isInvokableAgent(
|
||||||
return Boolean(agent && INVOKABLE_AGENT_STATUSES.has(agent.status));
|
agent: IssueLivenessAgentInput | null | undefined,
|
||||||
|
agentsById: Map<string, IssueLivenessAgentInput>,
|
||||||
|
) {
|
||||||
|
return Boolean(agent && isAgentInvokable({ agent, agents: [...agentsById.values()] }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasActiveExecutionPath(
|
function hasActiveExecutionPath(
|
||||||
@@ -209,7 +210,7 @@ function addOwnerCandidate(
|
|||||||
) {
|
) {
|
||||||
if (!agentId || seen.has(agentId)) return;
|
if (!agentId || seen.has(agentId)) return;
|
||||||
const agent = agentsById.get(agentId);
|
const agent = agentsById.get(agentId);
|
||||||
if (!agent || agent.companyId !== companyId || !isInvokableAgent(agent)) return;
|
if (!agent || agent.companyId !== companyId || !isInvokableAgent(agent, agentsById)) return;
|
||||||
seen.add(agentId);
|
seen.add(agentId);
|
||||||
candidates.push({ agentId, reason, sourceIssueId });
|
candidates.push({ agentId, reason, sourceIssueId });
|
||||||
}
|
}
|
||||||
@@ -236,9 +237,13 @@ function addAgentChainCandidates(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function orderedInvokableAgents(agents: IssueLivenessAgentInput[], companyId: string) {
|
function orderedInvokableAgents(
|
||||||
|
agents: IssueLivenessAgentInput[],
|
||||||
|
agentsById: Map<string, IssueLivenessAgentInput>,
|
||||||
|
companyId: string,
|
||||||
|
) {
|
||||||
return agents
|
return agents
|
||||||
.filter((agent) => agent.companyId === companyId && isInvokableAgent(agent))
|
.filter((agent) => agent.companyId === companyId && isInvokableAgent(agent, agentsById))
|
||||||
.sort((left, right) => left.id.localeCompare(right.id));
|
.sort((left, right) => left.id.localeCompare(right.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +289,7 @@ function ownerCandidatesForRecoveryIssue(
|
|||||||
issue.id,
|
issue.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
const invokableAgents = orderedInvokableAgents(agents, issue.companyId);
|
const invokableAgents = orderedInvokableAgents(agents, agentsById, issue.companyId);
|
||||||
for (const agent of invokableAgents) {
|
for (const agent of invokableAgents) {
|
||||||
if (!agent.reportsTo) {
|
if (!agent.reportsTo) {
|
||||||
addOwnerCandidate(candidates, seen, agentsById, issue.companyId, agent.id, "root_agent", issue.id);
|
addOwnerCandidate(candidates, seen, agentsById, issue.companyId, agent.id, "root_agent", issue.id);
|
||||||
@@ -419,7 +424,7 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
|
|||||||
const participantAgentId = readPrincipalAgentId(participant);
|
const participantAgentId = readPrincipalAgentId(participant);
|
||||||
if (participantAgentId) {
|
if (participantAgentId) {
|
||||||
const participantAgent = agentsById.get(participantAgentId);
|
const participantAgent = agentsById.get(participantAgentId);
|
||||||
if (isInvokableAgent(participantAgent) && participantAgent?.companyId === reviewIssue.companyId) return null;
|
if (isInvokableAgent(participantAgent, agentsById) && participantAgent?.companyId === reviewIssue.companyId) return null;
|
||||||
|
|
||||||
return finding({
|
return finding({
|
||||||
issue: source,
|
issue: source,
|
||||||
@@ -532,12 +537,15 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
|
|||||||
if (!blocker.assigneeAgentId) return null;
|
if (!blocker.assigneeAgentId) return null;
|
||||||
|
|
||||||
const blockerAgent = agentsById.get(blocker.assigneeAgentId);
|
const blockerAgent = agentsById.get(blocker.assigneeAgentId);
|
||||||
if (!blockerAgent || blockerAgent.companyId !== source.companyId || BLOCKING_AGENT_STATUSES.has(blockerAgent.status)) {
|
const blockerEligibility = blockerAgent
|
||||||
|
? getAgentWorkEligibility({ agent: blockerAgent, agents: input.agents })
|
||||||
|
: null;
|
||||||
|
if (!blockerAgent || blockerAgent.companyId !== source.companyId || !blockerEligibility?.invokable) {
|
||||||
return finding({
|
return finding({
|
||||||
issue: source,
|
issue: source,
|
||||||
state: "blocked_by_uninvokable_assignee",
|
state: "blocked_by_uninvokable_assignee",
|
||||||
reason: blockerAgent
|
reason: blockerAgent
|
||||||
? `${issueLabel(source)} is blocked by ${issueLabel(blocker)}, but its assignee is ${blockerAgent.status}.`
|
? `${issueLabel(source)} is blocked by ${issueLabel(blocker)}, but its assignee is ${blockerEligibility?.invokabilityReason === "invalid_org_chain" ? "in an invalid org chain" : blockerAgent.status}.`
|
||||||
: `${issueLabel(source)} is blocked by ${issueLabel(blocker)}, but its assignee no longer exists.`,
|
: `${issueLabel(source)} is blocked by ${issueLabel(blocker)}, but its assignee no longer exists.`,
|
||||||
dependencyPath,
|
dependencyPath,
|
||||||
recoveryIssue: blocker,
|
recoveryIssue: blocker,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { instanceSettingsService } from "../instance-settings.js";
|
|||||||
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
|
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
|
||||||
import { issueTreeControlService } from "../issue-tree-control.js";
|
import { issueTreeControlService } from "../issue-tree-control.js";
|
||||||
import { issueService } from "../issues.js";
|
import { issueService } from "../issues.js";
|
||||||
|
import { evaluateAgentInvokabilityFromDb } from "../agent-invokability.js";
|
||||||
import { getRunLogStore } from "../run-log-store.js";
|
import { getRunLogStore } from "../run-log-store.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS,
|
DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS,
|
||||||
@@ -327,10 +328,6 @@ function unwrapDatabaseConflictError(error: unknown) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAgentInvokable(agent: typeof agents.$inferSelect | null | undefined) {
|
|
||||||
return Boolean(agent && !["paused", "terminated", "pending_approval"].includes(agent.status));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isStrandedIssueRecoveryIssue(issue: Pick<typeof issues.$inferSelect, "originKind">) {
|
function isStrandedIssueRecoveryIssue(issue: Pick<typeof issues.$inferSelect, "originKind">) {
|
||||||
return isStrandedIssueRecoveryOriginKind(issue.originKind);
|
return isStrandedIssueRecoveryOriginKind(issue.originKind);
|
||||||
}
|
}
|
||||||
@@ -466,6 +463,10 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||||||
return db.select().from(agents).where(eq(agents.id, agentId)).then((rows) => rows[0] ?? null);
|
return db.select().from(agents).where(eq(agents.id, agentId)).then((rows) => rows[0] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function isAgentInvokable(agent: typeof agents.$inferSelect | null | undefined) {
|
||||||
|
return (await evaluateAgentInvokabilityFromDb(db, agent)).invokable;
|
||||||
|
}
|
||||||
|
|
||||||
async function getLatestIssueRun(companyId: string, issueId: string): Promise<LatestIssueRun> {
|
async function getLatestIssueRun(companyId: string, issueId: string): Promise<LatestIssueRun> {
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
@@ -697,7 +698,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const creatorAgent = await getAgent(creatorAgentId);
|
const creatorAgent = await getAgent(creatorAgentId);
|
||||||
if (!creatorAgent || creatorAgent.companyId !== candidate.companyId || !isAgentInvokable(creatorAgent)) {
|
if (!creatorAgent || creatorAgent.companyId !== candidate.companyId || !(await isAgentInvokable(creatorAgent))) {
|
||||||
skipped += 1;
|
skipped += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1249,7 +1250,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||||||
issueId: input.sourceIssue?.id ?? null,
|
issueId: input.sourceIssue?.id ?? null,
|
||||||
projectId: input.sourceIssue?.projectId ?? null,
|
projectId: input.sourceIssue?.projectId ?? null,
|
||||||
});
|
});
|
||||||
if (isAgentInvokable(candidate) && !budgetBlock) return candidate.id;
|
if ((await isAgentInvokable(candidate)) && !budgetBlock) return candidate.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -1872,7 +1873,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||||||
issueId: issue.id,
|
issueId: issue.id,
|
||||||
projectId: issue.projectId,
|
projectId: issue.projectId,
|
||||||
});
|
});
|
||||||
if (isAgentInvokable(candidate) && !budgetBlock) return candidate.id;
|
if ((await isAgentInvokable(candidate)) && !budgetBlock) return candidate.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -2486,7 +2487,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||||||
}
|
}
|
||||||
|
|
||||||
const agent = await getAgent(agentId);
|
const agent = await getAgent(agentId);
|
||||||
if (!agent || agent.companyId !== issue.companyId || !isAgentInvokable(agent)) {
|
if (!agent || agent.companyId !== issue.companyId || !(await isAgentInvokable(agent))) {
|
||||||
result.skipped += 1;
|
result.skipped += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import { logger } from "../middleware/logger.js";
|
|||||||
import { getTelemetryClient } from "../telemetry.js";
|
import { getTelemetryClient } from "../telemetry.js";
|
||||||
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
|
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
|
||||||
import { issueService } from "./issues.js";
|
import { issueService } from "./issues.js";
|
||||||
|
import { assertAssignableAgent } from "./agent-assignability.js";
|
||||||
import { secretService } from "./secrets.js";
|
import { secretService } from "./secrets.js";
|
||||||
import { getSecretProvider } from "../secrets/provider-registry.js";
|
import { getSecretProvider } from "../secrets/provider-registry.js";
|
||||||
import { parseCron, validateCron } from "./cron.js";
|
import { parseCron, validateCron } from "./cron.js";
|
||||||
@@ -610,25 +611,12 @@ export function routineService(
|
|||||||
return routine;
|
return routine;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assertAssignableAgent(companyId: string, agentId: string | null | undefined) {
|
|
||||||
if (!agentId) return;
|
|
||||||
const agent = await db
|
|
||||||
.select({ id: agents.id, companyId: agents.companyId, status: agents.status })
|
|
||||||
.from(agents)
|
|
||||||
.where(eq(agents.id, agentId))
|
|
||||||
.then((rows) => rows[0] ?? null);
|
|
||||||
if (!agent) throw notFound("Assignee agent not found");
|
|
||||||
if (agent.companyId !== companyId) throw unprocessable("Assignee must belong to same company");
|
|
||||||
if (agent.status === "pending_approval") throw conflict("Cannot assign routines to pending approval agents");
|
|
||||||
if (agent.status === "terminated") throw conflict("Cannot assign routines to terminated agents");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertRestorableAssignee(
|
async function assertRestorableAssignee(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
assigneeAgentId: string | null | undefined,
|
assigneeAgentId: string | null | undefined,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
) {
|
) {
|
||||||
await assertAssignableAgent(companyId, assigneeAgentId);
|
await assertAssignableAgent(db, companyId, assigneeAgentId, { kind: "routine" });
|
||||||
if (actor.agentId && assigneeAgentId !== actor.agentId) {
|
if (actor.agentId && assigneeAgentId !== actor.agentId) {
|
||||||
throw forbidden("Agents can only restore routine revisions assigned to themselves");
|
throw forbidden("Agents can only restore routine revisions assigned to themselves");
|
||||||
}
|
}
|
||||||
@@ -1170,6 +1158,7 @@ export function routineService(
|
|||||||
if (!assigneeAgentId) {
|
if (!assigneeAgentId) {
|
||||||
throw unprocessable("Default agent required");
|
throw unprocessable("Default agent required");
|
||||||
}
|
}
|
||||||
|
await assertAssignableAgent(db, input.routine.companyId, assigneeAgentId, { kind: "routine" });
|
||||||
const automaticVariables: Record<string, string | number | boolean> = {};
|
const automaticVariables: Record<string, string | number | boolean> = {};
|
||||||
if (input.executionWorkspaceId && routineUsesWorkspaceBranch(input.routine)) {
|
if (input.executionWorkspaceId && routineUsesWorkspaceBranch(input.routine)) {
|
||||||
const workspace = await db
|
const workspace = await db
|
||||||
@@ -1581,7 +1570,7 @@ export function routineService(
|
|||||||
|
|
||||||
create: async (companyId: string, input: CreateRoutine, actor: Actor): Promise<Routine> => {
|
create: async (companyId: string, input: CreateRoutine, actor: Actor): Promise<Routine> => {
|
||||||
await assertProject(companyId, input.projectId ?? null);
|
await assertProject(companyId, input.projectId ?? null);
|
||||||
await assertAssignableAgent(companyId, input.assigneeAgentId ?? null);
|
await assertAssignableAgent(db, companyId, input.assigneeAgentId ?? null, { kind: "routine" });
|
||||||
if (input.goalId) await assertGoal(companyId, input.goalId);
|
if (input.goalId) await assertGoal(companyId, input.goalId);
|
||||||
if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId);
|
if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId);
|
||||||
const env = input.env === undefined || input.env === null
|
const env = input.env === undefined || input.env === null
|
||||||
@@ -1663,7 +1652,9 @@ export function routineService(
|
|||||||
patch.variables === undefined ? existing.variables : sanitizeRoutineVariableInputs(patch.variables),
|
patch.variables === undefined ? existing.variables : sanitizeRoutineVariableInputs(patch.variables),
|
||||||
);
|
);
|
||||||
if (patch.projectId !== undefined) await assertProject(existing.companyId, nextProjectId);
|
if (patch.projectId !== undefined) await assertProject(existing.companyId, nextProjectId);
|
||||||
if (patch.assigneeAgentId !== undefined) await assertAssignableAgent(existing.companyId, nextAssigneeAgentId);
|
if (patch.assigneeAgentId !== undefined || patch.status === "active") {
|
||||||
|
await assertAssignableAgent(db, existing.companyId, nextAssigneeAgentId, { kind: "routine" });
|
||||||
|
}
|
||||||
if (patch.goalId) await assertGoal(existing.companyId, patch.goalId);
|
if (patch.goalId) await assertGoal(existing.companyId, patch.goalId);
|
||||||
if (patch.parentIssueId) await assertParentIssue(existing.companyId, patch.parentIssueId);
|
if (patch.parentIssueId) await assertParentIssue(existing.companyId, patch.parentIssueId);
|
||||||
assertRoutineVariableDefinitions(nextVariables);
|
assertRoutineVariableDefinitions(nextVariables);
|
||||||
@@ -2187,7 +2178,8 @@ export function routineService(
|
|||||||
if (!routine) throw notFound("Routine not found");
|
if (!routine) throw notFound("Routine not found");
|
||||||
if (routine.status === "archived") throw conflict("Routine is archived");
|
if (routine.status === "archived") throw conflict("Routine is archived");
|
||||||
await assertProject(routine.companyId, input.projectId ?? null);
|
await assertProject(routine.companyId, input.projectId ?? null);
|
||||||
await assertAssignableAgent(routine.companyId, input.assigneeAgentId ?? null);
|
const assigneeAgentId = input.assigneeAgentId ?? routine.assigneeAgentId ?? null;
|
||||||
|
await assertAssignableAgent(db, routine.companyId, assigneeAgentId, { kind: "routine" });
|
||||||
const trigger = input.triggerId ? await getTriggerById(input.triggerId) : null;
|
const trigger = input.triggerId ? await getTriggerById(input.triggerId) : null;
|
||||||
if (trigger && trigger.routineId !== routine.id) throw forbidden("Trigger does not belong to routine");
|
if (trigger && trigger.routineId !== routine.id) throw forbidden("Trigger does not belong to routine");
|
||||||
if (trigger && !trigger.enabled) throw conflict("Routine trigger is not active");
|
if (trigger && !trigger.enabled) throw conflict("Routine trigger is not active");
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ export function AgentActionButtons({
|
|||||||
runLabel = "Run now",
|
runLabel = "Run now",
|
||||||
showStatus = true,
|
showStatus = true,
|
||||||
actionsDisabled = false,
|
actionsDisabled = false,
|
||||||
|
workActionsDisabled = false,
|
||||||
|
workActionsDisabledReason,
|
||||||
navigateToRunOnInvoke = true,
|
navigateToRunOnInvoke = true,
|
||||||
onActionError,
|
onActionError,
|
||||||
children,
|
children,
|
||||||
@@ -138,6 +140,8 @@ export function AgentActionButtons({
|
|||||||
runLabel?: string;
|
runLabel?: string;
|
||||||
showStatus?: boolean;
|
showStatus?: boolean;
|
||||||
actionsDisabled?: boolean;
|
actionsDisabled?: boolean;
|
||||||
|
workActionsDisabled?: boolean;
|
||||||
|
workActionsDisabledReason?: string;
|
||||||
navigateToRunOnInvoke?: boolean;
|
navigateToRunOnInvoke?: boolean;
|
||||||
/**
|
/**
|
||||||
* Optional inline error reporter. When provided it is used instead of a toast
|
* Optional inline error reporter. When provided it is used instead of a toast
|
||||||
@@ -259,6 +263,8 @@ export function AgentActionButtons({
|
|||||||
|
|
||||||
const isPendingApproval = agent.status === "pending_approval";
|
const isPendingApproval = agent.status === "pending_approval";
|
||||||
const disabled = actionsDisabled || agentAction.isPending;
|
const disabled = actionsDisabled || agentAction.isPending;
|
||||||
|
const assignAndRunDisabled = disabled || isPendingApproval || workActionsDisabled;
|
||||||
|
const pauseResumeDisabled = disabled || isPendingApproval || (isPaused && workActionsDisabled);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className ?? "flex items-center gap-1 sm:gap-2 shrink-0"}>
|
<div className={className ?? "flex items-center gap-1 sm:gap-2 shrink-0"}>
|
||||||
@@ -266,13 +272,15 @@ export function AgentActionButtons({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size={size}
|
size={size}
|
||||||
onClick={() => openNewIssue({ assigneeAgentId: agent.id })}
|
onClick={() => openNewIssue({ assigneeAgentId: agent.id })}
|
||||||
|
disabled={assignAndRunDisabled}
|
||||||
|
title={workActionsDisabled ? workActionsDisabledReason : undefined}
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5 sm:mr-1" />
|
<Plus className="h-3.5 w-3.5 sm:mr-1" />
|
||||||
<span className="hidden sm:inline">{assignLabel}</span>
|
<span className="hidden sm:inline">{assignLabel}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<RunButton
|
<RunButton
|
||||||
onClick={() => agentAction.mutate("invoke")}
|
onClick={() => agentAction.mutate("invoke")}
|
||||||
disabled={disabled || isPendingApproval}
|
disabled={assignAndRunDisabled}
|
||||||
label={runLabel}
|
label={runLabel}
|
||||||
size={size}
|
size={size}
|
||||||
/>
|
/>
|
||||||
@@ -280,7 +288,7 @@ export function AgentActionButtons({
|
|||||||
isPaused={isPaused}
|
isPaused={isPaused}
|
||||||
onPause={() => agentAction.mutate("pause")}
|
onPause={() => agentAction.mutate("pause")}
|
||||||
onResume={() => agentAction.mutate("resume")}
|
onResume={() => agentAction.mutate("resume")}
|
||||||
disabled={disabled || isPendingApproval}
|
disabled={pauseResumeDisabled}
|
||||||
size={size}
|
size={size}
|
||||||
/>
|
/>
|
||||||
{showStatus && (
|
{showStatus && (
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { issuesApi } from "../api/issues";
|
|||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members";
|
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, isAgentTaskTarget } from "../lib/company-members";
|
||||||
import { ISSUE_OVERRIDE_ADAPTER_TYPES, type IssueModelLane } from "../lib/issue-assignee-overrides";
|
import { ISSUE_OVERRIDE_ADAPTER_TYPES, type IssueModelLane } from "../lib/issue-assignee-overrides";
|
||||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||||
import {
|
import {
|
||||||
@@ -545,7 +545,7 @@ export function IssueProperties({
|
|||||||
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [assigneeOpen]);
|
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [assigneeOpen]);
|
||||||
const recentAssigneeSelectionIds = useMemo(() => getRecentAssigneeSelectionIds(), [assigneeOpen]);
|
const recentAssigneeSelectionIds = useMemo(() => getRecentAssigneeSelectionIds(), [assigneeOpen]);
|
||||||
const sortedAgents = useMemo(
|
const sortedAgents = useMemo(
|
||||||
() => sortAgentsByRecency((agents ?? []).filter((a) => a.status !== "terminated"), recentAssigneeIds),
|
() => sortAgentsByRecency((agents ?? []).filter(isAgentTaskTarget), recentAssigneeIds),
|
||||||
[agents, recentAssigneeIds],
|
[agents, recentAssigneeIds],
|
||||||
);
|
);
|
||||||
const recentAssigneeValues = useMemo(
|
const recentAssigneeValues = useMemo(
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { agentsApi } from "../api/agents";
|
|||||||
import { accessApi } from "../api/access";
|
import { accessApi } from "../api/access";
|
||||||
import { authApi } from "../api/auth";
|
import { authApi } from "../api/auth";
|
||||||
import { assetsApi } from "../api/assets";
|
import { assetsApi } from "../api/assets";
|
||||||
import { buildCompanyUserInlineOptions, buildMarkdownMentionOptions } from "../lib/company-members";
|
import { buildCompanyUserInlineOptions, buildMarkdownMentionOptions, isAgentTaskTarget } from "../lib/company-members";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { orderReusableExecutionWorkspaces } from "../lib/reusable-execution-workspaces";
|
import { orderReusableExecutionWorkspaces } from "../lib/reusable-execution-workspaces";
|
||||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||||
@@ -1122,7 +1122,7 @@ export function NewIssueDialog() {
|
|||||||
...currentUserAssigneeOption(currentUserId),
|
...currentUserAssigneeOption(currentUserId),
|
||||||
...buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId] }),
|
...buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId] }),
|
||||||
...sortAgentsByRecency(
|
...sortAgentsByRecency(
|
||||||
(agents ?? []).filter((agent) => agent.status !== "terminated"),
|
(agents ?? []).filter(isAgentTaskTarget),
|
||||||
recentAssigneeIds,
|
recentAssigneeIds,
|
||||||
).map((agent) => ({
|
).map((agent) => ({
|
||||||
id: assigneeValueFromSelection({ assigneeAgentId: agent.id }),
|
id: assigneeValueFromSelection({ assigneeAgentId: agent.id }),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
PlayCircle,
|
PlayCircle,
|
||||||
Plus,
|
Plus,
|
||||||
Users,
|
Users,
|
||||||
|
AlertTriangle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useDialogActions } from "../context/DialogContext";
|
import { useDialogActions } from "../context/DialogContext";
|
||||||
@@ -114,12 +115,15 @@ function SidebarAgentItem({
|
|||||||
const isActive = activeAgentId === routeRef;
|
const isActive = activeAgentId === routeRef;
|
||||||
const isPaused = agent.status === "paused";
|
const isPaused = agent.status === "paused";
|
||||||
const isBudgetPaused = isPaused && agent.pauseReason === "budget";
|
const isBudgetPaused = isPaused && agent.pauseReason === "budget";
|
||||||
|
const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain";
|
||||||
const pauseResumeLabel = isPaused ? "Resume agent" : "Pause agent";
|
const pauseResumeLabel = isPaused ? "Resume agent" : "Pause agent";
|
||||||
const pauseResumeDisabled = disabled || agent.status === "pending_approval" || isBudgetPaused;
|
const pauseResumeDisabled = disabled || agent.status === "pending_approval" || isBudgetPaused || (isPaused && hasInvalidOrgChain);
|
||||||
const pauseResumeDisabledLabel = disabled
|
const pauseResumeDisabledLabel = disabled
|
||||||
? "Updating..."
|
? "Updating..."
|
||||||
: isBudgetPaused
|
: isBudgetPaused
|
||||||
? "Budget paused"
|
? "Budget paused"
|
||||||
|
: isPaused && hasInvalidOrgChain
|
||||||
|
? "Invalid org chain"
|
||||||
: pauseResumeLabel;
|
: pauseResumeLabel;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -139,6 +143,9 @@ function SidebarAgentItem({
|
|||||||
>
|
>
|
||||||
<AgentIcon icon={agent.icon} className="shrink-0 h-3.5 w-3.5 text-muted-foreground" />
|
<AgentIcon icon={agent.icon} className="shrink-0 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<span className="flex-1 truncate">{agent.name}</span>
|
<span className="flex-1 truncate">{agent.name}</span>
|
||||||
|
{hasInvalidOrgChain ? (
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-500" aria-label="Invalid reporting chain" />
|
||||||
|
) : null}
|
||||||
{(agent.pauseReason === "budget" || runCount > 0) && (
|
{(agent.pauseReason === "budget" || runCount > 0) && (
|
||||||
<span className="ml-auto flex items-center gap-1.5 shrink-0">
|
<span className="ml-auto flex items-center gap-1.5 shrink-0">
|
||||||
{agent.pauseReason === "budget" ? (
|
{agent.pauseReason === "budget" ? (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
buildCompanyUserProfileMap,
|
buildCompanyUserProfileMap,
|
||||||
buildMarkdownMentionOptions,
|
buildMarkdownMentionOptions,
|
||||||
} from "./company-members";
|
} from "./company-members";
|
||||||
|
import type { AgentOrgChainHealth } from "@paperclipai/shared";
|
||||||
|
|
||||||
const activeMember = (overrides: Partial<CompanyMember>): CompanyMember => ({
|
const activeMember = (overrides: Partial<CompanyMember>): CompanyMember => ({
|
||||||
id: overrides.id ?? "member-1",
|
id: overrides.id ?? "member-1",
|
||||||
@@ -22,6 +23,15 @@ const activeMember = (overrides: Partial<CompanyMember>): CompanyMember => ({
|
|||||||
grants: overrides.grants ?? [],
|
grants: overrides.grants ?? [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const invalidOrgChainHealth: AgentOrgChainHealth = {
|
||||||
|
status: "invalid_org_chain",
|
||||||
|
reason: "terminated_ancestor",
|
||||||
|
fullChain: [],
|
||||||
|
firstInvalidAncestor: { id: "manager-1", name: "Manager", status: "terminated" },
|
||||||
|
invalidAncestors: [{ id: "manager-1", name: "Manager", status: "terminated" }],
|
||||||
|
repairGuidance: "Repair the reporting chain.",
|
||||||
|
};
|
||||||
|
|
||||||
describe("company-members helpers", () => {
|
describe("company-members helpers", () => {
|
||||||
it("builds labels from company member profiles", () => {
|
it("builds labels from company member profiles", () => {
|
||||||
const labels = buildCompanyUserLabelMap([
|
const labels = buildCompanyUserLabelMap([
|
||||||
@@ -82,6 +92,25 @@ describe("company-members helpers", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("omits invalid-org-chain agents from markdown mention options", () => {
|
||||||
|
const options = buildMarkdownMentionOptions({
|
||||||
|
agents: [
|
||||||
|
{ id: "agent-1", name: "CodexCoder", status: "active", icon: "code" },
|
||||||
|
{
|
||||||
|
id: "agent-2",
|
||||||
|
name: "InvalidCoder",
|
||||||
|
status: "active",
|
||||||
|
icon: "code",
|
||||||
|
orgChainHealth: invalidOrgChainHealth,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(options).toEqual([
|
||||||
|
{ id: "agent:agent-1", name: "CodexCoder", kind: "agent", agentId: "agent-1", agentIcon: "code" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts read-only directory entries for assignee and mention helpers", () => {
|
it("accepts read-only directory entries for assignee and mention helpers", () => {
|
||||||
const users: CompanyUserDirectoryEntry[] = [
|
const users: CompanyUserDirectoryEntry[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -84,15 +84,23 @@ export function buildCompanyUserMentionOptions(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isAgentTaskTarget(agent: Pick<Agent, "status" | "orgChainHealth">): boolean {
|
||||||
|
return (
|
||||||
|
agent.status !== "terminated" &&
|
||||||
|
agent.status !== "pending_approval" &&
|
||||||
|
agent.orgChainHealth?.status !== "invalid_org_chain"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildMarkdownMentionOptions(args: {
|
export function buildMarkdownMentionOptions(args: {
|
||||||
agents?: Array<Pick<Agent, "id" | "name" | "status" | "icon">> | null | undefined;
|
agents?: Array<Pick<Agent, "id" | "name" | "status" | "icon" | "orgChainHealth">> | null | undefined;
|
||||||
projects?: Array<Pick<Project, "id" | "name" | "color">> | null | undefined;
|
projects?: Array<Pick<Project, "id" | "name" | "color">> | null | undefined;
|
||||||
members?: CompanyUserRecord[] | null | undefined;
|
members?: CompanyUserRecord[] | null | undefined;
|
||||||
}): MentionOption[] {
|
}): MentionOption[] {
|
||||||
const options: MentionOption[] = [
|
const options: MentionOption[] = [
|
||||||
...buildCompanyUserMentionOptions(args.members),
|
...buildCompanyUserMentionOptions(args.members),
|
||||||
...[...(args.agents ?? [])]
|
...[...(args.agents ?? [])]
|
||||||
.filter((agent) => agent.status !== "terminated")
|
.filter(isAgentTaskTarget)
|
||||||
.sort((left, right) => left.name.localeCompare(right.name))
|
.sort((left, right) => left.name.localeCompare(right.name))
|
||||||
.map((agent) => ({
|
.map((agent) => ({
|
||||||
id: `agent:${agent.id}`,
|
id: `agent:${agent.id}`,
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
AlertTriangle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
@@ -123,6 +124,12 @@ const SECRET_ENV_KEY_RE =
|
|||||||
const COMMAND_ENV_KEY_RE = /(^command$|^cmd$|command[-_]?line|resolved[-_]?command|PAPERCLIP_RESOLVED_COMMAND)/i;
|
const COMMAND_ENV_KEY_RE = /(^command$|^cmd$|command[-_]?line|resolved[-_]?command|PAPERCLIP_RESOLVED_COMMAND)/i;
|
||||||
const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/;
|
const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/;
|
||||||
|
|
||||||
|
function formatOrgChainHealthPath(agent: AgentDetailRecord) {
|
||||||
|
return agent.orgChainHealth?.fullChain
|
||||||
|
.map((entry) => `${entry.name}${entry.status !== "active" && entry.status !== "idle" ? ` (${entry.status})` : ""}`)
|
||||||
|
.join(" -> ") ?? agent.name;
|
||||||
|
}
|
||||||
|
|
||||||
function redactPathText(value: string, censorUsernameInLogs: boolean) {
|
function redactPathText(value: string, censorUsernameInLogs: boolean) {
|
||||||
return redactHomePathUserSegments(value, { enabled: censorUsernameInLogs });
|
return redactHomePathUserSegments(value, { enabled: censorUsernameInLogs });
|
||||||
}
|
}
|
||||||
@@ -913,6 +920,7 @@ export function AgentDetail() {
|
|||||||
return <Navigate to={`/agents/${canonicalAgentRef}/dashboard`} replace />;
|
return <Navigate to={`/agents/${canonicalAgentRef}/dashboard`} replace />;
|
||||||
}
|
}
|
||||||
const isPendingApproval = agent.status === "pending_approval";
|
const isPendingApproval = agent.status === "pending_approval";
|
||||||
|
const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain";
|
||||||
const showConfigActionBar = (activeView === "configuration" || activeView === "instructions") && (configDirty || configSaving);
|
const showConfigActionBar = (activeView === "configuration" || activeView === "instructions") && (configDirty || configSaving);
|
||||||
const showLeftAgentNotice = agentMembershipState === "left" && !dismissedLeftAgentIds.has(agent.id);
|
const showLeftAgentNotice = agentMembershipState === "left" && !dismissedLeftAgentIds.has(agent.id);
|
||||||
const agentMembershipPending =
|
const agentMembershipPending =
|
||||||
@@ -956,6 +964,27 @@ export function AgentDetail() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{hasInvalidOrgChain ? (
|
||||||
|
<div className="flex items-start gap-3 border border-amber-300/35 bg-amber-300/10 px-3 py-2 text-sm text-amber-100">
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<p className="font-medium">Invalid reporting chain</p>
|
||||||
|
<p className="text-amber-100/90">
|
||||||
|
{agent.name} cannot accept tasks or start runs until its reporting chain is repaired.
|
||||||
|
</p>
|
||||||
|
<p className="break-words font-mono text-xs text-amber-100/80">
|
||||||
|
{formatOrgChainHealthPath(agent)}
|
||||||
|
</p>
|
||||||
|
{agent.orgChainHealth?.repairGuidance ? (
|
||||||
|
<p className="text-amber-100/85">{agent.orgChainHealth.repairGuidance}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-amber-100/85">
|
||||||
|
Assign this agent to an active manager/root, or explicitly pause or terminate the affected agent/subtree.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
@@ -981,6 +1010,8 @@ export function AgentDetail() {
|
|||||||
assignLabel="Assign Task"
|
assignLabel="Assign Task"
|
||||||
runLabel="Run Heartbeat"
|
runLabel="Run Heartbeat"
|
||||||
actionsDisabled={agentAction.isPending}
|
actionsDisabled={agentAction.isPending}
|
||||||
|
workActionsDisabled={hasInvalidOrgChain}
|
||||||
|
workActionsDisabledReason="Repair this agent's reporting chain before assigning tasks or starting runs"
|
||||||
onActionError={setActionError}
|
onActionError={setActionError}
|
||||||
>
|
>
|
||||||
{mobileLiveRun && (
|
{mobileLiveRun && (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { Agent } from "@paperclipai/shared";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { ToastProvider } from "../context/ToastContext";
|
import { ToastProvider } from "../context/ToastContext";
|
||||||
import { Agents } from "./Agents";
|
import { Agents } from "./Agents";
|
||||||
|
import type { AgentOrgChainHealth } from "@paperclipai/shared";
|
||||||
|
|
||||||
const mockAgentsApi = vi.hoisted(() => ({
|
const mockAgentsApi = vi.hoisted(() => ({
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
@@ -105,6 +106,34 @@ function makeAgent(overrides: Partial<Agent>): Agent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const invalidOrgChainHealth: AgentOrgChainHealth = {
|
||||||
|
status: "invalid_org_chain",
|
||||||
|
reason: "terminated_ancestor",
|
||||||
|
fullChain: [
|
||||||
|
{
|
||||||
|
id: "agent-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
name: "Alpha",
|
||||||
|
status: "active",
|
||||||
|
reportsTo: "manager-1",
|
||||||
|
depth: 0,
|
||||||
|
relation: "self",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "manager-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
name: "Terminated Manager",
|
||||||
|
status: "terminated",
|
||||||
|
reportsTo: null,
|
||||||
|
depth: 1,
|
||||||
|
relation: "ancestor",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
firstInvalidAncestor: { id: "manager-1", name: "Terminated Manager", status: "terminated" },
|
||||||
|
invalidAncestors: [{ id: "manager-1", name: "Terminated Manager", status: "terminated" }],
|
||||||
|
repairGuidance: "Alpha reports through terminated ancestor Terminated Manager.",
|
||||||
|
};
|
||||||
|
|
||||||
async function flushReact() {
|
async function flushReact() {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
@@ -224,4 +253,26 @@ describe("Agents", () => {
|
|||||||
expect(titleCell?.textContent).toContain("Alpha");
|
expect(titleCell?.textContent).toContain("Alpha");
|
||||||
expect(container.querySelector(".min-w-\\[7rem\\]")).toBeNull();
|
expect(container.querySelector(".min-w-\\[7rem\\]")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps invalid-org-chain agents visible with a warning marker", async () => {
|
||||||
|
mockAgentsApi.list.mockResolvedValue([
|
||||||
|
makeAgent({ orgChainHealth: invalidOrgChainHealth }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root!.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ToastProvider>
|
||||||
|
<Agents />
|
||||||
|
</ToastProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Alpha");
|
||||||
|
expect(container.querySelector('[aria-label="Invalid reporting chain"]')).not.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-3
@@ -18,7 +18,7 @@ import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils";
|
|||||||
import { PageTabBar } from "../components/PageTabBar";
|
import { PageTabBar } from "../components/PageTabBar";
|
||||||
import { Tabs } from "@/components/ui/tabs";
|
import { Tabs } from "@/components/ui/tabs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Bot, Plus, List, GitBranch } from "lucide-react";
|
import { AlertTriangle, Bot, Plus, List, GitBranch } from "lucide-react";
|
||||||
import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
|
import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
|
||||||
import {
|
import {
|
||||||
resourceMembershipState,
|
resourceMembershipState,
|
||||||
@@ -211,6 +211,7 @@ export function Agents() {
|
|||||||
{effectiveView === "list" && filtered.length > 0 && (
|
{effectiveView === "list" && filtered.length > 0 && (
|
||||||
<div className="border border-border">
|
<div className="border border-border">
|
||||||
{filtered.map((agent) => {
|
{filtered.map((agent) => {
|
||||||
|
const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain";
|
||||||
return (
|
return (
|
||||||
<EntityRow
|
<EntityRow
|
||||||
key={agent.id}
|
key={agent.id}
|
||||||
@@ -227,7 +228,11 @@ export function Agents() {
|
|||||||
agent.pausedAt && tab !== "paused" ? "opacity-50" : "",
|
agent.pausedAt && tab !== "paused" ? "opacity-50" : "",
|
||||||
resourceMembershipState(membershipsQuery.data, "agent", agent.id) === "left" ? "text-foreground/55" : "",
|
resourceMembershipState(membershipsQuery.data, "agent", agent.id) === "left" ? "text-foreground/55" : "",
|
||||||
)}
|
)}
|
||||||
leading={<AgentStatusCapsule status={agent.status} />}
|
leading={hasInvalidOrgChain ? (
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5 text-amber-500" aria-label="Invalid reporting chain" />
|
||||||
|
) : (
|
||||||
|
<AgentStatusCapsule status={agent.status} />
|
||||||
|
)}
|
||||||
meta={
|
meta={
|
||||||
<div className="hidden xl:flex items-center gap-3">
|
<div className="hidden xl:flex items-center gap-3">
|
||||||
<AgentMetaColumns agent={agent} />
|
<AgentMetaColumns agent={agent} />
|
||||||
@@ -366,6 +371,7 @@ function OrgTreeNode({
|
|||||||
membershipMutation: ReturnType<typeof useResourceMembershipMutation>;
|
membershipMutation: ReturnType<typeof useResourceMembershipMutation>;
|
||||||
}) {
|
}) {
|
||||||
const agent = agentMap.get(node.id);
|
const agent = agentMap.get(node.id);
|
||||||
|
const hasInvalidOrgChain = Boolean(agent && agent.orgChainHealth?.status === "invalid_org_chain");
|
||||||
const membershipState = resourceMembershipState(memberships, "agent", node.id);
|
const membershipState = resourceMembershipState(memberships, "agent", node.id);
|
||||||
const pending = membershipMutation.isPending &&
|
const pending = membershipMutation.isPending &&
|
||||||
membershipMutation.variables?.resourceType === "agent" &&
|
membershipMutation.variables?.resourceType === "agent" &&
|
||||||
@@ -381,7 +387,11 @@ function OrgTreeNode({
|
|||||||
membershipState === "left" && "text-foreground/55",
|
membershipState === "left" && "text-foreground/55",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<AgentStatusCapsule status={node.status} />
|
{hasInvalidOrgChain ? (
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-500" aria-label="Invalid reporting chain" />
|
||||||
|
) : (
|
||||||
|
<AgentStatusCapsule status={node.status} />
|
||||||
|
)}
|
||||||
<div className="flex-1 min-w-[7rem]">
|
<div className="flex-1 min-w-[7rem]">
|
||||||
<span className="text-sm font-medium">{node.name}</span>
|
<span className="text-sm font-medium">{node.name}</span>
|
||||||
<span className="text-xs text-muted-foreground ml-2">
|
<span className="text-xs text-muted-foreground ml-2">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { useSidebar } from "../context/SidebarContext";
|
|||||||
import { useToastActions } from "../context/ToastContext";
|
import { useToastActions } from "../context/ToastContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
|
import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
|
||||||
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUserProfileMap, buildMarkdownMentionOptions } from "../lib/company-members";
|
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUserProfileMap, buildMarkdownMentionOptions, isAgentTaskTarget } from "../lib/company-members";
|
||||||
import { extractIssueTimelineEvents } from "../lib/issue-timeline-events";
|
import { extractIssueTimelineEvents } from "../lib/issue-timeline-events";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data";
|
import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data";
|
||||||
@@ -1603,7 +1603,7 @@ export function IssueDetail() {
|
|||||||
const options: Array<{ id: string; label: string; searchText?: string }> = [];
|
const options: Array<{ id: string; label: string; searchText?: string }> = [];
|
||||||
options.push(...buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId] }));
|
options.push(...buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId] }));
|
||||||
const activeAgents = [...(agents ?? [])]
|
const activeAgents = [...(agents ?? [])]
|
||||||
.filter((agent) => agent.status !== "terminated")
|
.filter(isAgentTaskTarget)
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
for (const agent of activeAgents) {
|
for (const agent of activeAgents) {
|
||||||
options.push({ id: `agent:${agent.id}`, label: agent.name });
|
options.push({ id: `agent:${agent.id}`, label: agent.name });
|
||||||
|
|||||||
Reference in New Issue
Block a user