mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
Add canonical issue execution disposition classifier
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
628d879978
commit
9413f55381
6 files changed
+922
No files matched your search
@@ -328,6 +328,13 @@ export type {
|
||||
IssueWorkProductProvider,
|
||||
IssueWorkProductStatus,
|
||||
IssueWorkProductReviewState,
|
||||
IssueExecutionDisposition,
|
||||
IssueExecutionDispositionKind,
|
||||
IssueExecutionHumanEscalationOwner,
|
||||
IssueExecutionInvalidReason,
|
||||
IssueExecutionLivePath,
|
||||
IssueExecutionRecoveryKind,
|
||||
IssueExecutionWaitingPath,
|
||||
Issue,
|
||||
IssueAssigneeAdapterOverrides,
|
||||
IssueBlockerAttention,
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
Reference in new issue
Block a user