From 9413f55381a40ea7e8ac09a4f84ef6503ecfa562 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 29 Apr 2026 10:19:22 -0500 Subject: [PATCH] Add canonical issue execution disposition classifier Co-Authored-By: Paperclip --- packages/shared/src/index.ts | 7 + packages/shared/src/types/index.ts | 9 + .../src/types/issue-execution-disposition.ts | 59 ++ .../issue-execution-disposition.test.ts | 503 ++++++++++++++++++ .../__tests__/recovery-classifiers.test.ts | 21 + .../services/issue-execution-disposition.ts | 323 +++++++++++ 6 files changed, 922 insertions(+) create mode 100644 packages/shared/src/types/issue-execution-disposition.ts create mode 100644 server/src/__tests__/issue-execution-disposition.test.ts create mode 100644 server/src/services/issue-execution-disposition.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f2db81286f3..9c1fd211a5a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -328,6 +328,13 @@ export type { IssueWorkProductProvider, IssueWorkProductStatus, IssueWorkProductReviewState, + IssueExecutionDisposition, + IssueExecutionDispositionKind, + IssueExecutionHumanEscalationOwner, + IssueExecutionInvalidReason, + IssueExecutionLivePath, + IssueExecutionRecoveryKind, + IssueExecutionWaitingPath, Issue, IssueAssigneeAdapterOverrides, IssueBlockerAttention, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index b40d7a3634e..f1aaa6cb356 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -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, diff --git a/packages/shared/src/types/issue-execution-disposition.ts b/packages/shared/src/types/issue-execution-disposition.ts new file mode 100644 index 00000000000..61031e523e5 --- /dev/null +++ b/packages/shared/src/types/issue-execution-disposition.ts @@ -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; + }; diff --git a/server/src/__tests__/issue-execution-disposition.test.ts b/server/src/__tests__/issue-execution-disposition.test.ts new file mode 100644 index 00000000000..54ef64f2920 --- /dev/null +++ b/server/src/__tests__/issue-execution-disposition.test.ts @@ -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 & { + issue?: Partial; + }, +): 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 & { 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 & { 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" }, + ); + }); +}); diff --git a/server/src/__tests__/recovery-classifiers.test.ts b/server/src/__tests__/recovery-classifiers.test.ts index f16b29f9bb1..7089f0a2a87 100644 --- a/server/src/__tests__/recovery-classifiers.test.ts +++ b/server/src/__tests__/recovery-classifiers.test.ts @@ -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", + }); + }); }); diff --git a/server/src/services/issue-execution-disposition.ts b/server/src/services/issue-execution-disposition.ts new file mode 100644 index 00000000000..bf1d3bae530 --- /dev/null +++ b/server/src/services/issue-execution-disposition.ts @@ -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; + 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, +): 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, +): 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(), +): 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" }; +}