Add canonical issue execution disposition classifier

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-29 10:19:22 -05:00
1 parent 628d879978
commit 9413f55381
6 files changed
+922

No files matched your search

+7
View File
@@ -328,6 +328,13 @@ export type {
IssueWorkProductProvider,
IssueWorkProductStatus,
IssueWorkProductReviewState,
IssueExecutionDisposition,
IssueExecutionDispositionKind,
IssueExecutionHumanEscalationOwner,
IssueExecutionInvalidReason,
IssueExecutionLivePath,
IssueExecutionRecoveryKind,
IssueExecutionWaitingPath,
Issue,
IssueAssigneeAdapterOverrides,
IssueBlockerAttention,
+9
View File
@@ -130,6 +130,15 @@ export type {
IssueWorkProductStatus,
IssueWorkProductReviewState,
} from "./work-product.js";
export type {
IssueExecutionDisposition,
IssueExecutionDispositionKind,
IssueExecutionHumanEscalationOwner,
IssueExecutionInvalidReason,
IssueExecutionLivePath,
IssueExecutionRecoveryKind,
IssueExecutionWaitingPath,
} from "./issue-execution-disposition.js";
export type {
Issue,
IssueAssigneeAdapterOverrides,
@@ -0,0 +1,59 @@
export type IssueExecutionDispositionKind =
| "terminal"
| "resting"
| "dispatchable"
| "live"
| "waiting"
| "recoverable_by_control_plane"
| "agent_continuable"
| "human_escalation_required"
| "invalid";
export type IssueExecutionLivePath =
| "active_run"
| "queued_wake"
| "scheduled_retry"
| "deferred_execution";
export type IssueExecutionWaitingPath =
| "participant"
| "interaction"
| "approval"
| "human_owner"
| "blocker_chain"
| "pause_hold"
| "review_artifact"
| "external_owner_action";
export type IssueExecutionRecoveryKind = "dispatch" | "continuation" | "repair_wait";
export type IssueExecutionHumanEscalationOwner =
| "board"
| "manager"
| "recovery_owner"
| "external";
export type IssueExecutionInvalidReason =
| "dual_assignee"
| "invalid_review_participant"
| "in_review_without_action_path"
| "blocked_by_invalid_issue"
| "blocked_by_cancelled_issue"
| "blocked_by_unassigned_issue"
| "blocked_by_resting_issue"
| "blocked_without_action_path";
export type IssueExecutionDisposition =
| { kind: "terminal" }
| { kind: "resting" }
| { kind: "dispatchable"; wakeTarget: string }
| { kind: "live"; path: IssueExecutionLivePath }
| { kind: "waiting"; path: IssueExecutionWaitingPath }
| { kind: "recoverable_by_control_plane"; recovery: IssueExecutionRecoveryKind }
| { kind: "agent_continuable"; continuationAttempt: number; maxAttempts: number }
| { kind: "human_escalation_required"; owner: IssueExecutionHumanEscalationOwner }
| {
kind: "invalid";
reason: IssueExecutionInvalidReason;
suggestedCorrection: string;
};
@@ -0,0 +1,503 @@
import { describe, expect, it } from "vitest";
import type { IssueStatus } from "@paperclipai/shared";
import type {
IssueExecutionDisposition,
IssueExecutionInvalidReason,
IssueExecutionWaitingPath,
} from "@paperclipai/shared";
import {
classifyIssueExecutionDisposition,
type IssueExecutionStateVector,
} from "../services/issue-execution-disposition.ts";
const agentId = "agent-1";
const humanId = "user-1";
function state(
input: Partial<IssueExecutionStateVector> & {
issue?: Partial<IssueExecutionStateVector["issue"]>;
},
): IssueExecutionStateVector {
const issue = {
id: "issue-1",
status: "todo" as IssueStatus,
assigneeAgentId: agentId,
assigneeUserId: null,
...input.issue,
};
return {
agent: issue.assigneeAgentId
? { id: issue.assigneeAgentId, status: "idle" }
: null,
...input,
issue,
};
}
function classify(input: IssueExecutionStateVector) {
const disposition = classifyIssueExecutionDisposition(input);
expect(disposition.kind).toBeTruthy();
return disposition;
}
function expectDisposition(
input: IssueExecutionStateVector,
expected: Partial<IssueExecutionDisposition> & { kind: IssueExecutionDisposition["kind"] },
) {
expect(classify(input)).toMatchObject(expected);
}
describe("issue execution disposition classifier", () => {
it.each([
["row 1 terminal done", state({ issue: { status: "done" } }), { kind: "terminal" }],
[
"row 1 terminal cancelled",
state({ issue: { status: "cancelled" } }),
{ kind: "terminal" },
],
[
"row 2 dual owner",
state({ issue: { assigneeAgentId: agentId, assigneeUserId: humanId } }),
{ kind: "invalid", reason: "dual_assignee" },
],
[
"row 3 backlog rests",
state({ issue: { status: "backlog" } }),
{ kind: "resting" },
],
[
"row 4 unassigned todo rests",
state({ issue: { assigneeAgentId: null, assigneeUserId: null }, agent: null }),
{ kind: "resting" },
],
[
"row 5 queued todo is live",
state({ execution: { queuedWake: true } }),
{ kind: "live", path: "queued_wake" },
],
["row 6 assigned todo dispatches", state({}), { kind: "dispatchable", wakeTarget: agentId }],
[
"row 7 assigned todo waits on interaction",
state({ waits: { pendingInteraction: "live" } }),
{ kind: "waiting", path: "interaction" },
],
[
"row 8 failed todo is dispatch recoverable",
state({ latestRun: { latestRunStatus: "failed", recoveryAttemptRemaining: true } }),
{ kind: "recoverable_by_control_plane", recovery: "dispatch" },
],
[
"row 9 paused assignee escalates",
state({ agent: { id: agentId, status: "paused" } }),
{ kind: "human_escalation_required", owner: "manager" },
],
[
"row 10 pause hold waits",
state({ issue: { status: "in_progress" }, waits: { pauseHold: true } }),
{ kind: "waiting", path: "pause_hold" },
],
[
"row 11 active in-progress run is live",
state({ issue: { status: "in_progress" }, execution: { activeRun: true } }),
{ kind: "live", path: "active_run" },
],
[
"row 12 queued in-progress continuation is live",
state({ issue: { status: "in_progress" }, execution: { scheduledRetry: true } }),
{ kind: "live", path: "scheduled_retry" },
],
[
"row 13 in-progress waits on approval",
state({ issue: { status: "in_progress" }, waits: { pendingApproval: "live" } }),
{ kind: "waiting", path: "approval" },
],
[
"row 14 failed in-progress run is continuation recoverable",
state({
issue: { status: "in_progress" },
latestRun: { latestRunStatus: "timed_out", recoveryAttemptRemaining: true },
}),
{ kind: "recoverable_by_control_plane", recovery: "continuation" },
],
[
"row 15 successful runnable progress is continuable",
state({
issue: { status: "in_progress" },
latestRun: {
latestRunStatus: "succeeded",
livenessState: "advanced",
nextAction: "runnable",
continuationAttempt: 0,
maxContinuationAttempts: 2,
},
}),
{ kind: "agent_continuable", continuationAttempt: 0, maxAttempts: 2 },
],
[
"row 16 ambiguous successful progress escalates",
state({
issue: { status: "in_progress" },
latestRun: {
latestRunStatus: "succeeded",
livenessState: "advanced",
nextAction: "manager_review",
continuationAttempt: 0,
maxContinuationAttempts: 2,
},
}),
{ kind: "human_escalation_required", owner: "manager" },
],
[
"row 17 human review assignee waits",
state({
issue: { status: "in_review", assigneeAgentId: null, assigneeUserId: humanId },
agent: null,
}),
{ kind: "waiting", path: "human_owner" },
],
[
"row 18 agent review participant waits",
state({ issue: { status: "in_review" }, waits: { participant: "valid" } }),
{ kind: "waiting", path: "participant" },
],
[
"row 19 bare agent review is invalid",
state({ issue: { status: "in_review" } }),
{ kind: "invalid", reason: "in_review_without_action_path" },
],
[
"row 20 healthy blocker chain waits",
state({
issue: { status: "blocked" },
blockers: [
{
issue: {
id: "blocker-1",
status: "todo",
assigneeAgentId: agentId,
assigneeUserId: null,
},
disposition: { kind: "dispatchable", wakeTarget: agentId },
},
],
}),
{ kind: "waiting", path: "blocker_chain" },
],
[
"row 21 cancelled blocker leaf is invalid",
state({
issue: { status: "blocked" },
blockers: [
{
issue: {
id: "blocker-1",
status: "cancelled",
assigneeAgentId: agentId,
assigneeUserId: null,
},
},
],
}),
{ kind: "invalid", reason: "blocked_by_cancelled_issue" },
],
[
"row 22 blocked external owner waits",
state({ issue: { status: "blocked" }, waits: { externalOwnerAction: true } }),
{ kind: "waiting", path: "external_owner_action" },
],
[
"row 23 blocked without path is invalid",
state({ issue: { status: "blocked" } }),
{ kind: "invalid", reason: "blocked_without_action_path" },
],
[
"row 24 human-owned non-terminal waits",
state({
issue: { status: "todo", assigneeAgentId: null, assigneeUserId: humanId },
agent: null,
}),
{ kind: "waiting", path: "human_owner" },
],
[
"row 25 open productivity review waits",
state({ issue: { status: "in_progress" }, waits: { openProductivityReviewIssue: true } }),
{ kind: "waiting", path: "review_artifact" },
],
[
"row 26 recovery issue failure escalates to recovery owner",
state({
issue: { status: "in_progress", originKind: "stranded_issue_recovery" },
latestRun: { latestRunStatus: "failed", recoveryAttemptRemaining: true },
}),
{ kind: "human_escalation_required", owner: "recovery_owner" },
],
] satisfies Array<[string, IssueExecutionStateVector, Partial<IssueExecutionDisposition> & { kind: IssueExecutionDisposition["kind"] }]>)(
"%s",
(_name, input, expected) => {
expectDisposition(input, expected);
},
);
it.each([
[
"invalid-review-leaf-pi-autoresearch",
state({ issue: { status: "in_review" } }),
"invalid",
"in_review_without_action_path",
undefined,
],
[
"invalid-review-leaf-object-detection",
state({ issue: { status: "in_review" } }),
"invalid",
"in_review_without_action_path",
undefined,
],
[
"healthy-review-participant",
state({ issue: { status: "in_review" }, waits: { participant: "valid" } }),
"waiting",
undefined,
"participant",
],
[
"healthy-review-confirmation",
state({ issue: { status: "in_progress" }, waits: { pendingInteraction: "live" } }),
"waiting",
undefined,
"interaction",
],
[
"healthy-linked-approval",
state({ waits: { pendingApproval: "live" } }),
"waiting",
undefined,
"approval",
],
[
"productive-terminal-run-runnable-next-action",
state({
issue: { status: "in_progress" },
latestRun: {
latestRunStatus: "succeeded",
livenessState: "advanced",
nextAction: "runnable",
continuationAttempt: 1,
maxContinuationAttempts: 2,
},
}),
"agent_continuable",
undefined,
undefined,
],
[
"productive-terminal-run-no-next-action",
state({
issue: { status: "in_progress" },
latestRun: {
latestRunStatus: "succeeded",
livenessState: "advanced",
nextAction: "unknown",
continuationAttempt: 1,
maxContinuationAttempts: 2,
},
}),
"human_escalation_required",
undefined,
undefined,
],
[
"true-failed-continuation-recovery",
state({
issue: { status: "in_progress" },
latestRun: { latestRunStatus: "cancelled", recoveryAttemptRemaining: true },
}),
"recoverable_by_control_plane",
undefined,
undefined,
],
[
"explicit-productivity-review-hold",
state({ issue: { status: "in_progress" }, waits: { openProductivityReviewIssue: true } }),
"waiting",
undefined,
"review_artifact",
],
[
"productivity-threshold-without-review",
state({ issue: { status: "in_progress" }, waits: { productivityReviewNeeded: true } }),
"human_escalation_required",
undefined,
undefined,
],
[
"healthy-long-active-run",
state({ issue: { status: "in_progress" }, execution: { activeRun: true } }),
"live",
undefined,
undefined,
],
[
"recovery-of-recovery-failure",
state({
issue: { status: "in_progress", originKind: "stranded_issue_recovery" },
latestRun: { latestRunStatus: "failed", recoveryAttemptRemaining: true },
}),
"human_escalation_required",
undefined,
undefined,
],
[
"normal-source-recovery-dedupe",
state({
issue: { status: "in_progress" },
latestRun: { latestRunStatus: "failed", recoveryAttemptRemaining: false },
}),
"human_escalation_required",
undefined,
undefined,
],
[
"blocked-chain-healthy-leaf",
state({
issue: { status: "blocked" },
blockers: [
{
issue: {
id: "blocker-1",
status: "in_progress",
assigneeAgentId: agentId,
assigneeUserId: null,
},
disposition: { kind: "live", path: "active_run" },
},
],
}),
"waiting",
undefined,
"blocker_chain",
],
[
"blocked-chain-cancelled-leaf",
state({
issue: { status: "blocked" },
blockers: [
{
issue: {
id: "blocker-1",
status: "cancelled",
assigneeAgentId: agentId,
assigneeUserId: null,
},
},
],
}),
"invalid",
"blocked_by_cancelled_issue",
undefined,
],
[
"budget-hard-stop",
state({ issue: { status: "in_progress" }, gates: { budgetBlocked: true } }),
"human_escalation_required",
undefined,
undefined,
],
[
"pause-held-subtree",
state({ issue: { status: "in_progress" }, waits: { pauseHold: true } }),
"waiting",
undefined,
"pause_hold",
],
[
"dual-assignee-write",
state({ issue: { assigneeAgentId: agentId, assigneeUserId: humanId } }),
"invalid",
"dual_assignee",
undefined,
],
] satisfies Array<
[
string,
IssueExecutionStateVector,
IssueExecutionDisposition["kind"],
IssueExecutionInvalidReason | undefined,
IssueExecutionWaitingPath | undefined,
]
>)("%s fixture", (_name, input, expectedKind, expectedReason, expectedPath) => {
const disposition = classify(input);
expect(disposition.kind).toBe(expectedKind);
if (expectedReason) expect(disposition).toMatchObject({ reason: expectedReason });
if (expectedPath) expect(disposition).toMatchObject({ path: expectedPath });
});
it("does not treat stale waits as durable coverage", () => {
expectDisposition(
state({ issue: { status: "in_review" }, waits: { pendingInteraction: "stale" } }),
{ kind: "invalid", reason: "in_review_without_action_path" },
);
expectDisposition(
state({
issue: { status: "in_progress" },
waits: { pendingApproval: "stale" },
latestRun: {
latestRunStatus: "succeeded",
livenessState: "advanced",
nextAction: "runnable",
continuationAttempt: 0,
maxContinuationAttempts: 2,
},
}),
{ kind: "agent_continuable", continuationAttempt: 0, maxAttempts: 2 },
);
});
it("rejects malformed review participants as invalid wait paths", () => {
expectDisposition(
state({ issue: { status: "in_review" }, waits: { participant: "invalid" } }),
{ kind: "invalid", reason: "invalid_review_participant" },
);
});
it("surfaces invalid and unassigned blocker leaves instead of covering parents", () => {
expectDisposition(
state({
issue: { status: "blocked" },
blockers: [
{
issue: {
id: "blocker-1",
status: "in_review",
assigneeAgentId: agentId,
assigneeUserId: null,
},
disposition: {
kind: "invalid",
reason: "in_review_without_action_path",
suggestedCorrection: "Add an explicit wait path.",
},
},
],
}),
{ kind: "invalid", reason: "blocked_by_invalid_issue" },
);
expectDisposition(
state({
issue: { status: "blocked" },
blockers: [
{
issue: {
id: "blocker-1",
status: "todo",
assigneeAgentId: null,
assigneeUserId: null,
},
},
],
}),
{ kind: "invalid", reason: "blocked_by_unassigned_issue" },
);
});
});
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { classifyIssueGraphLiveness as classifyIssueGraphLivenessCompat } from "../services/issue-liveness.ts";
import { classifyIssueExecutionDisposition } from "../services/issue-execution-disposition.ts";
import { decideRunLivenessContinuation as decideRunLivenessContinuationCompat } from "../services/run-continuations.ts";
import {
RECOVERY_KEY_PREFIXES,
@@ -151,4 +152,24 @@ describe("recovery classifier boundary", () => {
expect(isStrandedIssueRecoveryOriginKind("manual")).toBe(false);
expect(isStrandedIssueRecoveryOriginKind(null)).toBe(false);
});
it("aligns invalid review handoff parity with the canonical disposition classifier", () => {
const disposition = classifyIssueExecutionDisposition({
issue: {
id: issueId,
status: "in_review",
assigneeAgentId: agentId,
assigneeUserId: null,
},
agent: {
id: agentId,
status: "idle",
},
});
expect(disposition).toMatchObject({
kind: "invalid",
reason: "in_review_without_action_path",
});
});
});
@@ -0,0 +1,323 @@
import type {
AgentStatus,
IssueOriginKind,
IssueStatus,
} from "@paperclipai/shared";
import type {
IssueExecutionDisposition,
IssueExecutionInvalidReason,
IssueExecutionLivePath,
IssueExecutionWaitingPath,
} from "@paperclipai/shared";
export type IssueExecutionWaitSignalState = "none" | "live" | "stale";
export type IssueExecutionParticipantState = "none" | "valid" | "invalid";
export type IssueExecutionRunStatus =
| "queued"
| "running"
| "succeeded"
| "failed"
| "timed_out"
| "cancelled";
export type IssueExecutionRunLivenessState =
| "advanced"
| "plan_only"
| "empty_response"
| "no_progress"
| "unknown";
export type IssueExecutionNextActionState =
| "runnable"
| "manager_review"
| "approval_required"
| "unknown"
| "none";
export interface IssueExecutionDispositionIssueInput {
id: string;
status: IssueStatus;
assigneeAgentId?: string | null;
assigneeUserId?: string | null;
originKind?: IssueOriginKind | string | null;
}
export interface IssueExecutionDispositionAgentInput {
id: string;
status: AgentStatus | string;
}
export interface IssueExecutionPathVector {
activeRun?: boolean;
queuedWake?: boolean;
scheduledRetry?: boolean;
deferredExecution?: boolean;
}
export interface IssueExecutionWaitVector {
participant?: IssueExecutionParticipantState;
pendingInteraction?: IssueExecutionWaitSignalState;
pendingApproval?: IssueExecutionWaitSignalState;
pauseHold?: boolean;
openRecoveryIssue?: boolean;
openWatchdogIssue?: boolean;
openProductivityReviewIssue?: boolean;
productivityReviewNeeded?: boolean;
externalOwnerAction?: boolean;
}
export interface IssueExecutionRunEvidence {
latestRunStatus?: IssueExecutionRunStatus | null;
livenessState?: IssueExecutionRunLivenessState | null;
nextAction?: IssueExecutionNextActionState | null;
continuationAttempt?: number | null;
maxContinuationAttempts?: number | null;
recoveryAttemptRemaining?: boolean;
}
export interface IssueExecutionBlockerVector {
issue: Pick<IssueExecutionDispositionIssueInput, "id" | "status" | "assigneeAgentId" | "assigneeUserId">;
disposition?: IssueExecutionDisposition;
stateVector?: IssueExecutionStateVector;
}
export interface IssueExecutionGateVector {
budgetBlocked?: boolean;
}
export interface IssueExecutionStateVector {
issue: IssueExecutionDispositionIssueInput;
agent?: IssueExecutionDispositionAgentInput | null;
execution?: IssueExecutionPathVector;
waits?: IssueExecutionWaitVector;
latestRun?: IssueExecutionRunEvidence | null;
blockers?: IssueExecutionBlockerVector[];
gates?: IssueExecutionGateVector;
}
const INVOKABLE_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]);
const RECOVERY_ORIGIN_KINDS = new Set(["stranded_issue_recovery", "harness_liveness_escalation"]);
function invalid(
reason: IssueExecutionInvalidReason,
suggestedCorrection: string,
): IssueExecutionDisposition {
return { kind: "invalid", reason, suggestedCorrection };
}
function hasAgentOwner(issue: IssueExecutionDispositionIssueInput) {
return Boolean(issue.assigneeAgentId);
}
function hasHumanOwner(issue: IssueExecutionDispositionIssueInput) {
return Boolean(issue.assigneeUserId);
}
function agentIsInvokable(
issue: IssueExecutionDispositionIssueInput,
agent: IssueExecutionDispositionAgentInput | null | undefined,
budgetBlocked: boolean,
) {
if (!issue.assigneeAgentId || !agent || agent.id !== issue.assigneeAgentId) return false;
if (budgetBlocked) return false;
return INVOKABLE_AGENT_STATUSES.has(agent.status);
}
function livePath(execution: IssueExecutionPathVector | undefined): IssueExecutionLivePath | null {
if (execution?.activeRun) return "active_run";
if (execution?.queuedWake) return "queued_wake";
if (execution?.scheduledRetry) return "scheduled_retry";
if (execution?.deferredExecution) return "deferred_execution";
return null;
}
function waitingPath(
issue: IssueExecutionDispositionIssueInput,
waits: IssueExecutionWaitVector | undefined,
): IssueExecutionWaitingPath | null {
if (waits?.pauseHold) return "pause_hold";
if (hasHumanOwner(issue)) return "human_owner";
if (waits?.participant === "valid") return "participant";
if (waits?.pendingInteraction === "live") return "interaction";
if (waits?.pendingApproval === "live") return "approval";
if (waits?.openRecoveryIssue || waits?.openWatchdogIssue || waits?.openProductivityReviewIssue) {
return "review_artifact";
}
if (waits?.externalOwnerAction) return "external_owner_action";
return null;
}
function runFailed(run: IssueExecutionRunEvidence | null | undefined) {
return (
run?.latestRunStatus === "failed" ||
run?.latestRunStatus === "timed_out" ||
run?.latestRunStatus === "cancelled"
);
}
function hasRunnableContinuation(run: IssueExecutionRunEvidence | null | undefined) {
const attempt = run?.continuationAttempt ?? 0;
const maxAttempts = run?.maxContinuationAttempts ?? 2;
return (
run?.latestRunStatus === "succeeded" &&
run.livenessState === "advanced" &&
run.nextAction === "runnable" &&
attempt < maxAttempts
);
}
function hasExhaustedOrAmbiguousContinuation(run: IssueExecutionRunEvidence | null | undefined) {
if (run?.latestRunStatus !== "succeeded") return false;
if (run.livenessState !== "advanced") return true;
if (run.nextAction !== "runnable") return true;
return (run.continuationAttempt ?? 0) >= (run.maxContinuationAttempts ?? 2);
}
function isRecoveryIssue(issue: IssueExecutionDispositionIssueInput) {
return Boolean(issue.originKind && RECOVERY_ORIGIN_KINDS.has(issue.originKind));
}
function classifyBlocker(
blocker: IssueExecutionBlockerVector,
seen: Set<string>,
): IssueExecutionDisposition {
if (blocker.disposition) return blocker.disposition;
if (blocker.stateVector) return classifyIssueExecutionDisposition(blocker.stateVector, seen);
return classifyIssueExecutionDisposition({ issue: blocker.issue as IssueExecutionDispositionIssueInput }, seen);
}
function blockerInvalidReason(blocker: IssueExecutionBlockerVector): IssueExecutionInvalidReason {
if (blocker.issue.status === "cancelled") return "blocked_by_cancelled_issue";
if (!blocker.issue.assigneeAgentId && !blocker.issue.assigneeUserId) return "blocked_by_unassigned_issue";
return "blocked_by_resting_issue";
}
function classifyBlockedIssue(
input: IssueExecutionStateVector,
seen: Set<string>,
): IssueExecutionDisposition {
const blockers = input.blockers ?? [];
if (blockers.length > 0) {
for (const blocker of blockers) {
if (blocker.issue.status === "cancelled") {
return invalid(
"blocked_by_cancelled_issue",
"Remove or replace the cancelled blocker before treating the dependency chain as covered.",
);
}
const disposition = classifyBlocker(blocker, seen);
if (disposition.kind === "invalid") {
return invalid(
"blocked_by_invalid_issue",
"Repair the invalid unresolved blocker or create an explicit recovery/escalation path.",
);
}
if (disposition.kind === "resting" || disposition.kind === "terminal") {
return invalid(
blockerInvalidReason(blocker),
"Give the blocker a lawful next action, remove the blocker, or escalate to a human owner.",
);
}
}
return { kind: "waiting", path: "blocker_chain" };
}
const path = waitingPath(input.issue, input.waits);
if (path) return { kind: "waiting", path };
return invalid(
"blocked_without_action_path",
"Blocked issues require first-class blockers, a live wait primitive, or a structured external owner/action.",
);
}
export function classifyIssueExecutionDisposition(
input: IssueExecutionStateVector,
seen = new Set<string>(),
): IssueExecutionDisposition {
const { issue } = input;
const status = issue.status;
const budgetBlocked = Boolean(input.gates?.budgetBlocked);
const agentInvokable = agentIsInvokable(issue, input.agent, budgetBlocked);
const path = livePath(input.execution);
const wait = waitingPath(issue, input.waits);
if (status === "done" || status === "cancelled") return { kind: "terminal" };
if (seen.has(issue.id)) {
return invalid(
"blocked_by_invalid_issue",
"Break the recursive blocker chain or create an explicit recovery issue.",
);
}
seen.add(issue.id);
if (hasAgentOwner(issue) && hasHumanOwner(issue)) {
return invalid("dual_assignee", "Keep exactly one issue assignee: agent or human user, not both.");
}
if (status === "backlog") return { kind: "resting" };
if (wait) return { kind: "waiting", path: wait };
if (input.waits?.participant === "invalid") {
return invalid(
"invalid_review_participant",
"Set a typed execution participant with a valid agentId/userId or replace it with another explicit wait path.",
);
}
if (status === "blocked") return classifyBlockedIssue(input, seen);
if (hasHumanOwner(issue)) return { kind: "waiting", path: "human_owner" };
if (!hasAgentOwner(issue)) return { kind: "resting" };
if (path) return { kind: "live", path };
if (!agentInvokable) {
return { kind: "human_escalation_required", owner: budgetBlocked ? "board" : "manager" };
}
if (status === "todo") {
if (runFailed(input.latestRun) && input.latestRun?.recoveryAttemptRemaining !== false) {
return { kind: "recoverable_by_control_plane", recovery: "dispatch" };
}
if (runFailed(input.latestRun)) {
return { kind: "human_escalation_required", owner: "manager" };
}
if (input.waits?.productivityReviewNeeded) {
return { kind: "human_escalation_required", owner: "manager" };
}
return { kind: "dispatchable", wakeTarget: issue.assigneeAgentId! };
}
if (status === "in_progress") {
if (isRecoveryIssue(issue) && runFailed(input.latestRun)) {
return { kind: "human_escalation_required", owner: "recovery_owner" };
}
if (runFailed(input.latestRun) && input.latestRun?.recoveryAttemptRemaining !== false) {
return { kind: "recoverable_by_control_plane", recovery: "continuation" };
}
if (runFailed(input.latestRun)) {
return { kind: "human_escalation_required", owner: "manager" };
}
if (input.waits?.productivityReviewNeeded) {
return { kind: "human_escalation_required", owner: "manager" };
}
if (hasRunnableContinuation(input.latestRun)) {
return {
kind: "agent_continuable",
continuationAttempt: input.latestRun?.continuationAttempt ?? 0,
maxAttempts: input.latestRun?.maxContinuationAttempts ?? 2,
};
}
if (hasExhaustedOrAmbiguousContinuation(input.latestRun)) {
return { kind: "human_escalation_required", owner: "manager" };
}
return { kind: "dispatchable", wakeTarget: issue.assigneeAgentId! };
}
if (status === "in_review") {
return invalid(
"in_review_without_action_path",
"Agent-owned review requires a typed participant, pending interaction, approval, active/queued execution path, human owner, or explicit recovery issue.",
);
}
return { kind: "human_escalation_required", owner: "manager" };
}