diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index a403db1a465..99f3f791035 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -1,7 +1,7 @@ # Execution Semantics Status: Current implementation guide -Date: 2026-04-29 +Date: 2026-04-28 Audience: Product and engineering This document explains how Paperclip interprets issue assignment, issue status, execution runs, wakeups, parent/sub-issue structure, and blocker relationships. @@ -174,98 +174,6 @@ Run progress and issue liveness are separate questions. A finished heartbeat can Pending issue-thread interactions and linked pending approvals are status-independent waiting primitives: they can keep a `todo` or `in_progress` issue healthy if they clearly name the responder and continuation policy, and the wait is either fresh or tied to a resolvable human owner. Stale agent-authored or ownerless waits are not durable liveness coverage; recovery should treat them as stalled or recovery-needed instead of suppressing watchdog surfaces indefinitely. Agents should still move the source issue to `in_review` when they create a plan confirmation, question, or other board/user decision card, because `in_review` is the visible posture that tells operators execution is intentionally paused rather than abandoned. -### Canonical execution dispositions - -Issue status is the user-facing posture. It is not the whole execution state machine. For agent-owned non-terminal issues, Paperclip must derive exactly one canonical execution disposition from the state vector after each write, heartbeat, recovery scan, and read-model classification. - -The canonical dispositions are: - -| Disposition | Meaning | Healthy resting state? | Required handling | -|---|---|---:|---| -| `terminal` | The issue is `done` or `cancelled`. | yes | No execution or recovery expected. | -| `resting` | The issue is intentionally not executable yet, normally `backlog`. | yes | Do not wake or recover until status/owner changes. | -| `dispatchable` | The issue can be delivered to an invokable agent. | yes, briefly | Ensure a wake path exists or can be enqueued. | -| `live` | An active run, queued wake, scheduled retry, or deferred execution owns the next move. | yes | Preserve the live path and avoid duplicate wakes. | -| `waiting` | A typed participant, pending interaction, linked approval, human owner, active pause hold, healthy blocker chain, watchdog/recovery issue, or productivity review owns the next move. | yes | Surface the wait owner/action; do not auto-run the source while the wait is valid. | -| `recoverable_by_control_plane` | The control plane can safely repair lost execution continuity without choosing product intent. | transitional | Queue one bounded dispatch/continuation/repair wake, or create/reuse the approved recovery artifact. | -| `agent_continuable` | The assigned agent can be asked, within bounded continuation rules, to convert useful output or a runnable next action into durable issue state. | transitional | Queue a deduped continuation if attempts remain and no hold/gate suppresses it. | -| `human_escalation_required` | A board user, manager, approver, external owner, or explicit recovery owner must decide before work can lawfully move. | transitional or waiting when represented by an owned issue/interaction | Create or reuse one visible review/recovery/unblock artifact and block or move the source only when that wait is real. | -| `invalid` | The proposed or stored state has no lawful next action. | no | Reject at the API boundary, normalize before commit, or convert to visible recovery/escalation work. | - -`terminal`, `resting`, `dispatchable`, `live`, and `waiting` are stable classifications. `recoverable_by_control_plane`, `agent_continuable`, `human_escalation_required`, and `invalid` are action classifications: they must lead to a queued wake, explicit artifact, rejected write, normalized state, or visible blocker/recovery comment rather than quietly remaining as stale data. - -The classifier input is a state vector, not a single field. The vector includes: - -- work posture: issue status -- owner: agent, human user, unassigned, or invalid dual owner -- agent invokability: active, idle, running, error, paused, terminated, pending approval, or budget-blocked -- execution path: active run, queued wake, scheduled retry, deferred issue execution, checkout lock, execution lock -- explicit wait path: execution-policy participant, issue-thread interaction, linked approval, human owner, active pause hold -- dependency path: blockers and the first unresolved leaf disposition -- run evidence: latest issue-linked run status, run liveness classification, continuation attempt count, and next-action extraction -- recovery path: open explicit recovery issue, watchdog issue, productivity review hold, previous automatic recovery attempt -- governance gates: board approval requirements, permissions, company boundary, budget hard stop, subtree pause/cancel hold - -### State-vector matrix - -Use this matrix as the product contract for table-driven classifier tests. Rows are ordered by precedence: earlier rows win when multiple signals are present. - -| Row | Status posture | Owner vector | Action, wait, dependency, or gate vector | Canonical disposition | Handling contract | -|---|---|---|---|---|---| -| 1 | `done` or `cancelled` | any valid owner state | terminal status is set | `terminal` | Do not dispatch or recover. | -| 2 | any | both `assigneeAgentId` and `assigneeUserId` set | dual owner violates the single-assignee invariant | `invalid` | Reject or normalize before persistence. | -| 3 | `backlog` | unassigned or assigned | no active execution expectation | `resting` | Leave idle until promoted. | -| 4 | `todo` | unassigned | no owner | `resting` | Do not wake; surface as unassigned ready work if appropriate. | -| 5 | `todo` | agent assigned, invokable, not budget-blocked | queued wake, scheduled retry, or deferred issue execution exists | `live` | Preserve the existing path; do not enqueue duplicates. | -| 6 | `todo` | agent assigned, invokable, not budget-blocked | no run yet, no queued wake, no pause hold | `dispatchable` | Assignment dispatch may enqueue one backstop wake. | -| 7 | `todo` | agent assigned | pending interaction, linked approval, active pause hold, or open review/recovery/productivity issue owns next action | `waiting` | `todo` can wait explicitly, but the wait owner/action must be first-class. | -| 8 | `todo` | agent assigned, invokable, not budget-blocked | latest failed/timed-out/cancelled issue run and no live path | `recoverable_by_control_plane` | Queue one dispatch recovery wake, then escalate if exhausted. | -| 9 | `todo` or `in_progress` | agent assigned but paused, terminated, pending approval, uninvokable, or budget hard-stopped | no valid wait/recovery artifact already owns the next action | `human_escalation_required` | Board/manager must resume, reconfigure, raise budget, reassign, or cancel. | -| 10 | `todo`, `in_progress`, `in_review`, or `blocked` | agent assigned | active subtree pause/cancel hold covers the issue | `waiting` with `pause_hold` | Suppress dispatch, recovery, scheduled retry promotion, and bounded continuation until released. | -| 11 | `in_progress` | agent assigned | active run exists | `live` with `active_run` | Active-run watchdog may review silence; liveness recovery must not replace the live process. | -| 12 | `in_progress` | agent assigned | queued continuation, scheduled retry, or deferred issue execution exists | `live` | Preserve the queued path and dedupe equivalent wakes. | -| 13 | `in_progress` | agent assigned | pending interaction, linked approval, execution participant, active pause hold, or open review/recovery/productivity issue owns next action | `waiting` | Show owner/action; do not call a finished run the wait. | -| 14 | `in_progress` | agent assigned, invokable, not budget-blocked | live path disappeared after failed/timed-out/cancelled run and recovery attempt remains | `recoverable_by_control_plane` | Queue one continuation recovery wake. | -| 15 | `in_progress` | agent assigned, invokable, not budget-blocked | latest successful run made useful progress, issue is non-terminal, runnable next action exists, attempts remain, and no wait/live path exists | `agent_continuable` | Queue bounded `run_liveness_continuation` with idempotency key. | -| 16 | `in_progress` | agent assigned | latest successful run is non-terminal but next action is ambiguous, manager-review, approval-required without interaction, or continuation attempts are exhausted | `human_escalation_required` | Create/reuse explicit recovery or review work; do not silently pass. | -| 17 | `in_review` | human user assigned | user owns the review decision | `waiting` with `human_owner` | Board/user action is the valid wait path. | -| 18 | `in_review` | agent assigned | typed execution-policy participant, pending interaction, linked approval, active/queued issue wake, or explicit recovery issue exists | `waiting` | `in_review` is healthy only because the typed path exists, not because of status alone. | -| 19 | `in_review` | agent assigned | no participant, interaction, approval, user owner, active run, queued wake, or recovery issue | `invalid` | Reject new writes; for stored rows surface `in_review_without_action_path` and convert to recovery/escalation. | -| 20 | `blocked` | any valid owner | first-class blockers exist and every non-terminal unresolved leaf is `live`, `waiting`, `dispatchable`, `recoverable_by_control_plane`, `agent_continuable`, or `human_escalation_required` with an owned artifact/action | `waiting` with `blocker_chain` | Source remains idle; parent surfaces the first unresolved leaf and owner/action. | -| 21 | `blocked` | any valid owner | blocker leaf is `invalid`, cancelled as a blocker, unassigned without an action path, uninvokable without escalation, or recursively stalled recovery work | `invalid` | Surface the first bad leaf; create/reuse one recovery/unblock artifact when safe. | -| 22 | `blocked` | any valid owner | structured external owner/action exists and is still current | `waiting` with external owner/action | Source remains idle; UI must show the external owner and requested action. | -| 23 | `blocked` | any valid owner | no `blockedByIssueIds`, no live wait primitive, and no structured external owner/action | `invalid` | New writes should be rejected; stored rows need explicit unblock/recovery work. | -| 24 | any non-terminal | user assigned | human owns the next move | `waiting` with `human_owner` | Heartbeat liveness recovery does not apply. | -| 25 | any non-terminal | agent assigned | open watchdog or productivity review owns the decision | `waiting` with explicit review artifact | The source is not failed by definition; manager/recovery owner decides. | -| 26 | any non-terminal recovery issue | agent assigned | origin is recovery work and the recovery issue itself failed or lost execution | `human_escalation_required` | Update/block the recovery issue in place; do not create recovery-of-recovery descendants. | - -Budget hard stops never imply `done`, and pause holds never imply `terminal`. They are gates on invocation and recovery, not evidence that product work completed. - -### Fixture matrix for classifier tests - -These fixtures come from recent product incidents and plans. Implementations should encode them as synthetic table rows rather than depending on production data. - -| Fixture | Source | State vector to synthesize | Expected disposition | Assertion | -|---|---|---|---|---| -| `invalid-review-leaf-pi-autoresearch` | [PAP-2787](/PAP/issues/PAP-2787) / [PAP-2667](/PAP/issues/PAP-2667) | `in_review`, agent-owned, no `executionState.currentParticipant`, no pending interaction/approval, no user owner, no active run, no queued wake, no recovery issue; parent blockers point at this leaf | `invalid` with reason `in_review_without_action_path` | Blocked parents must surface this leaf, never `covered`, `active_child`, or `active_dependency`. | -| `invalid-review-leaf-object-detection` | [PAP-2335](/PAP/issues/PAP-2335) / [PAP-2279](/PAP/issues/PAP-2279) | UX review deliverable appears complete in comments, but issue remains agent-owned `in_review` with no typed wait path | `invalid` until converted to explicit recovery/escalation | Prose handoff does not count as a durable wait or automatic completion. | -| `healthy-review-participant` | Regression companion for [PAP-2787](/PAP/issues/PAP-2787) | `in_review`, agent-owned or policy-owned, `executionState.currentParticipant` names a valid participant who can decide | `waiting` with `participant` | Typed participant is sufficient wait coverage. | -| `healthy-review-confirmation` | [PAP-2708](/PAP/issues/PAP-2708) | productive successful run requested plan confirmation; source is `in_progress` or `in_review`; pending `request_confirmation` targets latest plan revision and names board/user continuation | `waiting` with `interaction` | Must not show `productive_run_stopped`; accepting/rejecting the confirmation owns continuation. | -| `healthy-linked-approval` | Explicit-wait companion for [PAP-2708](/PAP/issues/PAP-2708) | non-terminal agent-owned issue has a linked pending approval that names the responder/action and remains fresh or tied to a resolvable human owner | `waiting` with `approval` | Pending approvals are durable wait primitives across `todo`, `in_progress`, `in_review`, and `blocked`. | -| `productive-terminal-run-runnable-next-action` | [PAP-2674](/PAP/issues/PAP-2674) / [PAP-2642](/PAP/issues/PAP-2642) | `in_progress`, agent-owned, latest issue-linked run `succeeded`, liveness `advanced`, non-terminal issue, no live/wait path, next action is `runnable`, continuation attempts remain | `agent_continuable` | Finished run progress is not liveness; queue one bounded continuation. | -| `productive-terminal-run-no-next-action` | [PAP-2674](/PAP/issues/PAP-2674) | same as above, but next action is `unknown`, `manager_review`, or attempts are exhausted | `human_escalation_required` | Create/reuse explicit recovery/review work; do not keep spinning. | -| `true-failed-continuation-recovery` | [PAP-2674](/PAP/issues/PAP-2674) | `in_progress`, agent-owned, latest run `failed`, `timed_out`, or `cancelled`, no live/wait path, recovery attempt remains | `recoverable_by_control_plane` with `continuation` | Queue one automatic recovery wake and preserve owner. | -| `explicit-productivity-review-hold` | [PAP-2602](/PAP/issues/PAP-2602) / [PAP-2536](/PAP/issues/PAP-2536) | source issue has high churn/no-comment/long-active evidence and one open `issue_productivity_review` child | `waiting` with explicit review artifact | Source is yellow manager review, not failed; no duplicate review children. | -| `productivity-threshold-without-review` | [PAP-2602](/PAP/issues/PAP-2602) | no-comment streak, high churn, or long active threshold exceeded; no open review exists yet | `human_escalation_required` | Reconciler creates/reuses one manager-facing review; after that artifact exists, the source reclassifies as `waiting`. | -| `healthy-long-active-run` | [PAP-2602](/PAP/issues/PAP-2602) | `in_progress`, agent-owned, active run exists, duration exceeds long-active threshold, no silence-critical decision yet | `live` plus possible watchdog/productivity review side effect | Do not kill or replace the active run just because it is long. | -| `recovery-of-recovery-failure` | [PAP-2486](/PAP/issues/PAP-2486) / [PAP-2479](/PAP/issues/PAP-2479) | issue origin is `stranded_issue_recovery`; its own run fails/losses execution; no live path remains | `human_escalation_required` with owner `recovery_owner` or manager | Update/block the same recovery issue in place; create no nested recovery issue. | -| `normal-source-recovery-dedupe` | [PAP-2486](/PAP/issues/PAP-2486) | non-recovery source work exhausted automatic recovery and has no live/wait path | `human_escalation_required` represented by one open stranded recovery issue | Dedupe by source/fingerprint; concurrent scans cannot create two open recovery artifacts. | -| `blocked-chain-healthy-leaf` | [PAP-2335](/PAP/issues/PAP-2335) companion | parent `blocked`, unresolved leaf has active run, queued wake, pending interaction, human owner, or explicit recovery issue | `waiting` with `blocker_chain` | Parent is covered and should not wake until blockers resolve. | -| `blocked-chain-cancelled-leaf` | Required by [PAP-2790](/PAP/issues/PAP-2790) | parent `blocked`, unresolved blocker leaf is `cancelled` and still listed as blocker | `invalid` | Cancelled blockers do not resolve dependency chains automatically; remove/replace blocker or escalate. | -| `budget-hard-stop` | V1 budget rule | agent-owned `todo` or `in_progress`, agent/company/project hard budget stop prevents invocation, no existing wait artifact | `human_escalation_required` with owner `board` | Raise budget, resume, reassign, or cancel; never mark complete. | -| `pause-held-subtree` | Pause-hold contract | non-terminal issue covered by active subtree pause/cancel hold | `waiting` with `pause_hold` | Suppress recovery, dispatch, scheduled retry promotion, and bounded continuation until hold release. | -| `dual-assignee-write` | Single-assignee invariant | both agent and user assignee set on proposed or stored issue | `invalid` | API rejects with a precise owner invariant error. | - ### Agent-assigned `todo` This is dispatch state: ready to start, not yet actively claimed. @@ -297,9 +205,14 @@ An agent-owned `in_progress` issue is stalled when it has no active run, no queu For new handoffs, do not rely on `in_progress` plus a pending interaction as the normal user-facing state. It is accepted as a liveness backstop for compatibility and crash recovery, but the preferred contract is to set the issue to `in_review` before the heartbeat exits. -For terminal successful runs, Paperclip evaluates the canonical issue execution disposition instead of treating success as liveness. A succeeded run with useful output can still leave the issue as `agent_continuable`, `human_escalation_required`, or `invalid` when no separate live or waiting path exists. +For terminal successful runs, Paperclip evaluates the post-run issue disposition instead of treating success as liveness. The disposition is: -Only `terminal`, `resting`, `dispatchable`, `live`, and `waiting` are healthy resting states. `recoverable_by_control_plane`, `agent_continuable`, `human_escalation_required`, and `invalid` must be turned into a bounded continuation, automatic recovery wake, explicit recovery/review/unblock work, rejected write, or visible blocked state. +- `terminal`: the issue is `done` or `cancelled` +- `explicitly_live`: an active run, queued wake, scheduled retry, or deferred issue-execution wake owns the next step +- `explicitly_waiting`: a blocker, active pause hold, `in_review`, pending interaction, linked approval, execution-policy state, or explicit recovery issue owns the next step +- `invalid`: the issue is non-terminal and no live, waiting, or recovery path exists + +Only `terminal`, `explicitly_live`, and `explicitly_waiting` are healthy resting states. `invalid` must become a bounded continuation, explicit recovery/review work, or a visible blocked state. ### `in_review` @@ -366,10 +279,9 @@ Example: Recovery rule: - if the latest run failed/timed out/cancelled and no live execution path remains, Paperclip queues one automatic continuation wake -- if the latest successful run is terminal and the post-run issue disposition is `recoverable_by_control_plane`, Paperclip performs the named bounded repair without choosing product intent -- if the latest successful run is terminal and the post-run issue disposition is `agent_continuable`, Paperclip queues a `run_liveness_continuation` wake with a stable idempotency key and continuation attempt count -- if the latest successful run is terminal and the post-run issue disposition is `human_escalation_required` or `invalid`, Paperclip creates/reuses explicit recovery or review work, rejects/normalizes the invalid transition where possible, or moves the issue to `blocked` with a visible comment -- if the latest successful run is terminal and the post-run issue disposition is `terminal`, `resting`, `dispatchable`, `live`, or `waiting`, Paperclip preserves that disposition and does not treat run success itself as a live path +- if the latest successful run is terminal and the post-run issue disposition is invalid, Paperclip examines the run liveness state, next action, and actionability before continuing +- if bounded continuation is safe, Paperclip queues a `run_liveness_continuation` wake with a stable idempotency key and continuation attempt count +- if bounded continuation is exhausted or unsafe, Paperclip creates/reuses explicit recovery work or moves the issue to `blocked` with a visible comment This is an active-work continuity recovery. diff --git a/package.json b/package.json index b0153c0a141..d0b2a39a5c3 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,6 @@ "db:generate": "pnpm --filter @paperclipai/db generate", "db:migrate": "pnpm --filter @paperclipai/db migrate", "issue-references:backfill": "pnpm run preflight:workspace-links && tsx scripts/backfill-issue-reference-mentions.ts", - "execution-dispositions:preview": "tsx scripts/preview-legacy-invalid-execution-dispositions.ts", - "execution-dispositions:preview:test": "tsx --test scripts/preview-legacy-invalid-execution-dispositions.test.ts", "secrets:migrate-inline-env": "tsx scripts/migrate-inline-env-secrets.ts", "db:backup": "./scripts/backup-db.sh", "paperclipai": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9c1fd211a5a..f2db81286f3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -328,13 +328,6 @@ 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 f1aaa6cb356..b40d7a3634e 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -130,15 +130,6 @@ 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 deleted file mode 100644 index 61031e523e5..00000000000 --- a/packages/shared/src/types/issue-execution-disposition.ts +++ /dev/null @@ -1,59 +0,0 @@ -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/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 1ec6879a097..4f3b5ef6f13 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -15,7 +15,6 @@ import type { Goal } from "./goal.js"; import type { Project, ProjectWorkspace } from "./project.js"; import type { ExecutionWorkspace, IssueExecutionWorkspaceSettings } from "./workspace-runtime.js"; import type { IssueWorkProduct } from "./work-product.js"; -import type { IssueExecutionDisposition } from "./issue-execution-disposition.js"; export interface IssueAncestorProject { id: string; @@ -307,7 +306,6 @@ export interface Issue { blockedBy?: IssueRelationIssueSummary[]; blocks?: IssueRelationIssueSummary[]; blockerAttention?: IssueBlockerAttention; - executionDisposition?: IssueExecutionDisposition; productivityReview?: IssueProductivityReview | null; relatedWork?: IssueRelatedWorkSummary; referencedIssueIdentifiers?: string[]; diff --git a/scripts/preview-legacy-invalid-execution-dispositions.test.ts b/scripts/preview-legacy-invalid-execution-dispositions.test.ts deleted file mode 100644 index c5f7e75ad87..00000000000 --- a/scripts/preview-legacy-invalid-execution-dispositions.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; - -import { filterIssuesByLookback, issueLookbackTimestamp } from "./preview-legacy-invalid-execution-dispositions.ts"; - -describe("legacy execution-disposition preview lookback", () => { - it("scopes default repair candidates by issue creation time, not recent updates", () => { - const cutoff = new Date("2026-04-27T12:00:00.000Z"); - const oldButRecentlyUpdated = { - id: "old", - title: "old blocked issue", - status: "blocked" as const, - createdAt: "2026-04-20T12:00:00.000Z", - updatedAt: "2026-04-29T12:00:00.000Z", - }; - const newlyCreated = { - id: "new", - title: "new blocked issue", - status: "blocked" as const, - createdAt: "2026-04-28T12:00:00.000Z", - updatedAt: "2026-04-28T12:00:00.000Z", - }; - - assert.equal(issueLookbackTimestamp(oldButRecentlyUpdated)?.toISOString(), "2026-04-20T12:00:00.000Z"); - assert.deepEqual(filterIssuesByLookback([oldButRecentlyUpdated, newlyCreated], cutoff).map((issue) => issue.id), [ - "new", - ]); - }); -}); diff --git a/scripts/preview-legacy-invalid-execution-dispositions.ts b/scripts/preview-legacy-invalid-execution-dispositions.ts deleted file mode 100644 index 449a38611a5..00000000000 --- a/scripts/preview-legacy-invalid-execution-dispositions.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { pathToFileURL } from "node:url"; - -type IssueStatus = "backlog" | "todo" | "in_progress" | "in_review" | "done" | "blocked" | "cancelled"; - -type InvalidDisposition = { - kind: "invalid"; - reason: string; - suggestedCorrection: string; -}; - -type IssueSummary = { - id: string; - identifier?: string | null; - title: string; - status: IssueStatus; - createdAt?: string | Date | null; - updatedAt?: string | Date | null; - assigneeAgentId?: string | null; - assigneeUserId?: string | null; - executionDisposition?: { kind: string } | InvalidDisposition; - blockerAttention?: { - state?: string | null; - reason?: string | null; - sampleBlockerIdentifier?: string | null; - sampleStalledBlockerIdentifier?: string | null; - nextActionOwner?: unknown; - nextActionHint?: string | null; - } | null; - blockedBy?: Array<{ - id: string; - identifier?: string | null; - title: string; - status: IssueStatus; - assigneeAgentId?: string | null; - assigneeUserId?: string | null; - }>; -}; - -type PreviewGroup = { - reason: string; - suggestedCorrection: string; - status: string; - owner: string; - blockerAttentionSample: string; - proposedAction: string; - issues: IssueSummary[]; -}; - -const NON_TERMINAL_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked"] as const; -const PAGE_LIMIT = 1000; -const DEFAULT_LOOKBACK_HOURS = 48; - -function readFlag(name: string): string | null { - const index = process.argv.indexOf(name); - if (index < 0) return null; - const value = process.argv[index + 1]; - return value && !value.startsWith("--") ? value : null; -} - -function requireConfig(name: string, value: string | null | undefined): string { - const trimmed = value?.trim(); - if (!trimmed) { - throw new Error(`${name} is required`); - } - return trimmed; -} - -function readPositiveIntegerFlag(name: string): number | null { - const raw = readFlag(name); - if (raw === null) return null; - const value = Number(raw); - if (!Number.isInteger(value) || value <= 0) { - throw new Error(`${name} must be a positive integer`); - } - return value; -} - -function dateValue(value: string | Date | null | undefined): Date | null { - if (!value) return null; - if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value; - const parsed = new Date(value); - return Number.isNaN(parsed.getTime()) ? null : parsed; -} - -export function issueLookbackTimestamp(issue: IssueSummary): Date | null { - return dateValue(issue.createdAt); -} - -export function filterIssuesByLookback(issues: IssueSummary[], cutoff: Date | null): IssueSummary[] { - if (!cutoff) return issues; - return issues.filter((issue) => { - const timestamp = issueLookbackTimestamp(issue); - return timestamp ? timestamp >= cutoff : false; - }); -} - -function ownerKey(issue: IssueSummary): string { - if (issue.assigneeAgentId && issue.assigneeUserId) { - return `dual:agent:${issue.assigneeAgentId}:user:${issue.assigneeUserId}`; - } - if (issue.assigneeAgentId) return `agent:${issue.assigneeAgentId}`; - if (issue.assigneeUserId) return `user:${issue.assigneeUserId}`; - return "unassigned"; -} - -function blockerOwnerKey(issue: IssueSummary): string { - if (issue.assigneeAgentId) return `agent:${issue.assigneeAgentId}`; - if (issue.assigneeUserId) return `user:${issue.assigneeUserId}`; - return "unassigned"; -} - -function sampleValue(value: unknown): string | null { - if (value === null || value === undefined) return null; - if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { - return String(value); - } - if (typeof value === "object") { - const record = value as Record; - if (typeof record.type === "string" && typeof record.agentId === "string") { - return `${record.type}:${record.agentId}`; - } - if (typeof record.type === "string" && typeof record.userId === "string") { - return `${record.type}:${record.userId}`; - } - return JSON.stringify(value); - } - return String(value); -} - -function blockerAttentionSample(issue: IssueSummary): string { - const attention = issue.blockerAttention; - if (attention && attention.state && attention.state !== "none") { - const parts = [ - attention.state, - attention.reason, - attention.sampleBlockerIdentifier ? `sample:${attention.sampleBlockerIdentifier}` : null, - attention.sampleStalledBlockerIdentifier ? `stalled:${attention.sampleStalledBlockerIdentifier}` : null, - attention.nextActionOwner ? `owner:${sampleValue(attention.nextActionOwner)}` : null, - attention.nextActionHint ? `hint:${attention.nextActionHint}` : null, - ].filter(Boolean); - return parts.join(" "); - } - - const sampleBlocker = issue.blockedBy?.[0]; - if (sampleBlocker) { - return `blocker:${sampleBlocker.identifier ?? sampleBlocker.id} status:${sampleBlocker.status} owner:${blockerOwnerKey(sampleBlocker)}`; - } - - return "none"; -} - -function hasSafeCancelledBlockerCorrection(issue: IssueSummary): boolean { - if (issue.status !== "blocked") return false; - const blockers = issue.blockedBy ?? []; - const cancelledBlockerCount = blockers.filter((blocker) => blocker.status === "cancelled").length; - const remainingUnresolvedBlockerCount = blockers.filter( - (blocker) => blocker.status !== "cancelled" && blocker.status !== "done", - ).length; - return cancelledBlockerCount > 0 && remainingUnresolvedBlockerCount > 0; -} - -function proposedAction(reason: string, issue: IssueSummary): string { - if (reason === "blocked_by_cancelled_issue" && hasSafeCancelledBlockerCorrection(issue)) { - return "apply-safe: remove cancelled blocker links while preserving remaining unresolved blockers"; - } - switch (reason) { - case "dual_assignee": - return "follow-up: choose exactly one owner and clear the other assignee"; - case "invalid_review_participant": - return "follow-up: repair the execution participant or replace it with a live wait path"; - case "in_review_without_action_path": - return "follow-up: add a typed review/approval/interaction, assign a human owner, or move to an actionable status"; - case "blocked_by_invalid_issue": - return "follow-up: repair the invalid blocker leaf first"; - case "blocked_by_cancelled_issue": - return "follow-up: replace the cancelled blocker or define a new owner/action path"; - case "blocked_by_unassigned_issue": - return "follow-up: assign the blocker, replace it, or escalate to a human owner"; - case "blocked_by_resting_issue": - return "follow-up: give the blocker a lawful next action, replace it, or escalate"; - case "blocked_without_action_path": - return "follow-up: add first-class blockers, a live wait primitive, or an external owner/action"; - default: - return "follow-up: inspect and define the lawful next action"; - } -} - -let apiUrl: string; -let apiKey: string; -let runId: string | undefined; - -function configureApiFromEnvironment() { - apiUrl = requireConfig( - "PAPERCLIP_API_URL or --api-url", - readFlag("--api-url") ?? process.env.PAPERCLIP_API_URL, - ); - apiKey = requireConfig("PAPERCLIP_API_KEY", process.env.PAPERCLIP_API_KEY); - runId = process.env.PAPERCLIP_RUN_ID?.trim() || undefined; -} - -async function requestJson(method: string, path: string, body?: unknown): Promise { - const url = new URL(path, apiUrl); - const response = await fetch(url, { - method, - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - ...(runId ? { "X-Paperclip-Run-Id": runId } : {}), - }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - if (!response.ok) { - const text = await response.text(); - throw new Error(`${method} ${url.pathname}${url.search} failed: ${response.status} ${text}`); - } - return (await response.json()) as T; -} - -async function listNonTerminalIssues(companyId: string): Promise { - const result: IssueSummary[] = []; - for (const status of NON_TERMINAL_STATUSES) { - for (let offset = 0; ; offset += PAGE_LIMIT) { - const query = new URLSearchParams({ - status, - includeBlockedBy: "true", - limit: String(PAGE_LIMIT), - offset: String(offset), - }); - const page = await requestJson("GET", `/api/companies/${companyId}/issues?${query}`); - result.push(...page); - if (page.length < PAGE_LIMIT) break; - } - } - return result; -} - -function buildGroups(issues: IssueSummary[]): PreviewGroup[] { - const groups = new Map(); - for (const issue of issues) { - const disposition = issue.executionDisposition; - if (disposition?.kind !== "invalid") continue; - const invalid = disposition as InvalidDisposition; - const action = proposedAction(invalid.reason, issue); - const sample = blockerAttentionSample(issue); - const key = [ - invalid.reason, - invalid.suggestedCorrection, - issue.status, - ownerKey(issue), - sample, - action, - ].join("\u0000"); - const existing = groups.get(key); - if (existing) { - existing.issues.push(issue); - } else { - groups.set(key, { - reason: invalid.reason, - suggestedCorrection: invalid.suggestedCorrection, - status: issue.status, - owner: ownerKey(issue), - blockerAttentionSample: sample, - proposedAction: action, - issues: [issue], - }); - } - } - return [...groups.values()].sort((a, b) => { - const byReason = a.reason.localeCompare(b.reason); - if (byReason !== 0) return byReason; - const byStatus = a.status.localeCompare(b.status); - if (byStatus !== 0) return byStatus; - return b.issues.length - a.issues.length; - }); -} - -function formatIssue(issue: IssueSummary): string { - return `${issue.identifier ?? issue.id} (${issue.status}, ${ownerKey(issue)}): ${issue.title}`; -} - -function printPreview( - companyId: string, - totalFetched: number, - totalScanned: number, - cutoff: Date | null, - groups: PreviewGroup[], - appliedCount: number, -) { - const invalidCount = groups.reduce((sum, group) => sum + group.issues.length, 0); - const safeCandidateCount = groups.reduce( - (sum, group) => sum + group.issues.filter(hasSafeCancelledBlockerCorrection).length, - 0, - ); - const byReason = new Map(); - for (const group of groups) { - byReason.set(group.reason, (byReason.get(group.reason) ?? 0) + group.issues.length); - } - - console.log(`# Legacy execution-disposition reconciliation preview`); - console.log(""); - console.log(`Company: ${companyId}`); - console.log(`Lookback: ${cutoff ? `created since ${cutoff.toISOString()}` : "all non-terminal issues"}`); - console.log(`Non-terminal issues fetched: ${totalFetched}`); - console.log(`Non-terminal issues scanned: ${totalScanned}`); - console.log(`Invalid issues found: ${invalidCount}`); - console.log(`Safe cancelled-blocker corrections available: ${safeCandidateCount}`); - console.log(`Safe corrections applied: ${appliedCount}`); - console.log(""); - if (groups.length === 0) { - console.log("No invalid non-terminal issue dispositions found."); - return; - } - - console.log("## Counts by invalid reason"); - for (const [reason, count] of [...byReason.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { - console.log(`- ${reason}: ${count}`); - } - console.log(""); - - for (const group of groups) { - console.log(`## ${group.reason} (${group.issues.length})`); - console.log(`- Status: ${group.status}`); - console.log(`- Owner: ${group.owner}`); - console.log(`- Blocker-attention sample: ${group.blockerAttentionSample}`); - console.log(`- Suggested correction: ${group.suggestedCorrection}`); - console.log(`- Proposed action: ${group.proposedAction}`); - console.log("- Sample issues:"); - for (const issue of group.issues.slice(0, 5)) { - console.log(` - ${formatIssue(issue)}`); - } - if (group.issues.length > 5) { - console.log(` - ... ${group.issues.length - 5} more`); - } - console.log(""); - } -} - -async function applySafeCorrections(groups: PreviewGroup[]): Promise { - let applied = 0; - for (const group of groups) { - for (const issue of group.issues) { - if (!hasSafeCancelledBlockerCorrection(issue)) continue; - const remainingBlockerIds = (issue.blockedBy ?? []) - .filter((blocker) => blocker.status !== "cancelled" && blocker.status !== "done") - .map((blocker) => blocker.id); - if (remainingBlockerIds.length === 0) continue; - await requestJson("PATCH", `/api/issues/${issue.id}`, { - blockedByIssueIds: remainingBlockerIds, - comment: - "Legacy execution-disposition reconciliation: removed cancelled blocker links while preserving remaining unresolved blockers.", - }); - applied += 1; - } - } - return applied; -} - -async function main() { - configureApiFromEnvironment(); - const companyId = requireConfig( - "PAPERCLIP_COMPANY_ID or --company", - readFlag("--company") ?? process.env.PAPERCLIP_COMPANY_ID, - ); - const applySafe = process.argv.includes("--apply-safe"); - const allIssues = process.argv.includes("--all"); - const lookbackHours = allIssues ? null : (readPositiveIntegerFlag("--since-hours") ?? DEFAULT_LOOKBACK_HOURS); - const cutoff = lookbackHours === null ? null : new Date(Date.now() - lookbackHours * 60 * 60 * 1000); - - let fetchedIssues = await listNonTerminalIssues(companyId); - let issues = filterIssuesByLookback(fetchedIssues, cutoff); - let groups = buildGroups(issues); - let appliedCount = 0; - - if (applySafe) { - appliedCount = await applySafeCorrections(groups); - fetchedIssues = await listNonTerminalIssues(companyId); - issues = filterIssuesByLookback(fetchedIssues, cutoff); - groups = buildGroups(issues); - } - - printPreview(companyId, fetchedIssues.length, issues.length, cutoff, groups, appliedCount); -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - void main().catch((error) => { - const message = error instanceof Error ? error.message : String(error); - console.error(`Legacy execution-disposition reconciliation preview failed: ${message}`); - process.exitCode = 1; - }); -} diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 5339f59689f..11f039af1db 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -66,10 +66,6 @@ const mockHeartbeatService = vi.hoisted(() => ({ cancelRun: vi.fn(), })); -const mockRecoveryService = vi.hoisted(() => ({ - recordWatchdogDecision: vi.fn(), -})); - const mockIssueApprovalService = vi.hoisted(() => ({ linkManyForApproval: vi.fn(), })); @@ -146,10 +142,6 @@ function registerModuleMocks() { heartbeatService: () => mockHeartbeatService, })); - vi.doMock("../services/recovery/service.js", () => ({ - recoveryService: () => mockRecoveryService, - })); - vi.doMock("../services/issue-approvals.js", () => ({ issueApprovalService: () => mockIssueApprovalService, })); @@ -279,7 +271,6 @@ describe.sequential("agent permission routes", () => { vi.doUnmock("../services/budgets.js"); vi.doUnmock("../services/company-skills.js"); vi.doUnmock("../services/heartbeat.js"); - vi.doUnmock("../services/recovery/service.js"); vi.doUnmock("../services/index.js"); vi.doUnmock("../services/instance-settings.js"); vi.doUnmock("../services/issue-approvals.js"); @@ -315,7 +306,6 @@ describe.sequential("agent permission routes", () => { mockHeartbeatService.resetRuntimeSession.mockReset(); mockHeartbeatService.getRun.mockReset(); mockHeartbeatService.cancelRun.mockReset(); - mockRecoveryService.recordWatchdogDecision.mockReset(); mockIssueApprovalService.linkManyForApproval.mockReset(); mockIssueService.list.mockReset(); mockSecretService.normalizeAdapterConfigForPersistence.mockReset(); @@ -384,13 +374,6 @@ describe.sequential("agent permission routes", () => { censorUsernameInLogs: false, }); mockLogActivity.mockResolvedValue(undefined); - mockRecoveryService.recordWatchdogDecision.mockResolvedValue({ - id: "watchdog-decision-1", - runId: "run-1", - evaluationIssueId: "evaluation-1", - decision: "cancel_run", - reason: "The stale run is stuck", - }); }); it("redacts agent detail for authenticated company members without agent admin permission", async () => { @@ -1211,114 +1194,4 @@ describe.sequential("agent permission routes", () => { expect(res.status).toBe(403); expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); }); - - it("keeps generic heartbeat cancellation board-only", async () => { - const app = await createApp({ - type: "agent", - agentId, - companyId, - source: "agent_key", - runId: "actor-run-1", - }); - - const res = await requestApp(app, (baseUrl) => request(baseUrl) - .post("/api/heartbeat-runs/run-1/cancel") - .send({})); - - expect(res.status).toBe(403); - expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); - }); - - it("lets an agent use the watchdog recovery cancel route without board access", async () => { - mockHeartbeatService.getRun.mockResolvedValue({ - id: "run-1", - companyId, - agentId, - status: "running", - }); - mockHeartbeatService.cancelRun.mockResolvedValue({ - id: "run-1", - companyId, - agentId, - status: "cancelled", - }); - - const app = await createApp({ - type: "agent", - agentId, - companyId, - source: "agent_key", - runId: "actor-run-1", - }); - - const res = await requestApp(app, (baseUrl) => request(baseUrl) - .post("/api/heartbeat-runs/run-1/watchdog-decisions") - .send({ - decision: "cancel_run", - evaluationIssueId: "evaluation-1", - reason: "The stale run is stuck", - })); - - expect(res.status).toBe(200); - expect(mockRecoveryService.recordWatchdogDecision).toHaveBeenCalledWith({ - runId: "run-1", - actor: expect.objectContaining({ - type: "agent", - agentId, - runId: "actor-run-1", - }), - decision: "cancel_run", - evaluationIssueId: "evaluation-1", - reason: "The stale run is stuck", - snoozedUntil: null, - createdByRunId: "actor-run-1", - }); - expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("run-1", "The stale run is stuck"); - expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ - actorType: "agent", - actorId: agentId, - agentId, - runId: "run-1", - action: "heartbeat.watchdog_cancelled", - entityType: "heartbeat_run", - entityId: "run-1", - details: expect.objectContaining({ - decision: "cancel_run", - evaluationIssueId: "evaluation-1", - reason: "The stale run is stuck", - createdByRunId: "actor-run-1", - targetRunId: "run-1", - previousStatus: "running", - resultStatus: "cancelled", - }), - })); - }); - - it("requires an evaluation issue on watchdog recovery cancellation", async () => { - mockHeartbeatService.getRun.mockResolvedValue({ - id: "run-1", - companyId, - agentId, - status: "running", - }); - - const app = await createApp({ - type: "agent", - agentId, - companyId, - source: "agent_key", - runId: "actor-run-1", - }); - - const res = await requestApp(app, (baseUrl) => request(baseUrl) - .post("/api/heartbeat-runs/run-1/watchdog-decisions") - .send({ - decision: "cancel_run", - reason: "The stale run is stuck", - })); - - expect(res.status).toBe(400); - expect(mockRecoveryService.recordWatchdogDecision).not.toHaveBeenCalled(); - expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); - }); }); diff --git a/server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts b/server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts index 4f8d0d287c3..45ba2c5d655 100644 --- a/server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts +++ b/server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts @@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto"; import { and, eq, sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { - activityLog, agents, companies, createDb, @@ -354,87 +353,6 @@ describeEmbeddedPostgres("active-run output watchdog", () => { }); }); - it("lets the assigned recovery owner record cancel_run and uses the normal cancellation path", async () => { - const now = new Date("2026-04-22T20:00:00.000Z"); - const { companyId, managerId, runId, issueId } = await seedRunningRun({ - now, - ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000, - }); - const heartbeat = heartbeatService(db); - const recovery = recoveryService(db, { enqueueWakeup: vi.fn() }); - - const scan = await heartbeat.scanSilentActiveRuns({ now, companyId }); - const evaluationIssueId = scan.evaluationIssueIds[0]; - expect(evaluationIssueId).toBeTruthy(); - - const managerRunId = randomUUID(); - await db.insert(heartbeatRuns).values({ - id: managerRunId, - companyId, - agentId: managerId, - status: "succeeded", - invocationSource: "assignment", - triggerDetail: "system", - startedAt: now, - finishedAt: now, - processStartedAt: now, - lastOutputAt: now, - lastOutputSeq: 1, - lastOutputStream: "stdout", - contextSnapshot: {}, - logBytes: 0, - }); - - const decision = await recovery.recordWatchdogDecision({ - runId, - actor: { type: "agent", agentId: managerId, runId: managerRunId }, - decision: "cancel_run", - evaluationIssueId, - reason: "Run is silent and evidence shows it is stuck", - }); - - expect(decision).toMatchObject({ - runId, - evaluationIssueId, - decision: "cancel_run", - reason: "Run is silent and evidence shows it is stuck", - createdByAgentId: managerId, - createdByRunId: managerRunId, - }); - - const cancelled = await heartbeat.cancelRun(runId, decision.reason ?? undefined); - expect(cancelled).toMatchObject({ - id: runId, - status: "cancelled", - error: "Run is silent and evidence shows it is stuck", - errorCode: "cancelled", - }); - - const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, issueId)); - expect(sourceIssue?.executionRunId).toBeNull(); - expect(sourceIssue?.executionLockedAt).toBeNull(); - - const [audit] = await db - .select() - .from(activityLog) - .where(and(eq(activityLog.companyId, companyId), eq(activityLog.action, "heartbeat.watchdog_cancel_requested"))); - expect(audit).toMatchObject({ - actorType: "agent", - actorId: managerId, - agentId: managerId, - runId, - entityType: "heartbeat_run", - entityId: runId, - }); - expect(audit?.details).toMatchObject({ - decision: "cancel_run", - evaluationIssueId, - targetRunId: runId, - reason: "Run is silent and evidence shows it is stuck", - createdByRunId: managerRunId, - }); - }); - it("re-arms continue decisions after the default quiet window", async () => { const now = new Date("2026-04-22T20:00:00.000Z"); const { companyId, managerId, runId } = await seedRunningRun({ @@ -552,7 +470,6 @@ describeEmbeddedPostgres("active-run output watchdog", () => { snoozedUntil: new Date(now.getTime() + 60 * 60 * 1000), }, { decision: "continue" as const, evaluationIssueId: otherEvaluationIssueId }, - { decision: "cancel_run" as const, evaluationIssueId: otherEvaluationIssueId }, ]; for (const attempt of attempts) { @@ -578,61 +495,6 @@ describeEmbeddedPostgres("active-run output watchdog", () => { ).rejects.toMatchObject({ status: 403 }); }); - it("rejects cancel_run for terminal runs before writing a decision", async () => { - const now = new Date("2026-04-22T20:00:00.000Z"); - const { companyId, managerId, runId } = await seedRunningRun({ - now, - ageMs: ACTIVE_RUN_OUTPUT_SUSPICION_THRESHOLD_MS + 60_000, - }); - const heartbeat = heartbeatService(db); - const recovery = recoveryService(db, { enqueueWakeup: vi.fn() }); - - const scan = await heartbeat.scanSilentActiveRuns({ now, companyId }); - const evaluationIssueId = scan.evaluationIssueIds[0]; - expect(evaluationIssueId).toBeTruthy(); - await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: now }).where(eq(heartbeatRuns.id, runId)); - - await expect( - recovery.recordWatchdogDecision({ - runId, - actor: { type: "agent", agentId: managerId }, - decision: "cancel_run", - evaluationIssueId, - reason: "Too late to cancel", - }), - ).rejects.toMatchObject({ status: 409 }); - - const rows = await db - .select() - .from(heartbeatRunWatchdogDecisions) - .where(and(eq(heartbeatRunWatchdogDecisions.runId, runId), eq(heartbeatRunWatchdogDecisions.decision, "cancel_run"))); - expect(rows).toHaveLength(0); - }); - - it("requires an evaluation issue for cancel_run decisions", async () => { - const now = new Date("2026-04-22T20:00:00.000Z"); - const { managerId, runId } = await seedRunningRun({ - now, - ageMs: ACTIVE_RUN_OUTPUT_SUSPICION_THRESHOLD_MS + 60_000, - }); - const recovery = recoveryService(db, { enqueueWakeup: vi.fn() }); - - await expect( - recovery.recordWatchdogDecision({ - runId, - actor: { type: "agent", agentId: managerId }, - decision: "cancel_run", - reason: "No evaluation issue binding", - }), - ).rejects.toMatchObject({ status: 400 }); - - const rows = await db - .select() - .from(heartbeatRunWatchdogDecisions) - .where(and(eq(heartbeatRunWatchdogDecisions.runId, runId), eq(heartbeatRunWatchdogDecisions.decision, "cancel_run"))); - expect(rows).toHaveLength(0); - }); - it("validates createdByRunId before storing watchdog decisions", async () => { const now = new Date("2026-04-22T20:00:00.000Z"); const { companyId, managerId, runId } = await seedRunningRun({ diff --git a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts index 58dfee3d9fd..369bb05c2b7 100644 --- a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts +++ b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts @@ -117,7 +117,7 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { }); } - async function seedBlockedChain(opts: { outsideLookback?: boolean; freshUpdateOutsideLookback?: boolean } = {}) { + async function seedBlockedChain(opts: { outsideLookback?: boolean } = {}) { const companyId = randomUUID(); const managerId = randomUUID(); const coderId = randomUUID(); @@ -161,9 +161,6 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { const issueTimestamp = opts.outsideLookback === true ? new Date(Date.now() - 25 * 60 * 60 * 1000) : new Date(Date.now() - 60 * 60 * 1000); - const updatedAt = opts.freshUpdateOutsideLookback === true - ? new Date(Date.now() - 60 * 60 * 1000) - : issueTimestamp; await db.insert(issues).values([ { id: blockedIssueId, @@ -175,7 +172,7 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { issueNumber: 1, identifier: `${issuePrefix}-1`, createdAt: issueTimestamp, - updatedAt, + updatedAt: issueTimestamp, }, { id: blockerIssueId, @@ -186,7 +183,7 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { issueNumber: 2, identifier: `${issuePrefix}-2`, createdAt: issueTimestamp, - updatedAt, + updatedAt: issueTimestamp, }, ]); @@ -239,27 +236,6 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { expect(escalations).toHaveLength(0); }); - it("does not create recovery issues for old legacy findings that were only updated recently", async () => { - await enableAutoRecovery(); - const { companyId } = await seedBlockedChain({ - outsideLookback: true, - freshUpdateOutsideLookback: true, - }); - const heartbeat = heartbeatService(db); - - const result = await heartbeat.reconcileIssueGraphLiveness(); - - expect(result.findings).toBe(1); - expect(result.escalationsCreated).toBe(0); - expect(result.skippedOutsideLookback).toBe(1); - - const escalations = await db - .select() - .from(issues) - .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation"))); - expect(escalations).toHaveLength(0); - }); - it("suppresses liveness escalation when the source issue is under an active pause hold", async () => { await enableAutoRecovery(); const { companyId, blockedIssueId } = await seedBlockedChain(); diff --git a/server/src/__tests__/heartbeat-retry-scheduling.test.ts b/server/src/__tests__/heartbeat-retry-scheduling.test.ts index 4f61f867f4c..edf94d6eb4e 100644 --- a/server/src/__tests__/heartbeat-retry-scheduling.test.ts +++ b/server/src/__tests__/heartbeat-retry-scheduling.test.ts @@ -62,105 +62,6 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => { await tempDb?.cleanup(); }); - it("cancels queued issue runs and deferred wakeups when an issue is cancelled", async () => { - const companyId = randomUUID(); - const agentId = randomUUID(); - const issueId = randomUUID(); - const queuedWakeupId = randomUUID(); - const deferredWakeupId = randomUUID(); - const queuedRunId = randomUUID(); - - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: "TQUEUE", - requireBoardApprovalForNewAgents: false, - }); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "CodexCoder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: { - heartbeat: { - wakeOnDemand: true, - maxConcurrentRuns: 1, - }, - }, - permissions: {}, - }); - await db.insert(issues).values({ - id: issueId, - companyId, - title: "Cancel queued work", - status: "cancelled", - priority: "medium", - assigneeAgentId: agentId, - cancelledAt: new Date(), - }); - await db.insert(agentWakeupRequests).values([ - { - id: queuedWakeupId, - companyId, - agentId, - source: "automation", - triggerDetail: "system", - reason: "issue_commented", - payload: { issueId }, - status: "queued", - }, - { - id: deferredWakeupId, - companyId, - agentId, - source: "automation", - triggerDetail: "system", - reason: "issue_execution_deferred", - payload: { issueId }, - status: "deferred_issue_execution", - }, - ]); - await db.insert(heartbeatRuns).values({ - id: queuedRunId, - companyId, - agentId, - invocationSource: "automation", - triggerDetail: "system", - status: "queued", - wakeupRequestId: queuedWakeupId, - contextSnapshot: { issueId, wakeCommentId: randomUUID() }, - }); - await db - .update(agentWakeupRequests) - .set({ runId: queuedRunId }) - .where(eq(agentWakeupRequests.id, queuedWakeupId)); - - const result = await heartbeat.cancelIssueRuns({ - companyId, - issueId, - reason: "Cancelled because the issue was marked cancelled", - }); - - expect(result.runs.map((run) => run.id)).toEqual([queuedRunId]); - expect(result.wakeups.map((wakeup) => wakeup.id)).toEqual([deferredWakeupId]); - - const [queuedRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, queuedRunId)); - const wakeups = await db - .select() - .from(agentWakeupRequests) - .where(eq(agentWakeupRequests.companyId, companyId)); - - expect(queuedRun?.status).toBe("cancelled"); - expect(queuedRun?.errorCode).toBe("cancelled"); - expect(Object.fromEntries(wakeups.map((wakeup) => [wakeup.id, wakeup.status]))).toEqual({ - [queuedWakeupId]: "cancelled", - [deferredWakeupId]: "cancelled", - }); - }); - async function seedRetryFixture(input: { runId: string; companyId: string; diff --git a/server/src/__tests__/issue-blocker-attention.test.ts b/server/src/__tests__/issue-blocker-attention.test.ts index ccf8d817373..c817d52fd04 100644 --- a/server/src/__tests__/issue-blocker-attention.test.ts +++ b/server/src/__tests__/issue-blocker-attention.test.ts @@ -82,9 +82,6 @@ describeEmbeddedPostgres("issue blocker attention", () => { status: string; parentId?: string | null; assigneeAgentId?: string | null; - originKind?: string | null; - originId?: string | null; - originFingerprint?: string | null; }) { const id = input.id ?? randomUUID(); await db.insert(issues).values({ @@ -96,9 +93,6 @@ describeEmbeddedPostgres("issue blocker attention", () => { priority: "medium", parentId: input.parentId ?? null, assigneeAgentId: input.assigneeAgentId ?? null, - originKind: input.originKind ?? "manual", - originId: input.originId ?? null, - originFingerprint: input.originFingerprint ?? "default", }); return id; } @@ -418,54 +412,6 @@ describeEmbeddedPostgres("issue blocker attention", () => { }); }); - it("treats open liveness escalation blockers as explicit waiting paths", async () => { - const { companyId, agentId } = await createCompany("PBL"); - const parentId = await insertIssue({ companyId, identifier: "PBL-1", title: "Parent", status: "blocked" }); - const cancelledLeafId = await insertIssue({ - companyId, - identifier: "PBL-2", - title: "Cancelled blocker", - status: "cancelled", - assigneeAgentId: agentId, - }); - const incidentKey = [ - "harness_liveness", - companyId, - parentId, - "blocked_by_cancelled_issue", - cancelledLeafId, - ].join(":"); - const escalationId = await insertIssue({ - companyId, - identifier: "PBL-3", - title: "Liveness escalation", - status: "todo", - assigneeAgentId: agentId, - originKind: "harness_liveness_escalation", - originId: incidentKey, - originFingerprint: [ - "harness_liveness_leaf", - companyId, - "blocked_by_cancelled_issue", - cancelledLeafId, - ].join(":"), - }); - await block({ companyId, blockerIssueId: cancelledLeafId, blockedIssueId: parentId }); - await block({ companyId, blockerIssueId: escalationId, blockedIssueId: parentId }); - - const parent = (await svc.list(companyId, { status: "blocked,todo" })).find((issue) => issue.id === parentId); - - expect(parent?.blockerAttention).toMatchObject({ - state: "covered", - reason: "explicit_waiting", - unresolvedBlockerCount: 2, - coveredBlockerCount: 2, - attentionBlockerCount: 0, - nextActionHint: "needs_human_review", - }); - expect(parent?.blockerAttention?.nextActionOwner).toMatchObject({ type: "agent", agentId }); - }); - async function recordedRun(input: { companyId: string; agentId: string; diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 735afd3fd81..3cca0371fb6 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -24,7 +24,6 @@ const mockHeartbeatService = vi.hoisted(() => ({ getRun: vi.fn(async () => null), getActiveRunForAgent: vi.fn(async () => null), cancelRun: vi.fn(async () => null), - cancelIssueRuns: vi.fn(async () => ({ runs: [], wakeups: [] })), })); const mockAgentService = vi.hoisted(() => ({ @@ -111,7 +110,6 @@ vi.mock("../services/routines.js", () => ({ vi.mock("../services/index.js", () => ({ accessService: () => mockAccessService, agentService: () => mockAgentService, - companyService: () => ({}), documentService: () => ({}), executionWorkspaceService: () => ({}), feedbackService: () => mockFeedbackService, @@ -223,7 +221,6 @@ describe.sequential("issue comment reopen routes", () => { mockHeartbeatService.getRun.mockReset(); mockHeartbeatService.getActiveRunForAgent.mockReset(); mockHeartbeatService.cancelRun.mockReset(); - mockHeartbeatService.cancelIssueRuns.mockReset(); mockAgentService.getById.mockReset(); mockAgentService.list.mockReset(); mockAgentService.resolveByReference.mockReset(); @@ -245,7 +242,6 @@ describe.sequential("issue comment reopen routes", () => { mockHeartbeatService.getRun.mockResolvedValue(null); mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null); mockHeartbeatService.cancelRun.mockResolvedValue(null); - mockHeartbeatService.cancelIssueRuns.mockResolvedValue({ runs: [], wakeups: [] }); mockLogActivity.mockResolvedValue(undefined); mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]); mockFeedbackService.saveIssueVote.mockResolvedValue({ @@ -971,14 +967,17 @@ describe.sequential("issue comment reopen routes", () => { ...issue, ...patch, })); - mockHeartbeatService.cancelIssueRuns.mockResolvedValue({ - runs: [{ - id: "run-1", - companyId: "company-1", - agentId: "22222222-2222-4222-8222-222222222222", - status: "cancelled", - }], - wakeups: [], + mockHeartbeatService.getRun.mockResolvedValue({ + id: "run-1", + companyId: "company-1", + agentId: "22222222-2222-4222-8222-222222222222", + status: "running", + }); + mockHeartbeatService.cancelRun.mockResolvedValue({ + id: "run-1", + companyId: "company-1", + agentId: "22222222-2222-4222-8222-222222222222", + status: "cancelled", }); const res = await request(await installActor(createApp())) @@ -986,11 +985,8 @@ describe.sequential("issue comment reopen routes", () => { .send({ status: "cancelled" }); expect(res.status).toBe(200); - expect(mockHeartbeatService.cancelIssueRuns).toHaveBeenCalledWith({ - companyId: "company-1", - issueId: "11111111-1111-4111-8111-111111111111", - reason: "Cancelled because the issue was marked cancelled", - }); + expect(mockHeartbeatService.getRun).toHaveBeenCalledWith("run-1"); + expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("run-1"); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ @@ -1026,7 +1022,6 @@ describe.sequential("issue comment reopen routes", () => { expect(res.status).toBe(200); expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); - expect(mockHeartbeatService.cancelIssueRuns).not.toHaveBeenCalled(); }); it("writes decision ids into executionState and inserts the decision inside the transaction", async () => { diff --git a/server/src/__tests__/issue-execution-disposition-resolver.test.ts b/server/src/__tests__/issue-execution-disposition-resolver.test.ts deleted file mode 100644 index cd78d237483..00000000000 --- a/server/src/__tests__/issue-execution-disposition-resolver.test.ts +++ /dev/null @@ -1,359 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; -import { - agents, - agentWakeupRequests, - approvals, - companies, - createDb, - heartbeatRuns, - issueApprovals, - issueRelations, - issueThreadInteractions, - issues, -} from "@paperclipai/db"; -import { - getEmbeddedPostgresTestSupport, - startEmbeddedPostgresTestDatabase, -} from "./helpers/embedded-postgres.js"; -import { - backfillBlockedBySummaries, - listIssueExecutionDispositionsMap, -} from "../services/issue-execution-disposition-resolver.js"; - -const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); -const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; - -if (!embeddedPostgresSupport.supported) { - console.warn( - `Skipping embedded Postgres execution-disposition resolver tests: ${embeddedPostgresSupport.reason ?? "unsupported"}`, - ); -} - -describeEmbeddedPostgres("issue execution disposition resolver", () => { - let db!: ReturnType; - let tempDb: Awaited> | null = null; - - beforeAll(async () => { - tempDb = await startEmbeddedPostgresTestDatabase("paperclip-execution-disposition-"); - db = createDb(tempDb.connectionString); - }, 20_000); - - afterEach(async () => { - await db.delete(heartbeatRuns); - await db.delete(agentWakeupRequests); - await db.delete(issueThreadInteractions); - await db.delete(issueApprovals); - await db.delete(approvals); - await db.delete(issueRelations); - await db.delete(issues); - await db.delete(agents); - await db.delete(companies); - }); - - afterAll(async () => { - await tempDb?.cleanup(); - }); - - async function seed(prefix = "DSP") { - const companyId = randomUUID(); - const agentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: `Company ${prefix}`, - issuePrefix: prefix, - requireBoardApprovalForNewAgents: false, - }); - await db.insert(agents).values({ - id: agentId, - companyId, - name: `${prefix} Agent`, - role: "engineer", - status: "idle", - }); - return { companyId, agentId }; - } - - async function insertIssue(input: { - companyId: string; - identifier: string; - title: string; - status: string; - assigneeAgentId?: string | null; - assigneeUserId?: string | null; - originKind?: string | null; - originId?: string | null; - originFingerprint?: string | null; - }) { - const id = randomUUID(); - await db.insert(issues).values({ - id, - companyId: input.companyId, - identifier: input.identifier, - title: input.title, - status: input.status, - priority: "medium", - assigneeAgentId: input.assigneeAgentId ?? null, - assigneeUserId: input.assigneeUserId ?? null, - originKind: input.originKind ?? "manual", - originId: input.originId ?? null, - originFingerprint: input.originFingerprint ?? "default", - }); - return id; - } - - function inputFor(input: { - id: string; - companyId: string; - status: string; - assigneeAgentId?: string | null; - assigneeUserId?: string | null; - originKind?: string | null; - }) { - return { - id: input.id, - companyId: input.companyId, - status: input.status, - assigneeAgentId: input.assigneeAgentId ?? null, - assigneeUserId: input.assigneeUserId ?? null, - originKind: input.originKind ?? "manual", - executionRunId: null as string | null, - executionState: null as Record | null, - }; - } - - it("classifies an idle agent-owned todo as dispatchable", async () => { - const { companyId, agentId } = await seed(); - const issueId = await insertIssue({ - companyId, - identifier: "DSP-1", - title: "Ready", - status: "todo", - assigneeAgentId: agentId, - }); - const map = await listIssueExecutionDispositionsMap(db, companyId, [ - inputFor({ id: issueId, companyId, status: "todo", assigneeAgentId: agentId }), - ]); - const disposition = map.get(issueId); - expect(disposition?.kind).toBe("dispatchable"); - }); - - it("classifies an in_progress issue with an active heartbeat run as live", async () => { - const { companyId, agentId } = await seed(); - const issueId = await insertIssue({ - companyId, - identifier: "DSP-2", - title: "Running", - status: "in_progress", - assigneeAgentId: agentId, - }); - const runId = randomUUID(); - await db.insert(heartbeatRuns).values({ - id: runId, - companyId, - agentId, - status: "running", - contextSnapshot: { issueId }, - }); - - const map = await listIssueExecutionDispositionsMap(db, companyId, [ - inputFor({ id: issueId, companyId, status: "in_progress", assigneeAgentId: agentId }), - ]); - expect(map.get(issueId)?.kind).toBe("live"); - }); - - it("classifies a pending interaction as waiting on interaction", async () => { - const { companyId, agentId } = await seed(); - const issueId = await insertIssue({ - companyId, - identifier: "DSP-3", - title: "Waiting on confirmation", - status: "in_review", - assigneeAgentId: agentId, - }); - await db.insert(issueThreadInteractions).values({ - id: randomUUID(), - companyId, - issueId, - kind: "request_confirmation", - status: "pending", - payload: { target: { type: "issue_document", key: "plan", revisionId: "r1" } }, - createdByAgentId: agentId, - }); - - const map = await listIssueExecutionDispositionsMap(db, companyId, [ - inputFor({ id: issueId, companyId, status: "in_review", assigneeAgentId: agentId }), - ]); - const disposition = map.get(issueId); - expect(disposition?.kind).toBe("waiting"); - if (disposition?.kind === "waiting") { - expect(disposition.path).toBe("interaction"); - } - }); - - it("flags an in_review issue without any action path as invalid", async () => { - const { companyId, agentId } = await seed(); - const issueId = await insertIssue({ - companyId, - identifier: "DSP-4", - title: "Stalled review", - status: "in_review", - assigneeAgentId: agentId, - }); - const map = await listIssueExecutionDispositionsMap(db, companyId, [ - inputFor({ id: issueId, companyId, status: "in_review", assigneeAgentId: agentId }), - ]); - const disposition = map.get(issueId); - expect(disposition?.kind).toBe("invalid"); - if (disposition?.kind === "invalid") { - expect(disposition.reason).toBe("in_review_without_action_path"); - } - }); - - it("classifies a blocked issue with a healthy chain leaf as waiting on blocker chain", async () => { - const { companyId, agentId } = await seed(); - const blockerId = await insertIssue({ - companyId, - identifier: "DSP-5b", - title: "Blocker", - status: "in_progress", - assigneeAgentId: agentId, - }); - const blockedId = await insertIssue({ - companyId, - identifier: "DSP-5", - title: "Blocked", - status: "blocked", - assigneeAgentId: agentId, - }); - await db.insert(issueRelations).values({ - id: randomUUID(), - companyId, - issueId: blockerId, - relatedIssueId: blockedId, - type: "blocks", - }); - const runId = randomUUID(); - await db.insert(heartbeatRuns).values({ - id: runId, - companyId, - agentId, - status: "running", - contextSnapshot: { issueId: blockerId }, - }); - - const inputs = [ - inputFor({ id: blockedId, companyId, status: "blocked", assigneeAgentId: agentId }), - ]; - await backfillBlockedBySummaries(db, companyId, inputs); - const map = await listIssueExecutionDispositionsMap(db, companyId, inputs); - const disposition = map.get(blockedId); - expect(disposition?.kind).toBe("waiting"); - if (disposition?.kind === "waiting") { - expect(disposition.path).toBe("blocker_chain"); - } - }); - - it("flags a blocked issue with a cancelled blocker as invalid", async () => { - const { companyId, agentId } = await seed(); - const blockerId = await insertIssue({ - companyId, - identifier: "DSP-6b", - title: "Cancelled blocker", - status: "cancelled", - assigneeAgentId: agentId, - }); - const blockedId = await insertIssue({ - companyId, - identifier: "DSP-6", - title: "Blocked by cancelled", - status: "blocked", - assigneeAgentId: agentId, - }); - await db.insert(issueRelations).values({ - id: randomUUID(), - companyId, - issueId: blockerId, - relatedIssueId: blockedId, - type: "blocks", - }); - - const inputs = [ - inputFor({ id: blockedId, companyId, status: "blocked", assigneeAgentId: agentId }), - ]; - await backfillBlockedBySummaries(db, companyId, inputs); - const map = await listIssueExecutionDispositionsMap(db, companyId, inputs); - const disposition = map.get(blockedId); - // The cancelled blocker is filtered out by ne(status, "done") + companyId checks; backfill - // excludes it because it's not "open". So the issue ends up blocked-without-an-action-path, - // which is also invalid. Either invalid form is acceptable here; assert the kind only. - expect(disposition?.kind).toBe("invalid"); - }); - - it("treats an open liveness escalation as a waiting path for a cancelled blocker incident", async () => { - const { companyId, agentId } = await seed(); - const blockerId = await insertIssue({ - companyId, - identifier: "DSP-8b", - title: "Cancelled blocker", - status: "cancelled", - assigneeAgentId: agentId, - }); - const blockedId = await insertIssue({ - companyId, - identifier: "DSP-8", - title: "Blocked by cancelled with escalation", - status: "blocked", - assigneeAgentId: agentId, - }); - const incidentKey = [ - "harness_liveness", - companyId, - blockedId, - "blocked_by_cancelled_issue", - blockerId, - ].join(":"); - const escalationId = await insertIssue({ - companyId, - identifier: "DSP-8r", - title: "Liveness escalation", - status: "todo", - assigneeAgentId: agentId, - originKind: "harness_liveness_escalation", - originId: incidentKey, - originFingerprint: [ - "harness_liveness_leaf", - companyId, - "blocked_by_cancelled_issue", - blockerId, - ].join(":"), - }); - await db.insert(issueRelations).values([ - { - id: randomUUID(), - companyId, - issueId: blockerId, - relatedIssueId: blockedId, - type: "blocks", - }, - { - id: randomUUID(), - companyId, - issueId: escalationId, - relatedIssueId: blockedId, - type: "blocks", - }, - ]); - - const inputs = [ - inputFor({ id: blockedId, companyId, status: "blocked", assigneeAgentId: agentId }), - ]; - await backfillBlockedBySummaries(db, companyId, inputs); - const map = await listIssueExecutionDispositionsMap(db, companyId, inputs); - const disposition = map.get(blockedId); - expect(disposition?.kind).toBe("waiting"); - if (disposition?.kind === "waiting") { - expect(disposition.path).toBe("review_artifact"); - } - }); -}); diff --git a/server/src/__tests__/issue-execution-disposition.test.ts b/server/src/__tests__/issue-execution-disposition.test.ts deleted file mode 100644 index 13a65b78afc..00000000000 --- a/server/src/__tests__/issue-execution-disposition.test.ts +++ /dev/null @@ -1,525 +0,0 @@ -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 15b low-progress terminal run is continuable", - state({ - issue: { status: "in_progress" }, - latestRun: { - latestRunStatus: "succeeded", - livenessState: "plan_only", - nextAction: "none", - 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" }, - ], - [ - "row 27 todo recovery issue failure escalates in place", - state({ - issue: { status: "todo", 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__/issue-transition-guard.test.ts b/server/src/__tests__/issue-transition-guard.test.ts deleted file mode 100644 index b31cbf1c21d..00000000000 --- a/server/src/__tests__/issue-transition-guard.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; -import { - agents, - companies, - createDb, - issueRelations, - issues, -} from "@paperclipai/db"; -import { - getEmbeddedPostgresTestSupport, - startEmbeddedPostgresTestDatabase, -} from "./helpers/embedded-postgres.js"; -import { issueService } from "../services/issues.js"; - -const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); -const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; - -if (!embeddedPostgresSupport.supported) { - console.warn( - `Skipping embedded Postgres issue transition guard tests on this host: ${ - embeddedPostgresSupport.reason ?? "unsupported environment" - }`, - ); -} - -describeEmbeddedPostgres("issue transition guard", () => { - let db!: ReturnType; - let tempDb: Awaited> | null = null; - - beforeAll(async () => { - tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-transition-guard-"); - db = createDb(tempDb.connectionString); - }, 20_000); - - afterEach(async () => { - await db.delete(issueRelations); - await db.delete(issues); - await db.delete(agents); - await db.delete(companies); - }); - - afterAll(async () => { - await tempDb?.cleanup(); - }); - - async function seedCompanyAndAgents() { - const companyId = randomUUID(); - const coderAgentId = randomUUID(); - const reviewerAgentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); - await db.insert(agents).values([ - { - id: coderAgentId, - companyId, - name: "CodexCoder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }, - { - id: reviewerAgentId, - companyId, - name: "Reviewer", - role: "qa", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }, - ]); - return { companyId, coderAgentId, reviewerAgentId }; - } - - it("rejects bare agent-owned in_review creates", async () => { - const { companyId, coderAgentId } = await seedCompanyAndAgents(); - const svc = issueService(db); - - await expect( - svc.create(companyId, { - title: "Bare review", - status: "in_review", - assigneeAgentId: coderAgentId, - }), - ).rejects.toMatchObject({ - status: 422, - message: "Invalid issue transition: in_review_without_action_path", - }); - }); - - it("rejects blocked updates without blockers or a wait path", async () => { - const { companyId, coderAgentId } = await seedCompanyAndAgents(); - const svc = issueService(db); - const issue = await svc.create(companyId, { - title: "Needs a real blocker", - status: "todo", - assigneeAgentId: coderAgentId, - }); - - await expect(svc.update(issue.id, { status: "blocked" })).rejects.toMatchObject({ - status: 422, - message: "Invalid issue transition: blocked_without_action_path", - }); - }); - - it("accepts blocked writes with a healthy first-class blocker chain", async () => { - const { companyId, coderAgentId } = await seedCompanyAndAgents(); - const svc = issueService(db); - const blocker = await svc.create(companyId, { - title: "Do this first", - status: "todo", - assigneeAgentId: coderAgentId, - }); - - const issue = await svc.create(companyId, { - title: "Wait on blocker", - status: "blocked", - assigneeAgentId: coderAgentId, - blockedByIssueIds: [blocker.id], - }); - - expect(issue.status).toBe("blocked"); - }); - - it("accepts workflow-normalized review state with a typed participant", async () => { - const { companyId, coderAgentId, reviewerAgentId } = await seedCompanyAndAgents(); - const svc = issueService(db); - const issue = await svc.create(companyId, { - title: "Review me", - status: "in_progress", - assigneeAgentId: coderAgentId, - }); - - const updated = await svc.update(issue.id, { - status: "in_review", - assigneeAgentId: reviewerAgentId, - executionState: { - status: "pending", - currentStageId: randomUUID(), - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: reviewerAgentId, userId: null }, - returnAssignee: { type: "agent", agentId: coderAgentId, userId: null }, - reviewRequest: null, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, - }, - }); - - expect(updated).toMatchObject({ - id: issue.id, - status: "in_review", - assigneeAgentId: reviewerAgentId, - }); - }); -}); diff --git a/server/src/__tests__/recovery-classifiers.test.ts b/server/src/__tests__/recovery-classifiers.test.ts index 7089f0a2a87..f16b29f9bb1 100644 --- a/server/src/__tests__/recovery-classifiers.test.ts +++ b/server/src/__tests__/recovery-classifiers.test.ts @@ -1,6 +1,5 @@ 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, @@ -152,24 +151,4 @@ 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/routes/agents.ts b/server/src/routes/agents.ts index 4a551b86221..78172f321f1 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -85,9 +85,6 @@ import { recoveryService } from "../services/recovery/service.js"; const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000; const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024; -const LIVE_RUNS_DEFAULT_LIMIT = 50; -const WATCHDOG_DECISIONS = ["snooze", "continue", "dismissed_false_positive", "cancel_run"] as const; -type WatchdogDecision = (typeof WATCHDOG_DECISIONS)[number]; function readRunLogLimitBytes(value: unknown) { const parsed = Number(value ?? RUN_LOG_DEFAULT_LIMIT_BYTES); @@ -2575,7 +2572,7 @@ export function agentRoutes( assertCompanyAccess(req, companyId); const minCount = readLiveRunsQueryInt(req.query.minCount, 50); - const limit = readLiveRunsQueryInt(req.query.limit, LIVE_RUNS_DEFAULT_LIMIT, LIVE_RUNS_DEFAULT_LIMIT); + const limit = readLiveRunsQueryInt(req.query.limit, 50); const columns = { id: heartbeatRuns.id, @@ -2698,20 +2695,12 @@ export function agentRoutes( } assertCompanyAccess(req, existing.companyId); const decision = typeof req.body?.decision === "string" ? req.body.decision : ""; - if (!WATCHDOG_DECISIONS.includes(decision as WatchdogDecision)) { + if (!["snooze", "continue", "dismissed_false_positive"].includes(decision)) { res.status(400).json({ error: "Unsupported watchdog decision" }); return; } const evaluationIssueId = typeof req.body?.evaluationIssueId === "string" ? req.body.evaluationIssueId : null; const reason = typeof req.body?.reason === "string" ? req.body.reason.slice(0, 4000) : null; - if (decision === "cancel_run" && !reason?.trim()) { - res.status(400).json({ error: "Cancellation reason is required" }); - return; - } - if (decision === "cancel_run" && !evaluationIssueId) { - res.status(400).json({ error: "evaluationIssueId is required for cancellation" }); - return; - } const snoozedUntil = decision === "snooze" ? new Date(String(req.body?.snoozedUntil ?? "")) : null; @@ -2723,47 +2712,14 @@ export function agentRoutes( const row = await recovery.recordWatchdogDecision({ runId: existing.id, actor: req.actor, - decision: decision as WatchdogDecision, + decision: decision as "snooze" | "continue" | "dismissed_false_positive", evaluationIssueId, reason, snoozedUntil, createdByRunId: req.actor.runId ?? null, }); - if (decision !== "cancel_run") { - res.json(row); - return; - } - - const cancelledRun = await heartbeat.cancelRun(existing.id, reason?.trim()); - if (cancelledRun) { - await logActivity(db, { - companyId: cancelledRun.companyId, - actorType: req.actor.type === "agent" ? "agent" : "user", - actorId: req.actor.type === "agent" - ? req.actor.agentId ?? "agent" - : req.actor.type === "board" - ? req.actor.userId ?? "board" - : "unknown", - agentId: req.actor.type === "agent" ? req.actor.agentId ?? null : null, - runId: cancelledRun.id, - action: "heartbeat.watchdog_cancelled", - entityType: "heartbeat_run", - entityId: cancelledRun.id, - details: { - source: "routes.heartbeat_runs.watchdog_decisions", - decision, - evaluationIssueId, - reason: reason?.trim() ?? null, - createdByRunId: req.actor.runId ?? null, - targetRunId: cancelledRun.id, - previousStatus: existing.status, - resultStatus: cancelledRun.status, - }, - }); - } - - res.json({ decision: row, run: cancelledRun }); + res.json(row); }); router.get("/heartbeat-runs/:runId/events", async (req, res) => { diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index f7c5973964d..75c5f528989 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1113,21 +1113,6 @@ export function issueRoutes( documentsSvc.getIssueDocumentByKey(issue.id, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY), currentExecutionWorkspacePromise, ]); - const executionDisposition = await svc - .listExecutionDispositions(issue.companyId, [ - { - id: issue.id, - companyId: issue.companyId, - status: issue.status, - assigneeAgentId: issue.assigneeAgentId, - assigneeUserId: issue.assigneeUserId, - originKind: issue.originKind ?? null, - executionRunId: issue.executionRunId ?? null, - executionState: issue.executionState ?? null, - blockedBy: relations.blockedBy, - }, - ]) - .then((map) => map.get(issue.id) ?? null); res.json({ issue: { @@ -1137,7 +1122,6 @@ export function issueRoutes( description: issue.description, status: issue.status, ...(blockerAttention ? { blockerAttention } : {}), - ...(executionDisposition ? { executionDisposition } : {}), productivityReview, priority: issue.priority, projectId: issue.projectId, @@ -1236,27 +1220,11 @@ export function issueRoutes( ? await executionWorkspacesSvc.getById(issue.executionWorkspaceId) : null; const workProducts = await workProductsSvc.listForIssue(issue.id); - const executionDisposition = await svc - .listExecutionDispositions(issue.companyId, [ - { - id: issue.id, - companyId: issue.companyId, - status: issue.status, - assigneeAgentId: issue.assigneeAgentId, - assigneeUserId: issue.assigneeUserId, - originKind: issue.originKind ?? null, - executionRunId: issue.executionRunId ?? null, - executionState: issue.executionState ?? null, - blockedBy: relations.blockedBy, - }, - ]) - .then((map) => map.get(issue.id) ?? null); res.json({ ...issue, goalId: goal?.id ?? issue.goalId, ancestors, ...(blockerAttention ? { blockerAttention } : {}), - ...(executionDisposition ? { executionDisposition } : {}), productivityReview, blockedBy: relations.blockedBy, blocks: relations.blocks, @@ -2115,6 +2083,10 @@ export function issueRoutes( } } + const runToCancelForCancelledStatus = shouldCancelActiveRunForCancelledStatus + ? await resolveActiveIssueRun(existing) + : null; + if (hiddenAtRaw !== undefined) { updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null; } @@ -2297,50 +2269,26 @@ export function issueRoutes( return; } - const cancelledStatusRunIds: string[] = []; - const cancelledStatusWakeupIds: string[] = []; - if (shouldCancelActiveRunForCancelledStatus) { + let cancelledStatusRunId: string | null = null; + if (runToCancelForCancelledStatus) { try { - const cancelled = await heartbeat.cancelIssueRuns({ - companyId: issue.companyId, - issueId: issue.id, - reason: "Cancelled because the issue was marked cancelled", - }); - for (const cancelledRun of cancelled.runs) { - cancelledStatusRunIds.push(cancelledRun.id); + const cancelled = await heartbeat.cancelRun(runToCancelForCancelledStatus.id); + if (cancelled) { + cancelledStatusRunId = cancelled.id; await logActivity(db, { - companyId: cancelledRun.companyId, + companyId: cancelled.companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "heartbeat.cancelled", entityType: "heartbeat_run", - entityId: cancelledRun.id, - details: { agentId: cancelledRun.agentId, source: "issue_status_cancelled", issueId: existing.id }, - }); - } - for (const cancelledWakeup of cancelled.wakeups) { - cancelledStatusWakeupIds.push(cancelledWakeup.id); - await logActivity(db, { - companyId: issue.companyId, - actorType: actor.actorType, - actorId: actor.actorId, - agentId: actor.agentId, - runId: actor.runId, - action: "issue.wakeup_cancelled", - entityType: "agent_wakeup_request", - entityId: cancelledWakeup.id, - details: { - agentId: cancelledWakeup.agentId, - previousReason: cancelledWakeup.reason, - source: "issue_status_cancelled", - issueId: existing.id, - }, + entityId: cancelled.id, + details: { agentId: cancelled.agentId, source: "issue_status_cancelled", issueId: existing.id }, }); } } catch (err) { - logger.warn({ err, issueId: existing.id }, "failed to cancel runs for cancelled issue"); + logger.warn({ err, issueId: existing.id, runId: runToCancelForCancelledStatus.id }, "failed to cancel run for cancelled issue"); await logActivity(db, { companyId: existing.companyId, actorType: actor.actorType, @@ -2348,8 +2296,8 @@ export function issueRoutes( agentId: actor.agentId, runId: actor.runId, action: "heartbeat.cancel_failed", - entityType: "issue", - entityId: existing.id, + entityType: "heartbeat_run", + entityId: runToCancelForCancelledStatus.id, details: { source: "issue_status_cancelled", issueId: existing.id }, }); } @@ -2421,13 +2369,7 @@ export function issueRoutes( ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), - ...(cancelledStatusRunIds.length > 0 - ? { - cancelledStatusRunId: cancelledStatusRunIds[0], - cancelledStatusRunIds, - } - : {}), - ...(cancelledStatusWakeupIds.length > 0 ? { cancelledStatusWakeupIds } : {}), + ...(cancelledStatusRunId ? { cancelledStatusRunId } : {}), _previous: hasFieldChanges ? previous : undefined, ...summarizeIssueReferenceActivityDetails( updateReferenceDiff diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 39ea8722f04..d26f349567c 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -7304,11 +7304,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) return wakeupIds.length; } - async function cancelRunInternal( - runId: string, - reason = "Cancelled by control plane", - opts?: { startNextQueuedRun?: boolean }, - ) { + async function cancelRunInternal(runId: string, reason = "Cancelled by control plane") { const run = await getRun(runId); if (!run) throw notFound("Heartbeat run not found"); if (!CANCELLABLE_HEARTBEAT_RUN_STATUSES.includes(run.status as (typeof CANCELLABLE_HEARTBEAT_RUN_STATUSES)[number])) return run; @@ -7358,90 +7354,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) runningProcesses.delete(run.id); await finalizeAgentStatus(run.agentId, "cancelled"); - if (opts?.startNextQueuedRun !== false) { - await startNextQueuedRunForAgent(run.agentId); - } + await startNextQueuedRunForAgent(run.agentId); return cancelled; } - async function cancelIssueRunsInternal(input: { - companyId: string; - issueId: string; - reason?: string; - }) { - const reason = input.reason ?? "Cancelled because the issue was cancelled"; - const now = new Date(); - - const cancelledWakeups = await db - .update(agentWakeupRequests) - .set({ - status: "cancelled", - finishedAt: now, - error: reason, - updatedAt: now, - }) - .where( - and( - eq(agentWakeupRequests.companyId, input.companyId), - inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution"]), - isNull(agentWakeupRequests.runId), - sql`${agentWakeupRequests.payload} ->> 'issueId' = ${input.issueId}`, - ), - ) - .returning({ - id: agentWakeupRequests.id, - agentId: agentWakeupRequests.agentId, - reason: agentWakeupRequests.reason, - status: agentWakeupRequests.status, - payload: agentWakeupRequests.payload, - }); - - const issue = await db - .select({ - executionRunId: issues.executionRunId, - }) - .from(issues) - .where(and(eq(issues.companyId, input.companyId), eq(issues.id, input.issueId))) - .then((rows) => rows[0] ?? null); - - const contextIssueId = sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`; - const runs = await db - .select() - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.companyId, input.companyId), - inArray(heartbeatRuns.status, [...CANCELLABLE_HEARTBEAT_RUN_STATUSES]), - issue?.executionRunId - ? or(eq(heartbeatRuns.id, issue.executionRunId), sql`${contextIssueId} = ${input.issueId}`) - : sql`${contextIssueId} = ${input.issueId}`, - ), - ) - .orderBy( - sql`case when ${heartbeatRuns.status} = 'running' then 0 when ${heartbeatRuns.status} = 'queued' then 1 else 2 end`, - asc(heartbeatRuns.createdAt), - ); - - const cancelledRuns: Array = []; - const affectedAgentIds = new Set(); - for (const run of runs) { - const cancelled = await cancelRunInternal(run.id, reason, { startNextQueuedRun: false }); - affectedAgentIds.add(run.agentId); - if (cancelled?.status === "cancelled") { - cancelledRuns.push(cancelled); - } - } - - for (const agentId of affectedAgentIds) { - await startNextQueuedRunForAgent(agentId); - } - - return { - runs: cancelledRuns, - wakeups: cancelledWakeups, - }; - } - async function cancelActiveForAgentInternal(agentId: string, reason = "Cancelled due to agent pause") { const agent = await getAgent(agentId); const runs = await db @@ -7816,9 +7732,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) return { checked, enqueued, skipped }; }, - cancelRun: (runId: string, reason?: string) => cancelRunInternal(runId, reason), - - cancelIssueRuns: (input: { companyId: string; issueId: string; reason?: string }) => cancelIssueRunsInternal(input), + cancelRun: (runId: string) => cancelRunInternal(runId), cancelActiveForAgent: (agentId: string) => cancelActiveForAgentInternal(agentId), diff --git a/server/src/services/issue-execution-disposition-resolver.ts b/server/src/services/issue-execution-disposition-resolver.ts deleted file mode 100644 index f98294b78db..00000000000 --- a/server/src/services/issue-execution-disposition-resolver.ts +++ /dev/null @@ -1,521 +0,0 @@ -import { and, desc, eq, inArray, isNull, ne, notInArray, sql } from "drizzle-orm"; -import { - agentWakeupRequests, - agents, - approvals, - heartbeatRuns, - issueApprovals, - issueRelations, - issueThreadInteractions, - issues, -} from "@paperclipai/db"; -import type { - IssueExecutionDisposition, - IssueOriginKind, - IssueRelationIssueSummary, - IssueStatus, -} from "@paperclipai/shared"; -import { - isLiveExplicitApprovalWaitingPath, - isLiveExplicitInteractionWaitingPath, -} from "./recovery/explicit-waiting-paths.js"; -import { parseIssueGraphLivenessIncidentKey } from "./recovery/origins.js"; -import { - classifyIssueExecutionDisposition, - type IssueExecutionParticipantState, - type IssueExecutionRunLivenessState, - type IssueExecutionRunStatus, -} from "./issue-execution-disposition.js"; - -const ACTIVE_RUN_STATUSES = ["queued", "running"] as const; -const ACTIVE_WAKE_STATUSES = ["queued", "deferred_issue_execution"] as const; -const PENDING_INTERACTION_STATUSES = ["pending"] as const; -const WAITING_INTERACTION_KINDS = ["request_confirmation", "ask_user_questions", "suggest_tasks"] as const; -const PENDING_APPROVAL_STATUSES = ["pending", "revision_requested"] as const; -const STRANDED_RECOVERY_ORIGIN_KIND = "stranded_issue_recovery" as const; -const HARNESS_LIVENESS_ESCALATION_ORIGIN_KIND = "harness_liveness_escalation" as const; -const PRODUCTIVITY_REVIEW_ORIGIN_KIND = "issue_productivity_review" as const; -const NON_TERMINAL_RECOVERY_STATUSES = ["done", "cancelled"] as const; -const INVOKABLE_AGENT_STATUSES: ReadonlySet = new Set(["active", "idle", "running", "error"]); -const DEFAULT_MAX_CONTINUATION_ATTEMPTS = 2; -const QUERY_CHUNK_SIZE = 500; - -export interface IssueExecutionDispositionInputNode { - id: string; - companyId: string; - status: IssueStatus | string; - assigneeAgentId: string | null; - assigneeUserId: string | null; - originKind: IssueOriginKind | string | null; - executionRunId: string | null; - executionState?: Record | null; - blockedBy?: IssueRelationIssueSummary[]; -} - -interface ExplicitInteractionRow { - companyId: string; - issueId: string; - status: string; - createdByUserId: string | null; - createdAt: Date; -} - -interface ExplicitApprovalRow { - companyId: string; - issueId: string; - status: string; - requestedByUserId: string | null; - linkedByUserId: string | null; - createdAt: Date; - updatedAt: Date; - linkedAt: Date; -} - -interface LatestRunRow { - issueId: string; - status: string; - livenessState: string | null; - continuationAttempt: number | null; - contextSnapshot: unknown; -} - -function chunk(items: T[], size: number): T[][] { - if (items.length === 0) return []; - const out: T[][] = []; - for (let i = 0; i < items.length; i += size) { - out.push(items.slice(i, i + size)); - } - return out; -} - -function readPrincipalAgentId(principal: unknown): string | null { - if (!principal || typeof principal !== "object") return null; - const value = principal as Record; - return value.type === "agent" && typeof value.agentId === "string" && value.agentId.length > 0 - ? value.agentId - : null; -} - -function principalIsResolvableUser(principal: unknown): boolean { - if (!principal || typeof principal !== "object") return false; - const value = principal as Record; - return value.type === "user" && typeof value.userId === "string" && value.userId.length > 0; -} - -function participantStateForExecutionState( - executionState: Record | null | undefined, - agentStatusById: ReadonlyMap, -): IssueExecutionParticipantState { - if (!executionState) return "none"; - const status = (executionState.status as string | undefined) ?? null; - if (!status || status === "satisfied") return "none"; - const principal = executionState.currentParticipant ?? null; - if (!principal) return "none"; - const agentId = readPrincipalAgentId(principal); - if (agentId) { - const agentStatus = agentStatusById.get(agentId); - if (agentStatus && INVOKABLE_AGENT_STATUSES.has(agentStatus)) return "valid"; - return "invalid"; - } - if (principalIsResolvableUser(principal)) return "valid"; - return "invalid"; -} - -function readNextActionFlag(contextSnapshot: unknown): boolean { - if (!contextSnapshot || typeof contextSnapshot !== "object") return false; - const next = (contextSnapshot as Record).nextAction; - return typeof next === "string" && next.trim().length > 0; -} - -export async function listIssueExecutionDispositionsMap( - dbOrTx: any, - companyId: string, - issueRows: IssueExecutionDispositionInputNode[], -): Promise> { - const result = new Map(); - const sameCompanyRows = issueRows.filter((row) => row.companyId === companyId); - if (sameCompanyRows.length === 0) return result; - - const issueIds = sameCompanyRows.map((row) => row.id); - - // The list select drops executionState for performance; rehydrate it from the DB - // for the issues that actually need participant evidence (in_review). - const reviewIdsMissingState = sameCompanyRows - .filter((row) => row.status === "in_review" && row.executionState === undefined) - .map((row) => row.id); - const executionStateById = new Map | null>(); - for (const part of chunk(reviewIdsMissingState, QUERY_CHUNK_SIZE)) { - const rows: Array<{ id: string; executionState: Record | null }> = await dbOrTx - .select({ id: issues.id, executionState: issues.executionState }) - .from(issues) - .where(and(eq(issues.companyId, companyId), inArray(issues.id, part))); - for (const r of rows) executionStateById.set(r.id, r.executionState ?? null); - } - const resolvedExecutionStateFor = (row: IssueExecutionDispositionInputNode) => - row.executionState !== undefined ? row.executionState : executionStateById.get(row.id) ?? null; - - const agentIds = new Set(); - for (const row of sameCompanyRows) { - if (row.assigneeAgentId) agentIds.add(row.assigneeAgentId); - const state = resolvedExecutionStateFor(row); - const participant = state?.currentParticipant ?? null; - const participantAgentId = readPrincipalAgentId(participant); - if (participantAgentId) agentIds.add(participantAgentId); - } - - const activeRunIssueIds = new Set(); - const queuedWakeIssueIds = new Set(); - const explicitInteractionLive = new Set(); - const explicitApprovalLive = new Set(); - const openRecoveryIssueOrigins = new Set(); - const openProductivityReviewOrigins = new Set(); - const latestRunByIssueId = new Map(); - - // Active runs: any heartbeatRun in queued/running referencing the issue via contextSnapshot. - for (const part of chunk(issueIds, QUERY_CHUNK_SIZE)) { - const rows: Array<{ issueId: string | null }> = await dbOrTx - .select({ - issueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, - }) - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.companyId, companyId), - inArray(heartbeatRuns.status, [...ACTIVE_RUN_STATUSES]), - inArray(sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, part), - ), - ); - for (const row of rows) { - if (row.issueId) activeRunIssueIds.add(row.issueId); - } - } - - // Active runs by executionRunId — covers cases where the run claimed the issue but the - // contextSnapshot lookup misses (legacy snapshots). - const executionRunIds = sameCompanyRows - .map((row) => row.executionRunId) - .filter((value): value is string => Boolean(value)); - for (const part of chunk(executionRunIds, QUERY_CHUNK_SIZE)) { - const rows: Array<{ id: string }> = await dbOrTx - .select({ id: heartbeatRuns.id }) - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.companyId, companyId), - inArray(heartbeatRuns.status, [...ACTIVE_RUN_STATUSES]), - inArray(heartbeatRuns.id, part), - ), - ); - if (rows.length === 0) continue; - const liveExecutionRunIds = new Set(rows.map((row) => row.id)); - for (const row of sameCompanyRows) { - if (row.executionRunId && liveExecutionRunIds.has(row.executionRunId)) { - activeRunIssueIds.add(row.id); - } - } - } - - // Queued wakes: agent_wakeup_requests with issueId payload, no runId yet. - for (const part of chunk(issueIds, QUERY_CHUNK_SIZE)) { - const rows: Array<{ issueId: string | null }> = await dbOrTx - .select({ - issueId: sql`${agentWakeupRequests.payload} ->> 'issueId'`, - }) - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.companyId, companyId), - inArray(agentWakeupRequests.status, [...ACTIVE_WAKE_STATUSES]), - sql`${agentWakeupRequests.runId} is null`, - inArray(sql`${agentWakeupRequests.payload} ->> 'issueId'`, part), - ), - ); - for (const row of rows) { - if (row.issueId) queuedWakeIssueIds.add(row.issueId); - } - } - - // Pending interactions. - const interactionsByIssueId = new Map(); - for (const part of chunk(issueIds, QUERY_CHUNK_SIZE)) { - const rows: ExplicitInteractionRow[] = await dbOrTx - .select({ - companyId: issueThreadInteractions.companyId, - issueId: issueThreadInteractions.issueId, - status: issueThreadInteractions.status, - createdByUserId: issueThreadInteractions.createdByUserId, - createdAt: issueThreadInteractions.createdAt, - }) - .from(issueThreadInteractions) - .where( - and( - eq(issueThreadInteractions.companyId, companyId), - inArray(issueThreadInteractions.status, [...PENDING_INTERACTION_STATUSES]), - inArray(issueThreadInteractions.kind, [...WAITING_INTERACTION_KINDS]), - inArray(issueThreadInteractions.issueId, part), - ), - ); - for (const row of rows) { - const list = interactionsByIssueId.get(row.issueId) ?? []; - list.push(row); - interactionsByIssueId.set(row.issueId, list); - } - } - - // Pending approvals. - const approvalsByIssueId = new Map(); - for (const part of chunk(issueIds, QUERY_CHUNK_SIZE)) { - const rows: ExplicitApprovalRow[] = await dbOrTx - .select({ - companyId: issueApprovals.companyId, - issueId: issueApprovals.issueId, - status: approvals.status, - requestedByUserId: approvals.requestedByUserId, - linkedByUserId: issueApprovals.linkedByUserId, - createdAt: approvals.createdAt, - updatedAt: approvals.updatedAt, - linkedAt: issueApprovals.createdAt, - }) - .from(issueApprovals) - .innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id)) - .where( - and( - eq(issueApprovals.companyId, companyId), - inArray(approvals.status, [...PENDING_APPROVAL_STATUSES]), - inArray(issueApprovals.issueId, part), - ), - ); - for (const row of rows) { - const list = approvalsByIssueId.get(row.issueId) ?? []; - list.push(row); - approvalsByIssueId.set(row.issueId, list); - } - } - - const now = new Date(); - for (const row of sameCompanyRows) { - const issueShape = { - companyId: row.companyId, - id: row.id, - assigneeUserId: row.assigneeUserId, - executionState: resolvedExecutionStateFor(row), - }; - const interactions = interactionsByIssueId.get(row.id) ?? []; - if (interactions.some((entry) => isLiveExplicitInteractionWaitingPath(issueShape, entry, now))) { - explicitInteractionLive.add(row.id); - } - const approvalsForIssue = approvalsByIssueId.get(row.id) ?? []; - if (approvalsForIssue.some((entry) => isLiveExplicitApprovalWaitingPath(issueShape, entry, now))) { - explicitApprovalLive.add(row.id); - } - } - - // Open recovery / harness escalation / productivity review issues whose origin points at our inputs. - for (const part of chunk(issueIds, QUERY_CHUNK_SIZE)) { - const rows: Array<{ originId: string | null }> = await dbOrTx - .select({ originId: issues.originId }) - .from(issues) - .where( - and( - eq(issues.companyId, companyId), - eq(issues.originKind, STRANDED_RECOVERY_ORIGIN_KIND), - isNull(issues.hiddenAt), - inArray(issues.originId, part), - notInArray(issues.status, [...NON_TERMINAL_RECOVERY_STATUSES]), - ), - ); - for (const r of rows) { - if (r.originId) openRecoveryIssueOrigins.add(r.originId); - } - } - const livenessRecoveryRows: Array<{ originId: string | null }> = await dbOrTx - .select({ originId: issues.originId }) - .from(issues) - .where( - and( - eq(issues.companyId, companyId), - eq(issues.originKind, HARNESS_LIVENESS_ESCALATION_ORIGIN_KIND), - isNull(issues.hiddenAt), - notInArray(issues.status, [...NON_TERMINAL_RECOVERY_STATUSES]), - ), - ); - for (const r of livenessRecoveryRows) { - const parsed = parseIssueGraphLivenessIncidentKey(r.originId); - if (!parsed || parsed.companyId !== companyId) continue; - openRecoveryIssueOrigins.add(parsed.issueId); - openRecoveryIssueOrigins.add(parsed.leafIssueId); - } - for (const part of chunk(issueIds, QUERY_CHUNK_SIZE)) { - const rows: Array<{ originId: string | null }> = await dbOrTx - .select({ originId: issues.originId }) - .from(issues) - .where( - and( - eq(issues.companyId, companyId), - eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND), - isNull(issues.hiddenAt), - inArray(issues.originId, part), - notInArray(issues.status, [...NON_TERMINAL_RECOVERY_STATUSES]), - ), - ); - for (const r of rows) { - if (r.originId) openProductivityReviewOrigins.add(r.originId); - } - } - - // Latest issue-linked run, used to populate run evidence. - const recoveryCandidateIds = sameCompanyRows - .filter((row) => - row.assigneeAgentId && - !activeRunIssueIds.has(row.id) && - !queuedWakeIssueIds.has(row.id) && - (row.status === "todo" || row.status === "in_progress"), - ) - .map((row) => row.id); - for (const part of chunk(recoveryCandidateIds, QUERY_CHUNK_SIZE)) { - const rows: LatestRunRow[] = await dbOrTx - .select({ - issueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, - status: heartbeatRuns.status, - livenessState: heartbeatRuns.livenessState, - continuationAttempt: heartbeatRuns.continuationAttempt, - contextSnapshot: heartbeatRuns.contextSnapshot, - }) - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.companyId, companyId), - inArray(sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, part), - ), - ) - .orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)); - for (const row of rows) { - if (!row.issueId) continue; - if (latestRunByIssueId.has(row.issueId)) continue; - latestRunByIssueId.set(row.issueId, row); - } - } - - // Agents. - const agentStatusById = new Map(); - if (agentIds.size > 0) { - const rows: Array<{ id: string; status: string }> = await dbOrTx - .select({ id: agents.id, status: agents.status }) - .from(agents) - .where(and(eq(agents.companyId, companyId), inArray(agents.id, [...agentIds]))); - for (const r of rows) agentStatusById.set(r.id, r.status); - } - - for (const row of sameCompanyRows) { - const agentStatus = row.assigneeAgentId ? agentStatusById.get(row.assigneeAgentId) ?? null : null; - const latestRun = latestRunByIssueId.get(row.id) ?? null; - const productivityReviewNeeded = openProductivityReviewOrigins.has(row.id); - const openRecoveryIssue = openRecoveryIssueOrigins.has(row.id); - const blockers = (row.blockedBy ?? []).map((blocker) => ({ - issue: { - id: blocker.id, - status: blocker.status as IssueStatus, - assigneeAgentId: blocker.assigneeAgentId ?? null, - assigneeUserId: blocker.assigneeUserId ?? null, - }, - })); - - const disposition = classifyIssueExecutionDisposition({ - issue: { - id: row.id, - status: row.status as IssueStatus, - assigneeAgentId: row.assigneeAgentId, - assigneeUserId: row.assigneeUserId, - originKind: row.originKind ?? null, - }, - agent: row.assigneeAgentId - ? { id: row.assigneeAgentId, status: agentStatus ?? "terminated" } - : null, - execution: { - activeRun: activeRunIssueIds.has(row.id), - queuedWake: queuedWakeIssueIds.has(row.id), - }, - waits: { - participant: participantStateForExecutionState(resolvedExecutionStateFor(row), agentStatusById), - pendingInteraction: explicitInteractionLive.has(row.id) ? "live" : "none", - pendingApproval: explicitApprovalLive.has(row.id) ? "live" : "none", - openRecoveryIssue, - openProductivityReviewIssue: productivityReviewNeeded, - productivityReviewNeeded, - }, - latestRun: latestRun - ? { - latestRunStatus: latestRun.status as IssueExecutionRunStatus, - livenessState: (latestRun.livenessState as IssueExecutionRunLivenessState | null) ?? null, - nextAction: readNextActionFlag(latestRun.contextSnapshot) ? "runnable" : "none", - continuationAttempt: latestRun.continuationAttempt, - maxContinuationAttempts: DEFAULT_MAX_CONTINUATION_ATTEMPTS, - } - : null, - blockers, - }); - - result.set(row.id, disposition); - } - - return result; -} - -// Helper: load blockedBy relation summaries for issues that don't already have them on the input. -export async function backfillBlockedBySummaries( - dbOrTx: any, - companyId: string, - rows: IssueExecutionDispositionInputNode[], -): Promise { - const needsBackfill = rows.filter((row) => row.status === "blocked" && !row.blockedBy); - if (needsBackfill.length === 0) return; - const ids = needsBackfill.map((row) => row.id); - const blockerRows: Array<{ - issueId: string; - blockerIssueId: string; - identifier: string | null; - title: string; - status: string; - priority: string | null; - assigneeAgentId: string | null; - assigneeUserId: string | null; - }> = await dbOrTx - .select({ - issueId: issueRelations.relatedIssueId, - blockerIssueId: issues.id, - identifier: issues.identifier, - title: issues.title, - status: issues.status, - priority: issues.priority, - assigneeAgentId: issues.assigneeAgentId, - assigneeUserId: issues.assigneeUserId, - }) - .from(issueRelations) - .innerJoin(issues, eq(issueRelations.issueId, issues.id)) - .where( - and( - eq(issueRelations.companyId, companyId), - eq(issueRelations.type, "blocks"), - inArray(issueRelations.relatedIssueId, ids), - eq(issues.companyId, companyId), - ne(issues.status, "done"), - ), - ); - const blockersByIssueId = new Map(); - for (const row of blockerRows) { - const list = blockersByIssueId.get(row.issueId) ?? []; - list.push({ - id: row.blockerIssueId, - identifier: row.identifier, - title: row.title, - status: row.status as IssueStatus, - priority: (row.priority ?? "medium") as IssueRelationIssueSummary["priority"], - assigneeAgentId: row.assigneeAgentId, - assigneeUserId: row.assigneeUserId, - }); - blockersByIssueId.set(row.issueId, list); - } - for (const row of needsBackfill) { - row.blockedBy = blockersByIssueId.get(row.id) ?? []; - } -} diff --git a/server/src/services/issue-execution-disposition.ts b/server/src/services/issue-execution-disposition.ts deleted file mode 100644 index 05696fa88ad..00000000000 --- a/server/src/services/issue-execution-disposition.ts +++ /dev/null @@ -1,337 +0,0 @@ -import type { - AgentStatus, - IssueOriginKind, - IssueStatus, - RunLivenessState, -} 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 = RunLivenessState | "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; - const livenessState = run?.livenessState ?? null; - const actionableLowProgressState = livenessState === "plan_only" || livenessState === "empty_response"; - const runnableFollowupState = - (livenessState === "advanced" || livenessState === "needs_followup") && - run?.nextAction === "runnable"; - return ( - run?.latestRunStatus === "succeeded" && - (actionableLowProgressState || runnableFollowupState) && - attempt < maxAttempts - ); -} - -function hasExhaustedOrAmbiguousContinuation(run: IssueExecutionRunEvidence | null | undefined) { - if (run?.latestRunStatus !== "succeeded") return false; - if (run.livenessState === "completed") return true; - if (run.livenessState === "blocked" || run.livenessState === "failed") return true; - if (run.livenessState === "plan_only" || run.livenessState === "empty_response") { - return (run.continuationAttempt ?? 0) >= (run.maxContinuationAttempts ?? 2); - } - if (run.livenessState !== "advanced" && run.livenessState !== "needs_followup") 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 { - if (isRecoveryIssue(input.issue) && (hasAgentOwner(input.issue) || hasHumanOwner(input.issue))) { - return { kind: "waiting", path: "external_owner_action" }; - } - - const path = waitingPath(input.issue, input.waits); - if (path) return { kind: "waiting", path }; - - 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" }; - } - - 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 (path) return { kind: "live", path }; - if (!hasAgentOwner(issue)) return { kind: "resting" }; - - if (status === "todo") { - if (isRecoveryIssue(issue) && runFailed(input.latestRun)) { - return { kind: "human_escalation_required", owner: "recovery_owner" }; - } - if (!agentInvokable) { - return { kind: "human_escalation_required", owner: budgetBlocked ? "board" : "manager" }; - } - 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 (!agentInvokable) { - return { kind: "human_escalation_required", owner: budgetBlocked ? "board" : "manager" }; - } - 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" }; -} diff --git a/server/src/services/issue-transition-guard.ts b/server/src/services/issue-transition-guard.ts deleted file mode 100644 index 2f4fe747884..00000000000 --- a/server/src/services/issue-transition-guard.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { and, desc, eq, inArray, isNull, notInArray, sql } from "drizzle-orm"; -import type { Db } from "@paperclipai/db"; -import { - agentWakeupRequests, - agents, - approvals, - heartbeatRuns, - issueApprovals, - issueRelations, - issueThreadInteractions, - issues, -} from "@paperclipai/db"; -import type { IssueExecutionDisposition } from "@paperclipai/shared"; -import { unprocessable } from "../errors.js"; -import { - classifyIssueExecutionDisposition, - type IssueExecutionParticipantState, - type IssueExecutionStateVector, -} from "./issue-execution-disposition.js"; -import { parseIssueExecutionState } from "./issue-execution-policy.js"; -import { - isLiveExplicitApprovalWaitingPath, - isLiveExplicitInteractionWaitingPath, -} from "./recovery/explicit-waiting-paths.js"; - -const ACTIVE_RUN_STATUSES = ["queued", "running"] as const; -const SCHEDULED_RETRY_RUN_STATUSES = ["scheduled_retry"] as const; -const ACTIVE_WAKE_STATUSES = ["queued", "deferred_issue_execution"] as const; -const PENDING_INTERACTION_STATUSES = ["pending"] as const; -const WAITING_INTERACTION_KINDS = ["request_confirmation", "ask_user_questions"] as const; -const PENDING_APPROVAL_STATUSES = ["pending", "revision_requested"] as const; -const OPEN_RECOVERY_ORIGIN_KINDS = ["harness_liveness_escalation", "stranded_issue_recovery"] as const; -const OPEN_RECOVERY_TERMINAL_STATUSES = ["done", "cancelled"] as const; - -type DbReader = Pick; -type IssueRow = typeof issues.$inferSelect; - -export type IssueTransitionGuardSource = "create" | "update"; - -function isValidParticipant(participant: unknown) { - if (!participant || typeof participant !== "object") return false; - const value = participant as Record; - if (value.type === "agent") return typeof value.agentId === "string" && value.agentId.length > 0; - if (value.type === "user") return typeof value.userId === "string" && value.userId.length > 0; - return false; -} - -function isGuardedStatus(status: string | null | undefined) { - return status === "in_progress" || status === "in_review" || status === "blocked"; -} - -function invalidMessage(disposition: Extract) { - return `Invalid issue transition: ${disposition.reason}`; -} - -async function buildIssueExecutionStateVector( - dbOrTx: DbReader, - issue: IssueRow, -): Promise { - const [agent] = issue.assigneeAgentId - ? await dbOrTx - .select({ - id: agents.id, - status: agents.status, - }) - .from(agents) - .where(and(eq(agents.companyId, issue.companyId), eq(agents.id, issue.assigneeAgentId))) - : []; - - const [executionRun] = issue.executionRunId - ? await dbOrTx - .select({ status: heartbeatRuns.status }) - .from(heartbeatRuns) - .where(and(eq(heartbeatRuns.companyId, issue.companyId), eq(heartbeatRuns.id, issue.executionRunId))) - : []; - - const [queuedWake] = await dbOrTx - .select({ - issueId: sql`${agentWakeupRequests.payload} ->> 'issueId'`, - status: agentWakeupRequests.status, - }) - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.companyId, issue.companyId), - inArray(agentWakeupRequests.status, [...ACTIVE_WAKE_STATUSES]), - sql`${agentWakeupRequests.runId} is null`, - eq(sql`${agentWakeupRequests.payload} ->> 'issueId'`, issue.id), - ), - ) - .orderBy(desc(agentWakeupRequests.createdAt), desc(agentWakeupRequests.id)) - .limit(1); - - const executionState = parseIssueExecutionState(issue.executionState); - let participant: IssueExecutionParticipantState = "none"; - if (executionState?.status === "pending") { - participant = isValidParticipant(executionState.currentParticipant) ? "valid" : "invalid"; - } - - let pendingInteraction: "none" | "live" = "none"; - const interactionRows: Array<{ - issueId: string; - companyId: string; - status: string; - kind: string; - createdByUserId: string | null; - createdAt: Date; - }> = await dbOrTx - .select({ - issueId: issueThreadInteractions.issueId, - companyId: issueThreadInteractions.companyId, - status: issueThreadInteractions.status, - kind: issueThreadInteractions.kind, - createdByUserId: issueThreadInteractions.createdByUserId, - createdAt: issueThreadInteractions.createdAt, - }) - .from(issueThreadInteractions) - .where( - and( - eq(issueThreadInteractions.companyId, issue.companyId), - eq(issueThreadInteractions.issueId, issue.id), - inArray(issueThreadInteractions.status, [...PENDING_INTERACTION_STATUSES]), - inArray(issueThreadInteractions.kind, [...WAITING_INTERACTION_KINDS]), - ), - ); - for (const row of interactionRows) { - if (isLiveExplicitInteractionWaitingPath(issue, row)) { - pendingInteraction = "live"; - break; - } - } - - let pendingApproval: "none" | "live" = "none"; - const approvalRows: Array<{ - issueId: string; - companyId: string; - status: string; - requestedByUserId: string | null; - linkedByUserId: string | null; - createdAt: Date; - updatedAt: Date; - linkedAt: Date; - }> = await dbOrTx - .select({ - issueId: issueApprovals.issueId, - companyId: issueApprovals.companyId, - status: approvals.status, - requestedByUserId: approvals.requestedByUserId, - linkedByUserId: issueApprovals.linkedByUserId, - createdAt: approvals.createdAt, - updatedAt: approvals.updatedAt, - linkedAt: issueApprovals.createdAt, - }) - .from(issueApprovals) - .innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id)) - .where( - and( - eq(issueApprovals.companyId, issue.companyId), - eq(issueApprovals.issueId, issue.id), - inArray(approvals.status, [...PENDING_APPROVAL_STATUSES]), - ), - ); - for (const row of approvalRows) { - if (isLiveExplicitApprovalWaitingPath(issue, row)) { - pendingApproval = "live"; - break; - } - } - - const recoveryRows: Array<{ id: string }> = await dbOrTx - .select({ id: issues.id }) - .from(issues) - .where( - and( - eq(issues.companyId, issue.companyId), - inArray(issues.originKind, [...OPEN_RECOVERY_ORIGIN_KINDS]), - eq(issues.originId, issue.id), - isNull(issues.hiddenAt), - notInArray(issues.status, [...OPEN_RECOVERY_TERMINAL_STATUSES]), - ), - ) - .limit(1); - - const blockerRows: Array<{ - id: string; - status: string; - assigneeAgentId: string | null; - assigneeUserId: string | null; - originKind: string | null; - }> = await dbOrTx - .select({ - id: issues.id, - status: issues.status, - assigneeAgentId: issues.assigneeAgentId, - assigneeUserId: issues.assigneeUserId, - originKind: issues.originKind, - }) - .from(issueRelations) - .innerJoin(issues, eq(issueRelations.issueId, issues.id)) - .where( - and( - eq(issueRelations.companyId, issue.companyId), - eq(issueRelations.relatedIssueId, issue.id), - eq(issueRelations.type, "blocks"), - eq(issues.companyId, issue.companyId), - ), - ); - - return { - issue: { - id: issue.id, - status: issue.status as IssueExecutionStateVector["issue"]["status"], - assigneeAgentId: issue.assigneeAgentId, - assigneeUserId: issue.assigneeUserId, - originKind: issue.originKind, - }, - agent: agent - ? { - id: agent.id, - status: agent.status, - } - : null, - execution: { - activeRun: executionRun ? (ACTIVE_RUN_STATUSES as readonly string[]).includes(executionRun.status) : false, - scheduledRetry: executionRun - ? (SCHEDULED_RETRY_RUN_STATUSES as readonly string[]).includes(executionRun.status) - : false, - queuedWake: queuedWake?.status === "queued", - deferredExecution: queuedWake?.status === "deferred_issue_execution", - }, - waits: { - participant, - pendingInteraction, - pendingApproval, - openRecoveryIssue: recoveryRows.length > 0 || - blockerRows.some((blocker) => - (OPEN_RECOVERY_ORIGIN_KINDS as readonly string[]).includes(blocker.originKind ?? "") - ), - }, - blockers: blockerRows.map((blocker) => ({ - issue: { - id: blocker.id, - status: blocker.status as IssueExecutionStateVector["issue"]["status"], - assigneeAgentId: blocker.assigneeAgentId, - assigneeUserId: blocker.assigneeUserId, - }, - })), - }; -} - -export async function assertIssueTransitionAllowed(input: { - db: DbReader; - issue: IssueRow; - source: IssueTransitionGuardSource; - statusTouched?: boolean; - assigneeTouched?: boolean; - blockerTouched?: boolean; - executionStateTouched?: boolean; -}) { - const shouldGuard = - isGuardedStatus(input.issue.status) && - (input.source === "create" || - input.statusTouched || - input.assigneeTouched || - input.blockerTouched || - input.executionStateTouched); - if (!shouldGuard) return; - - const stateVector = await buildIssueExecutionStateVector(input.db, input.issue); - const disposition = classifyIssueExecutionDisposition(stateVector); - if (disposition.kind !== "invalid") return; - - throw unprocessable(invalidMessage(disposition), { - disposition, - suggestedCorrection: disposition.suggestedCorrection, - }); -} diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 42816974eba..b6e57fd2120 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -33,7 +33,6 @@ import type { IssueBlockerAttentionState, IssueBlockerNextActionHint, IssueBlockerNextActionOwner, - IssueStatus, IssueProductivityReview, IssueProductivityReviewTrigger, IssueRelationIssueSummary, @@ -60,17 +59,6 @@ import { isLiveExplicitApprovalWaitingPath, isLiveExplicitInteractionWaitingPath, } from "./recovery/explicit-waiting-paths.js"; -import { parseIssueGraphLivenessIncidentKey } from "./recovery/origins.js"; -import { - classifyIssueExecutionDisposition, - type IssueExecutionRunLivenessState, -} from "./issue-execution-disposition.js"; -import { - backfillBlockedBySummaries, - listIssueExecutionDispositionsMap, - type IssueExecutionDispositionInputNode, -} from "./issue-execution-disposition-resolver.js"; -import { assertIssueTransitionAllowed } from "./issue-transition-guard.js"; const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"]; const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500; @@ -896,37 +884,6 @@ function classifyLeafRecovery(input: { // the existing "no owner" surface continues to apply. if (owner.type === "none") return null; - const disposition = classifyIssueExecutionDisposition({ - issue: { - id: node.id, - status: node.status as IssueStatus, - assigneeAgentId: node.assigneeAgentId, - assigneeUserId: node.assigneeUserId, - }, - agent: node.assigneeAgentId - ? { - id: node.assigneeAgentId, - status: agentInvokable ? "idle" : "paused", - } - : null, - execution: { - activeRun: hasActivePath, - }, - waits: { - productivityReviewNeeded: hasProductivityReview, - }, - latestRun: { - latestRunStatus: "succeeded", - livenessState: latestRun.livenessState as IssueExecutionRunLivenessState, - nextAction: "runnable", - continuationAttempt: latestRun.continuationAttempt, - maxContinuationAttempts, - }, - }); - if (disposition.kind !== "agent_continuable" && disposition.kind !== "human_escalation_required") { - return null; - } - let reason: LeafRecoveryClassification["reason"]; if (hasProductivityReview) { reason = "continuation_suppressed"; @@ -1428,36 +1385,29 @@ async function listIssueBlockerAttentionMap( }))); } - } - - const recoveryRows: Array<{ - id: string; - originId: string | null; - assigneeAgentId: string | null; - assigneeUserId: string | null; - }> = await dbOrTx - .select({ - id: issues.id, - originId: issues.originId, - assigneeAgentId: issues.assigneeAgentId, - assigneeUserId: issues.assigneeUserId, - }) - .from(issues) - .where( - and( - eq(issues.companyId, companyId), - eq(issues.originKind, BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND), - isNull(issues.hiddenAt), - notInArray(issues.status, BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES), - ), - ); - for (const row of recoveryRows) { - const parsed = parseIssueGraphLivenessIncidentKey(row.originId); - if (!parsed || parsed.companyId !== companyId) continue; - const waitingPath = explicitWaitingPathForOwner(ownerForExplicitWait(row)); - setExplicitWaitingPath(row.id, waitingPath); - setExplicitWaitingPath(parsed.issueId, waitingPath); - setExplicitWaitingPath(parsed.leafIssueId, waitingPath); + const recoveryRows: Array<{ + originId: string | null; + assigneeAgentId: string | null; + assigneeUserId: string | null; + }> = await dbOrTx + .select({ + originId: issues.originId, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + }) + .from(issues) + .where( + and( + eq(issues.companyId, companyId), + eq(issues.originKind, BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND), + isNull(issues.hiddenAt), + inArray(issues.originId, chunk), + notInArray(issues.status, BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES), + ), + ); + for (const row of recoveryRows) { + setExplicitWaitingPath(row.originId, explicitWaitingPathForOwner(ownerForExplicitWait(row))); + } } } @@ -2716,23 +2666,6 @@ export function issueService(db: Db) { listIssueBlockerAttentionMap(db, companyId, withRuns), listIssueProductivityReviewMap(db, companyId, issueIds), ]); - const dispositionInputs: IssueExecutionDispositionInputNode[] = withRuns.map((row) => ({ - id: row.id, - companyId: row.companyId, - status: row.status, - assigneeAgentId: row.assigneeAgentId, - assigneeUserId: row.assigneeUserId, - originKind: row.originKind ?? null, - executionRunId: row.executionRunId ?? null, - // executionState is intentionally omitted from the list select; the resolver rehydrates it. - blockedBy: includeBlockedBy ? blockedByMap.get(row.id) ?? [] : undefined, - })); - await backfillBlockedBySummaries(db, companyId, dispositionInputs); - const dispositionByIssueId = await listIssueExecutionDispositionsMap( - db, - companyId, - dispositionInputs, - ); if (!contextUserId) { return withRuns.map((row) => { @@ -2747,7 +2680,6 @@ export function issueService(db: Db) { ...(includeBlockedBy ? { blockedBy: blockedByMap.get(row.id) ?? [] } : {}), lastActivityAt, ...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}), - ...(dispositionByIssueId.has(row.id) ? { executionDisposition: dispositionByIssueId.get(row.id) } : {}), ...(productivityReviewByIssueId.has(row.id) ? { productivityReview: productivityReviewByIssueId.get(row.id) } : {}), @@ -2769,7 +2701,6 @@ export function issueService(db: Db) { ...(includeBlockedBy ? { blockedBy: blockedByMap.get(row.id) ?? [] } : {}), lastActivityAt, ...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}), - ...(dispositionByIssueId.has(row.id) ? { executionDisposition: dispositionByIssueId.get(row.id) } : {}), ...(productivityReviewByIssueId.has(row.id) ? { productivityReview: productivityReviewByIssueId.get(row.id) } : {}), @@ -2924,16 +2855,6 @@ export function issueService(db: Db) { return listIssueBlockerAttentionMap(dbOrTx, companyId, issueRows); }, - listExecutionDispositions: async ( - companyId: string, - issueRows: IssueExecutionDispositionInputNode[], - dbOrTx: any = db, - ) => { - const inputs = issueRows.map((row) => ({ ...row })); - await backfillBlockedBySummaries(dbOrTx, companyId, inputs); - return listIssueExecutionDispositionsMap(dbOrTx, companyId, inputs); - }, - listProductivityReviews: async ( companyId: string, sourceIssueIds: string[], @@ -3309,15 +3230,6 @@ export function issueService(db: Db) { tx, ); } - await assertIssueTransitionAllowed({ - db: tx, - issue, - source: "create", - statusTouched: true, - assigneeTouched: issueData.assigneeAgentId !== undefined || issueData.assigneeUserId !== undefined, - blockerTouched: blockedByIssueIds !== undefined, - executionStateTouched: issueData.executionState !== undefined, - }); const [enriched] = await withIssueLabels(tx, [issue]); return enriched; }); @@ -3471,15 +3383,6 @@ export function issueService(db: Db) { tx, ); } - await assertIssueTransitionAllowed({ - db: tx, - issue: updated, - source: "update", - statusTouched: issueData.status !== undefined, - assigneeTouched: issueData.assigneeAgentId !== undefined || issueData.assigneeUserId !== undefined, - blockerTouched: blockedByIssueIds !== undefined, - executionStateTouched: issueData.executionState !== undefined, - }); const [enriched] = await withIssueLabels(tx, [updated]); return enriched; }; diff --git a/server/src/services/recovery/issue-graph-liveness.ts b/server/src/services/recovery/issue-graph-liveness.ts index 97636529947..111ac06fe4f 100644 --- a/server/src/services/recovery/issue-graph-liveness.ts +++ b/server/src/services/recovery/issue-graph-liveness.ts @@ -1,14 +1,8 @@ import { buildIssueGraphLivenessIncidentKey } from "./origins.js"; -import type { IssueStatus } from "@paperclipai/shared"; import { isLiveExplicitApprovalWaitingPath, isLiveExplicitInteractionWaitingPath, } from "./explicit-waiting-paths.js"; -import { - classifyIssueExecutionDisposition, - type IssueExecutionDispositionAgentInput, - type IssueExecutionParticipantState, -} from "../issue-execution-disposition.js"; export type IssueLivenessSeverity = "warning" | "critical"; @@ -148,26 +142,6 @@ function hasActiveExecutionPath( ); } -function hasActiveRunPath( - companyId: string, - issueId: string, - activeRuns: IssueLivenessExecutionPathInput[], -) { - return activeRuns.some( - (entry) => entry.companyId === companyId && entry.issueId === issueId, - ); -} - -function hasQueuedWakePath( - companyId: string, - issueId: string, - queuedWakeRequests: IssueLivenessExecutionPathInput[], -) { - return queuedWakeRequests.some( - (entry) => entry.companyId === companyId && entry.issueId === issueId, - ); -} - function hasWaitingPath( issue: IssueLivenessIssueInput, waitingPaths: IssueLivenessWaitingPathInput[], @@ -195,23 +169,6 @@ function principalIsResolvableUser(principal: unknown): boolean { return value.type === "user" && typeof value.userId === "string" && value.userId.length > 0; } -function participantStateForIssue( - issue: IssueLivenessIssueInput, - agentsById: Map, -): IssueExecutionParticipantState { - if (!issue.executionState) return "none"; - const participant = issue.executionState.currentParticipant; - const participantAgentId = readPrincipalAgentId(participant); - if (participantAgentId) { - const participantAgent = agentsById.get(participantAgentId); - return participantAgent && participantAgent.companyId === issue.companyId && isInvokableAgent(participantAgent) - ? "valid" - : "invalid"; - } - if (principalIsResolvableUser(participant)) return "valid"; - return "invalid"; -} - function addOwnerCandidate( candidates: IssueLivenessOwnerCandidate[], seen: Set, @@ -416,42 +373,13 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu openRecoveryIssues.some((entry) => entry.companyId === issue.companyId && entry.issueId === issue.id); } - function canonicalDisposition(issue: IssueLivenessIssueInput) { - const agent: IssueExecutionDispositionAgentInput | null = issue.assigneeAgentId - ? agentsById.get(issue.assigneeAgentId) ?? null - : null; - - return classifyIssueExecutionDisposition({ - issue: { - id: issue.id, - status: issue.status as IssueStatus, - assigneeAgentId: issue.assigneeAgentId, - assigneeUserId: issue.assigneeUserId, - }, - agent, - execution: { - activeRun: hasActiveRunPath(issue.companyId, issue.id, activeRuns), - queuedWake: hasQueuedWakePath(issue.companyId, issue.id, queuedWakeRequests), - }, - waits: { - participant: participantStateForIssue(issue, agentsById), - pendingInteraction: hasWaitingPath(issue, pendingInteractions, "interaction", now) ? "live" : "none", - pendingApproval: hasWaitingPath(issue, pendingApprovals, "approval", now) ? "live" : "none", - openRecoveryIssue: openRecoveryIssues.some((entry) => - entry.companyId === issue.companyId && entry.issueId === issue.id - ), - }, - }); - } - function reviewFinding( source: IssueLivenessIssueInput, reviewIssue: IssueLivenessIssueInput, dependencyPath: IssueLivenessIssueInput[], ): IssueLivenessFinding | null { if (reviewIssue.status !== "in_review") return null; - const disposition = canonicalDisposition(reviewIssue); - if (disposition.kind !== "invalid") return null; + if (hasExplicitWaitingPath(reviewIssue)) return null; const ownerCandidates = ownerCandidatesForRecoveryIssue(reviewIssue, input.agents, agentsById, { includeStalledAssignee: true, @@ -459,8 +387,9 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu const participant = reviewIssue.executionState?.currentParticipant; const participantAgentId = readPrincipalAgentId(participant); - if (participantAgentId && disposition.reason === "invalid_review_participant") { + if (participantAgentId) { const participantAgent = agentsById.get(participantAgentId); + if (isInvokableAgent(participantAgent) && participantAgent?.companyId === reviewIssue.companyId) return null; return finding({ issue: source, @@ -478,7 +407,9 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu }); } - if (disposition.reason === "invalid_review_participant") { + if (principalIsResolvableUser(participant)) return null; + + if (reviewIssue.executionState) { return finding({ issue: source, state: "invalid_review_participant", @@ -492,7 +423,7 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu }); } - if (disposition.reason !== "in_review_without_action_path") return null; + if (!reviewIssue.assigneeAgentId || reviewIssue.assigneeUserId) return null; return finding({ issue: source, @@ -516,7 +447,6 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu const ownerCandidates = ownerCandidatesForRecoveryIssue(blocker, input.agents, agentsById, { includeStalledAssignee: true, }); - const disposition = canonicalDisposition(blocker); if (blocker.status === "cancelled") { return finding({ @@ -533,21 +463,13 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu }); } - if ( - disposition.kind === "waiting" || - disposition.kind === "live" || - disposition.kind === "dispatchable" || - disposition.kind === "agent_continuable" || - disposition.kind === "recoverable_by_control_plane" - ) { - return null; - } + if (hasExplicitWaitingPath(blocker)) return null; if (blocker.status === "in_review") { return reviewFinding(source, blocker, dependencyPath); } - if (disposition.kind === "resting" && !blocker.assigneeAgentId && !blocker.assigneeUserId) { + if (!blocker.assigneeAgentId && !blocker.assigneeUserId) { return finding({ issue: source, state: "blocked_by_unassigned_issue", @@ -562,12 +484,10 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu }); } - const blockerAgent = blocker.assigneeAgentId ? agentsById.get(blocker.assigneeAgentId) : null; - if ( - disposition.kind === "human_escalation_required" && - blocker.assigneeAgentId && - (!blockerAgent || blockerAgent.companyId !== source.companyId || BLOCKING_AGENT_STATUSES.has(blockerAgent.status)) - ) { + if (!blocker.assigneeAgentId) return null; + + const blockerAgent = agentsById.get(blocker.assigneeAgentId); + if (!blockerAgent || blockerAgent.companyId !== source.companyId || BLOCKING_AGENT_STATUSES.has(blockerAgent.status)) { return finding({ issue: source, state: "blocked_by_uninvokable_assignee", diff --git a/server/src/services/recovery/run-liveness-continuations.ts b/server/src/services/recovery/run-liveness-continuations.ts index 4b8244a9549..afa4c7a96c7 100644 --- a/server/src/services/recovery/run-liveness-continuations.ts +++ b/server/src/services/recovery/run-liveness-continuations.ts @@ -1,18 +1,18 @@ import { and, eq, inArray } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agentWakeupRequests, agents, heartbeatRuns, issues } from "@paperclipai/db"; -import type { IssueStatus, RunLivenessState } from "@paperclipai/shared"; +import type { RunLivenessState } from "@paperclipai/shared"; import { RECOVERY_REASON_KINDS } from "./origins.js"; -import { - classifyIssueExecutionDisposition, - type IssueExecutionRunLivenessState, - type IssueExecutionNextActionState, -} from "../issue-execution-disposition.js"; export const RUN_LIVENESS_CONTINUATION_REASON = RECOVERY_REASON_KINDS.runLivenessContinuation; export const DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS = 2; +const ACTIONABLE_LIVENESS_STATES = new Set(["plan_only", "empty_response"]); +const PROGRESS_WITH_FOLLOWUP_LIVENESS_STATES = new Set(["advanced"]); const CONTINUATION_ACTIVE_ISSUE_STATUSES = new Set(["todo", "in_progress"]); +// A prior adapter error should not permanently suppress bounded liveness +// continuations; the max-attempt/idempotency guards prevent unbounded retries. +const CONTINUATION_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]); const IDEMPOTENT_WAKE_STATUSES = ["queued", "deferred_issue_execution", "completed"]; type HeartbeatRunRow = Pick; @@ -61,14 +61,6 @@ export function buildRunLivenessContinuationIdempotencyKey(input: { ].join(":"); } -function nextActionStateForContinuation(nextAction: string | null): IssueExecutionNextActionState { - return nextAction?.trim() ? "runnable" : "none"; -} - -function livenessStateForDisposition(livenessState: RunLivenessState): IssueExecutionRunLivenessState { - return livenessState; -} - export async function findExistingRunLivenessContinuationWake( db: Db, input: { @@ -117,6 +109,13 @@ export function decideRunLivenessContinuation(input: { return { kind: "skip", reason: "liveness state is not actionable for continuation" }; } + const hasExplicitRunnableFollowup = Boolean(nextAction?.trim()); + const livenessCanContinue = + ACTIONABLE_LIVENESS_STATES.has(livenessState) || + PROGRESS_WITH_FOLLOWUP_LIVENESS_STATES.has(livenessState) && hasExplicitRunnableFollowup; + if (!livenessCanContinue) { + return { kind: "skip", reason: "liveness state is not actionable for continuation" }; + } if (!issue) return { kind: "skip", reason: "issue not found" }; if (!agent) return { kind: "skip", reason: "agent not found" }; if (issue.companyId !== run.companyId || agent.companyId !== run.companyId) { @@ -131,48 +130,14 @@ export function decideRunLivenessContinuation(input: { if (issue.executionState) { return { kind: "skip", reason: "issue is blocked by execution policy state" }; } - - const currentAttempt = readContinuationAttempt(run.continuationAttempt); - const disposition = classifyIssueExecutionDisposition({ - issue: { - id: issue.id, - status: issue.status as IssueStatus, - assigneeAgentId: issue.assigneeAgentId, - assigneeUserId: null, - }, - agent, - latestRun: { - latestRunStatus: "succeeded", - livenessState: livenessStateForDisposition(livenessState), - nextAction: nextActionStateForContinuation(nextAction), - continuationAttempt: currentAttempt, - maxContinuationAttempts: maxAttempts, - }, - gates: { budgetBlocked }, - }); - + if (!CONTINUATION_AGENT_STATUSES.has(agent.status)) { + return { kind: "skip", reason: `agent status ${agent.status} is not invokable` }; + } if (budgetBlocked) { return { kind: "skip", reason: "budget hard stop blocks continuation" }; } - if (disposition.kind === "human_escalation_required" && disposition.owner === "manager" && currentAttempt >= maxAttempts) { - return { - kind: "exhausted", - attempt: currentAttempt, - maxAttempts, - comment: [ - "Bounded liveness continuation exhausted", - "", - `- Last liveness state: \`${livenessState}\``, - `- Attempts used: ${currentAttempt}/${maxAttempts}`, - `- Reason: ${livenessReason ?? "Run ended without concrete progress"}`, - "- Next action: a human or manager should inspect the run and either clarify the task, mark it blocked, or assign a concrete follow-up.", - ].join("\n"), - }; - } - if (disposition.kind !== "agent_continuable") { - return { kind: "skip", reason: `canonical disposition ${disposition.kind} is not continuable` }; - } + const currentAttempt = readContinuationAttempt(run.continuationAttempt); if (currentAttempt >= maxAttempts) { return { kind: "exhausted", diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index d8ca5db63ac..271c76c4570 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -6,7 +6,6 @@ import { MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, type IssueGraphLivenessAutoRecoveryPreview, type IssueGraphLivenessAutoRecoveryPreviewItem, - type IssueStatus, type RunLivenessState, } from "@paperclipai/shared"; import { @@ -24,7 +23,7 @@ import { } from "@paperclipai/db"; import { parseObject, asBoolean, asNumber } from "../../adapters/utils.js"; import { runningProcesses } from "../../adapters/index.js"; -import { badRequest, conflict, forbidden, notFound } from "../../errors.js"; +import { forbidden, notFound } from "../../errors.js"; import { logger } from "../../middleware/logger.js"; import { redactCurrentUserText } from "../../log-redaction.js"; import { redactSensitiveText } from "../../redaction.js"; @@ -57,14 +56,8 @@ import { findExistingRunLivenessContinuationWake, readContinuationAttempt, } from "./run-liveness-continuations.js"; -import { - classifyIssueExecutionDisposition, - type IssueExecutionRunLivenessState, - type IssueExecutionRunStatus, -} from "../issue-execution-disposition.js"; const EXECUTION_PATH_HEARTBEAT_RUN_STATUSES = ["queued", "running", "scheduled_retry"] as const; -const CANCELLABLE_ACTIVE_RUN_RECOVERY_STATUSES = ["queued", "running", "scheduled_retry"] as const; const UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES = ["failed", "cancelled", "timed_out"] as const; export const ACTIVE_RUN_OUTPUT_SUSPICION_THRESHOLD_MS = 60 * 60 * 1000; export const ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS = 4 * 60 * 60 * 1000; @@ -113,8 +106,6 @@ type WatchdogDecisionActor = | { type: "agent"; agentId?: string | null; runId?: string | null } | { type: "none" }; -type WatchdogDecision = "snooze" | "continue" | "dismissed_false_positive" | "cancel_run"; - export type RunOutputSilenceSummary = { lastOutputAt: Date | null; lastOutputSeq: number; @@ -488,47 +479,14 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) issue: typeof issues.$inferSelect, latestRun: LatestIssueRun, ): Promise { - const [activePath, explicitWait, agent, budgetBlocked] = await Promise.all([ - hasActiveExecutionPath(issue.companyId, issue.id), - hasExplicitWaitingPath(issue), - issue.assigneeAgentId ? getAgent(issue.assigneeAgentId) : Promise.resolve(null), - issue.assigneeAgentId ? isInvocationBudgetBlocked(issue, issue.assigneeAgentId) : Promise.resolve(false), - ]); - const activeRunFromLatest = Boolean( - latestRun && - EXECUTION_PATH_HEARTBEAT_RUN_STATUSES.includes( - latestRun.status as (typeof EXECUTION_PATH_HEARTBEAT_RUN_STATUSES)[number], - ), - ); - const disposition = classifyIssueExecutionDisposition({ - issue: { - id: issue.id, - status: issue.status as IssueStatus, - assigneeAgentId: issue.assigneeAgentId, - assigneeUserId: issue.assigneeUserId, - originKind: issue.originKind, - }, - agent, - execution: { - activeRun: activePath || activeRunFromLatest, - }, - waits: { - openRecoveryIssue: explicitWait, - }, - latestRun: latestRun - ? { - latestRunStatus: latestRun.status as IssueExecutionRunStatus, - livenessState: latestRun.livenessState as IssueExecutionRunLivenessState | null, - nextAction: latestRun.nextAction ? "runnable" : "none", - continuationAttempt: latestRun.continuationAttempt, - } - : null, - gates: { budgetBlocked }, - }); - - if (disposition.kind === "terminal") return "terminal"; - if (disposition.kind === "live") return "explicitly_live"; - if (disposition.kind === "waiting") return "explicitly_waiting"; + if (issue.status === "done" || issue.status === "cancelled") return "terminal"; + if (await hasActiveExecutionPath(issue.companyId, issue.id)) return "explicitly_live"; + if (await hasExplicitWaitingPath(issue)) return "explicitly_waiting"; + if (latestRun && EXECUTION_PATH_HEARTBEAT_RUN_STATUSES.includes( + latestRun.status as (typeof EXECUTION_PATH_HEARTBEAT_RUN_STATUSES)[number], + )) { + return "explicitly_live"; + } return "invalid"; } @@ -1345,7 +1303,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) async function recordWatchdogDecision(input: { runId: string; actor: WatchdogDecisionActor; - decision: WatchdogDecision; + decision: "snooze" | "continue" | "dismissed_false_positive"; evaluationIssueId?: string | null; reason?: string | null; snoozedUntil?: Date | null; @@ -1359,15 +1317,6 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) .limit(1); if (!run) throw notFound("Heartbeat run not found"); - const normalizedReason = typeof input.reason === "string" ? input.reason.trim() : ""; - if (input.decision === "cancel_run") { - if (!normalizedReason) throw badRequest("Cancellation reason is required"); - if (!input.evaluationIssueId) throw badRequest("evaluationIssueId is required for cancellation"); - if (!CANCELLABLE_ACTIVE_RUN_RECOVERY_STATUSES.includes(run.status as (typeof CANCELLABLE_ACTIVE_RUN_RECOVERY_STATUSES)[number])) { - throw conflict("Heartbeat run is not cancellable", { status: run.status }); - } - } - let evaluationIssue: { id: string; assigneeAgentId: string | null; @@ -1454,7 +1403,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) evaluationIssueId: input.evaluationIssueId ?? null, decision: input.decision, snoozedUntil: effectiveSnoozedUntil, - reason: input.decision === "cancel_run" ? normalizedReason : input.reason ?? null, + reason: input.reason ?? null, createdByAgentId: input.actor.type === "agent" ? input.actor.agentId ?? null : null, createdByUserId: input.actor.type === "board" ? input.actor.userId ?? null : null, createdByRunId, @@ -1471,21 +1420,15 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) : "unknown", agentId: input.actor.type === "agent" ? input.actor.agentId ?? null : null, runId: run.id, - action: input.decision === "snooze" - ? "heartbeat.watchdog_snoozed" - : input.decision === "cancel_run" - ? "heartbeat.watchdog_cancel_requested" - : "heartbeat.watchdog_decision_recorded", + action: input.decision === "snooze" ? "heartbeat.watchdog_snoozed" : "heartbeat.watchdog_decision_recorded", entityType: "heartbeat_run", entityId: run.id, details: { source: "recovery.record_watchdog_decision", decision: input.decision, evaluationIssueId: input.evaluationIssueId ?? null, - targetRunId: run.id, snoozedUntil: effectiveSnoozedUntil?.toISOString() ?? null, - reason: input.decision === "cancel_run" ? normalizedReason : input.reason ?? null, - createdByRunId, + reason: input.reason ?? null, }, }); @@ -2414,36 +2357,31 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) return `${companyId}:${issueId}`; } - type LivenessDependencyTimestamps = { - createdAt: Date; - updatedAt: Date; - }; - - async function loadLivenessDependencyTimestampsByIssue(findings: IssueLivenessFinding[]) { + async function loadLivenessDependencyUpdatedAtByIssue(findings: IssueLivenessFinding[]) { const issueIds = [ ...new Set( findings.flatMap((finding) => finding.dependencyPath.map((entry) => entry.issueId)), ), ]; - if (issueIds.length === 0) return new Map(); + if (issueIds.length === 0) return new Map(); const rows = await db - .select({ id: issues.id, companyId: issues.companyId, createdAt: issues.createdAt, updatedAt: issues.updatedAt }) + .select({ id: issues.id, companyId: issues.companyId, updatedAt: issues.updatedAt }) .from(issues) .where(inArray(issues.id, issueIds)); return new Map(rows.map((row) => [ livenessDependencyIssueKey(row.companyId, row.id), - { createdAt: row.createdAt, updatedAt: row.updatedAt }, + row.updatedAt, ])); } function latestDependencyUpdatedAtForLivenessFinding( finding: IssueLivenessFinding, - timestampsByIssueKey: Map, + updatedAtByIssueKey: Map, ) { const dependencyIssueIds = [...new Set(finding.dependencyPath.map((entry) => entry.issueId))]; if (dependencyIssueIds.length === 0) return null; const timestamps = dependencyIssueIds.map((issueId) => - timestampsByIssueKey.get(livenessDependencyIssueKey(finding.companyId, issueId))?.updatedAt ?? null + updatedAtByIssueKey.get(livenessDependencyIssueKey(finding.companyId, issueId)) ?? null ); if (timestamps.some((timestamp) => !timestamp)) return null; const [firstTimestamp, ...remainingTimestamps] = timestamps as Date[]; @@ -2452,22 +2390,13 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) firstTimestamp!); } - function recoveryIssueCreatedAtForLivenessFinding( - finding: IssueLivenessFinding, - timestampsByIssueKey: Map, - ) { - return timestampsByIssueKey.get( - livenessDependencyIssueKey(finding.companyId, finding.recoveryIssueId), - )?.createdAt ?? null; - } - function isLivenessFindingInsideAutoRecoveryLookback( finding: IssueLivenessFinding, cutoff: Date, - timestampsByIssueKey: Map, + updatedAtByIssueKey: Map, ) { - const recoveryIssueCreatedAt = recoveryIssueCreatedAtForLivenessFinding(finding, timestampsByIssueKey); - return Boolean(recoveryIssueCreatedAt && recoveryIssueCreatedAt >= cutoff); + const latestUpdatedAt = latestDependencyUpdatedAtForLivenessFinding(finding, updatedAtByIssueKey); + return Boolean(latestUpdatedAt && latestUpdatedAt >= cutoff); } async function buildIssueGraphLivenessAutoRecoveryPreview( @@ -2477,7 +2406,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) const lookbackHours = normalizeIssueGraphLivenessAutoRecoveryLookbackHours(opts?.lookbackHours); const cutoff = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000); const findings = await collectIssueGraphLivenessFindings(); - const timestampsByIssueKey = await loadLivenessDependencyTimestampsByIssue(findings); + const updatedAtByIssueKey = await loadLivenessDependencyUpdatedAtByIssue(findings); const issueIds = [...new Set(findings.map((finding) => finding.recoveryIssueId))]; const recoveryRows = issueIds.length > 0 ? await db @@ -2492,12 +2421,9 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) for (const finding of findings) { const latestDependencyUpdatedAt = latestDependencyUpdatedAtForLivenessFinding( finding, - timestampsByIssueKey, + updatedAtByIssueKey, ); - if ( - !latestDependencyUpdatedAt || - !isLivenessFindingInsideAutoRecoveryLookback(finding, cutoff, timestampsByIssueKey) - ) { + if (!latestDependencyUpdatedAt || latestDependencyUpdatedAt < cutoff) { skippedOutsideLookback += 1; continue; } @@ -2807,7 +2733,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) const now = new Date(); const cutoff = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000); const obsoleteRecoveryCleanup = await retireObsoleteLivenessRecoveryIssues(findings); - const timestampsByIssueKey = await loadLivenessDependencyTimestampsByIssue(findings); + const updatedAtByIssueKey = await loadLivenessDependencyUpdatedAtByIssue(findings); const result = { findings: findings.length, autoRecoveryEnabled, @@ -2832,7 +2758,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) } for (const finding of findings) { - if (!isLivenessFindingInsideAutoRecoveryLookback(finding, cutoff, timestampsByIssueKey)) { + if (!isLivenessFindingInsideAutoRecoveryLookback(finding, cutoff, updatedAtByIssueKey)) { result.skippedOutsideLookback += 1; result.skipped += 1; continue; diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index 67137d847f3..b4bf89c81d7 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -60,7 +60,7 @@ export interface LiveRunForIssue { export interface WatchdogDecisionInput { runId: string; - decision: "snooze" | "continue" | "dismissed_false_positive" | "cancel_run"; + decision: "snooze" | "continue" | "dismissed_false_positive"; evaluationIssueId?: string | null; reason?: string | null; snoozedUntil?: string | null; diff --git a/ui/src/components/IssueBlockedNotice.test.tsx b/ui/src/components/IssueBlockedNotice.test.tsx index b0a74c46e8d..24cee80ac6b 100644 --- a/ui/src/components/IssueBlockedNotice.test.tsx +++ b/ui/src/components/IssueBlockedNotice.test.tsx @@ -97,113 +97,6 @@ describe("IssueBlockedNotice", () => { expect(html).toContain("Wake to continue"); }); - it("renders the sky explicit-wait banner with plan-confirmation copy when a pending request_confirmation exists", () => { - const html = renderNotice( - <> - - , - ); - - expect(html).toContain('data-blocker-attention-state="covered"'); - expect(html).toContain('data-blocker-attention-reason="explicit_waiting"'); - expect(html).toContain("Waiting on board confirmation"); - expect(html).not.toContain("Plan revision r2"); - expect(html).not.toMatch(/>\s*Target\s*Owner<"); - expect(html).toContain(">Resume<"); - expect(html).toContain("Resumes when the board accepts"); - expect(html).toContain("border-sky-300/70"); - expect(html).not.toContain("border-rose-300/70"); - expect(html).toContain("Jump to confirmation"); - }); - - it("renders the sky explicit-wait banner falling back to a board-approval message when only an approval is pending", () => { - const html = renderNotice( - <> - - , - ); - - expect(html).toContain("Waiting on board approval"); - expect(html).toContain("A board approval is open"); - expect(html).not.toContain("Approval approval"); - expect(html).toContain("Jump to approval"); - expect(html).toContain("border-sky-300/70"); - }); - it("falls back to amber stalled treatment when state is stalled rather than recovery_needed", () => { const html = renderNotice( <> diff --git a/ui/src/components/IssueBlockedNotice.tsx b/ui/src/components/IssueBlockedNotice.tsx index fe3dfd8fa46..761dcc9742e 100644 --- a/ui/src/components/IssueBlockedNotice.tsx +++ b/ui/src/components/IssueBlockedNotice.tsx @@ -1,17 +1,6 @@ -import type { - Approval, - AskUserQuestionsInteraction, - AskUserQuestionsPayload, - IssueBlockerAttention, - IssueRelationIssueSummary, - IssueThreadInteraction, - RequestConfirmationInteraction, - SuggestTasksInteraction, -} from "@paperclipai/shared"; -import { AlertTriangle, ArrowDown, ArrowUp, Hourglass } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import type { IssueBlockerAttention, IssueRelationIssueSummary } from "@paperclipai/shared"; +import { AlertTriangle } from "lucide-react"; import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb"; -import { formatAssigneeUserLabel } from "../lib/assignees"; import { IssueLinkQuicklook } from "./IssueLinkQuicklook"; import { Identity } from "./Identity"; @@ -33,94 +22,23 @@ const NEXT_ACTION_VERB: Record = new Set([ - "request_confirmation", - "ask_user_questions", - "suggest_tasks", -]); - -function isWaitingActionableInteraction( - interaction: IssueThreadInteraction, -): interaction is WaitingActionableInteraction { - return WAITING_INTERACTION_KINDS.has(interaction.kind); -} - -function pickLatestPendingInteraction( - interactions: IssueThreadInteraction[] | null | undefined, -): WaitingActionableInteraction | null { - if (!interactions || interactions.length === 0) return null; - let best: WaitingActionableInteraction | null = null; - let bestTime = -Infinity; - for (const interaction of interactions) { - if (interaction.status !== "pending") continue; - if (!isWaitingActionableInteraction(interaction)) continue; - const time = new Date(interaction.createdAt as string | Date).getTime(); - if (Number.isFinite(time) && time > bestTime) { - bestTime = time; - best = interaction; - } - } - return best; -} - -function pickLatestPendingApproval(approvals: Approval[] | null | undefined): Approval | null { - if (!approvals || approvals.length === 0) return null; - let best: Approval | null = null; - let bestTime = -Infinity; - for (const approval of approvals) { - if (approval.status !== "pending") continue; - const time = new Date(approval.createdAt as string | Date).getTime(); - if (Number.isFinite(time) && time > bestTime) { - bestTime = time; - best = approval; - } - } - return best; -} - export function IssueBlockedNotice({ - issueId, - issueIdentifier, issueStatus, blockers, blockerAttention, ownerAgentName, ownerUserName, - interactions, - approvals, - agentMap, - userLabelMap, - currentUserId, }: { - issueId?: string | null; - issueIdentifier?: string | null; issueStatus?: string; blockers: IssueRelationIssueSummary[]; blockerAttention?: IssueBlockerAttention | null; ownerAgentName?: string | null; ownerUserName?: string | null; - interactions?: IssueThreadInteraction[] | null; - approvals?: Approval[] | null; - agentMap?: ReadonlyMap | null; - userLabelMap?: ReadonlyMap | null; - currentUserId?: string | null; }) { const isRecoveryNeeded = blockerAttention?.state === "recovery_needed" && blockerAttention.reason !== null && RECOVERY_REASONS.has(blockerAttention.reason); - const isExplicitWaiting = blockerAttention?.state === "covered" - && blockerAttention.reason === "explicit_waiting"; - if ( - blockers.length === 0 - && issueStatus !== "blocked" - && !isRecoveryNeeded - && !isExplicitWaiting - ) return null; + if (blockers.length === 0 && issueStatus !== "blocked" && !isRecoveryNeeded) return null; const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues"; const terminalBlockers = blockers @@ -168,79 +86,37 @@ export function IssueBlockedNotice({ } return candidates; })(); + // Leaf-level surface: the issue itself is the invalid leaf. We render the + // rose notice without any blocker chips, using the leaf-perspective copy. const isLeafSurface = isRecoveryNeeded && issueStatus !== "blocked"; - // Explicit-wait wins over rose only when state is actually "covered". The - // server already gates these so they cannot co-occur on the same issue (rose - // wins on tie via the precedence: recovery_needed first, then explicit - // waiting), but check explicitly per the spec. - const showExplicitWaiting = isExplicitWaiting && !isRecoveryNeeded; - const waitLeafIdentifier = showExplicitWaiting - ? blockerAttention?.sampleBlockerIdentifier ?? null - : null; - const isWaitChain = showExplicitWaiting - && waitLeafIdentifier !== null - && issueIdentifier != null - && waitLeafIdentifier !== issueIdentifier; - const waitLeafBlockers = (() => { - if (!showExplicitWaiting || !isWaitChain) return [] as IssueRelationIssueSummary[]; - const candidates: IssueRelationIssueSummary[] = []; - for (const blocker of [...blockers, ...terminalBlockers]) { - if (blocker.status === "done" || blocker.status === "cancelled") continue; - if (candidates.some((existing) => existing.id === blocker.id)) continue; - candidates.push(blocker); - } - if (waitLeafIdentifier) { - const preferred = candidates.find( - (blocker) => (blocker.identifier ?? blocker.id) === waitLeafIdentifier, - ); - if (preferred) { - return [preferred, ...candidates.filter((blocker) => blocker.id !== preferred.id)]; - } - } - return candidates; - })(); + const containerClass = isRecoveryNeeded + ? "mb-3 rounded-md border border-rose-300/70 bg-rose-50/90 px-3 py-2.5 text-sm text-rose-950 shadow-sm dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-100" + : "mb-3 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2.5 text-sm text-amber-950 shadow-sm dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100"; + const iconClass = isRecoveryNeeded + ? "mt-0.5 h-4 w-4 shrink-0 text-rose-600 dark:text-rose-300" + : "mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300"; + const subRowLabelClass = isRecoveryNeeded + ? "text-xs font-medium text-rose-800 dark:text-rose-200" + : "text-xs font-medium text-amber-800 dark:text-amber-200"; - const containerClass = showExplicitWaiting - ? "mb-3 rounded-md border border-sky-300/70 bg-sky-50/90 px-3 py-2.5 text-sm text-sky-950 shadow-sm dark:border-sky-500/40 dark:bg-sky-500/10 dark:text-sky-100" - : isRecoveryNeeded - ? "mb-3 rounded-md border border-rose-300/70 bg-rose-50/90 px-3 py-2.5 text-sm text-rose-950 shadow-sm dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-100" - : "mb-3 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2.5 text-sm text-amber-950 shadow-sm dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100"; - const iconClass = showExplicitWaiting - ? "mt-0.5 h-4 w-4 shrink-0 text-sky-600 dark:text-sky-300" - : isRecoveryNeeded - ? "mt-0.5 h-4 w-4 shrink-0 text-rose-600 dark:text-rose-300" - : "mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300"; - const subRowLabelClass = showExplicitWaiting - ? "text-xs font-medium text-sky-800 dark:text-sky-200" - : isRecoveryNeeded - ? "text-xs font-medium text-rose-800 dark:text-rose-200" - : "text-xs font-medium text-amber-800 dark:text-amber-200"; - - const renderBlockerChip = ( - blocker: IssueRelationIssueSummary, - variant: "amber" | "rose" | "sky" = "amber", - ) => { + const renderBlockerChip = (blocker: IssueRelationIssueSummary, variant: "amber" | "rose" = "amber") => { const issuePathId = blocker.identifier ?? blocker.id; - const chipClassByVariant: Record = { - amber: "inline-flex max-w-full items-center gap-1 rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-amber-950 transition-colors hover:border-amber-500 hover:bg-amber-100 hover:underline dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15", - rose: "inline-flex max-w-full items-center gap-1 rounded-md border border-rose-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-rose-950 transition-colors hover:border-rose-500 hover:bg-rose-100 hover:underline dark:border-rose-500/40 dark:bg-background/40 dark:text-rose-100 dark:hover:bg-rose-500/15", - sky: "inline-flex max-w-full items-center gap-1 rounded-md border border-sky-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-sky-950 transition-colors hover:border-sky-500 hover:bg-sky-100 hover:underline dark:border-sky-500/40 dark:bg-background/40 dark:text-sky-100 dark:hover:bg-sky-500/15", - }; - const labelClassByVariant: Record = { - amber: "max-w-[18rem] truncate font-sans text-[11px] text-amber-800 dark:text-amber-200", - rose: "max-w-[18rem] truncate font-sans text-[11px] text-rose-800 dark:text-rose-200", - sky: "max-w-[18rem] truncate font-sans text-[11px] text-sky-800 dark:text-sky-200", - }; + const chipClass = variant === "rose" + ? "inline-flex max-w-full items-center gap-1 rounded-md border border-rose-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-rose-950 transition-colors hover:border-rose-500 hover:bg-rose-100 hover:underline dark:border-rose-500/40 dark:bg-background/40 dark:text-rose-100 dark:hover:bg-rose-500/15" + : "inline-flex max-w-full items-center gap-1 rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-amber-950 transition-colors hover:border-amber-500 hover:bg-amber-100 hover:underline dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"; + const labelClass = variant === "rose" + ? "max-w-[18rem] truncate font-sans text-[11px] text-rose-800 dark:text-rose-200" + : "max-w-[18rem] truncate font-sans text-[11px] text-amber-800 dark:text-amber-200"; return ( {blocker.identifier ?? blocker.id.slice(0, 8)} - {blocker.title} + {blocker.title} ); }; @@ -278,166 +154,6 @@ export function IssueBlockedNotice({ ); }; - // ----- Explicit waiting helpers ----- - - const ownerLabel = (() => { - const owner = blockerAttention?.nextActionOwner ?? null; - if (!owner) return "the next reviewer"; - if (owner.type === "user") { - if (!owner.userId) return "the board"; - const resolved = formatAssigneeUserLabel(owner.userId, currentUserId ?? null, userLabelMap ?? undefined); - if (resolved) return resolved; - return ownerUserName ?? "the user"; - } - if (owner.type === "agent") { - if (owner.agentId && agentMap) { - const agent = agentMap.get(owner.agentId); - if (agent?.name) return agent.name; - } - return ownerAgentName ?? "the agent"; - } - return "the next reviewer"; - })(); - - const pendingInteraction = showExplicitWaiting && !isWaitChain - ? pickLatestPendingInteraction(interactions ?? null) - : null; - const pendingApproval = showExplicitWaiting && !pendingInteraction && !isWaitChain - ? pickLatestPendingApproval(approvals ?? null) - : null; - - const isPlanConfirmation = (interaction: WaitingActionableInteraction): boolean => { - if (interaction.kind !== "request_confirmation") return false; - const target = interaction.payload?.target ?? null; - if (!target) return false; - if (target.type === "issue_document") return target.key === "plan"; - return false; - }; - - const explicitWaitingHeadline = (() => { - if (!showExplicitWaiting) return null; - if (isWaitChain) { - return <>Waiting on {ownerLabel} downstream.; - } - if (pendingInteraction) { - if (pendingInteraction.kind === "request_confirmation") { - if (isPlanConfirmation(pendingInteraction)) { - return <>Waiting on board confirmation.; - } - return <>Waiting on confirmation.; - } - if (pendingInteraction.kind === "ask_user_questions") { - return <>Waiting on user response.; - } - if (pendingInteraction.kind === "suggest_tasks") { - return <>Waiting on task selection.; - } - } - if (pendingApproval) { - return <>Waiting on board approval.; - } - return <>Waiting on {ownerLabel}.; - })(); - - const explicitWaitingBody = (() => { - if (!showExplicitWaiting) return null; - if (isWaitChain) { - const leafChip = waitLeafIdentifier - ? [{waitLeafIdentifier}] - : null; - return leafChip - ? <>Work on this issue is paused while {ownerLabel} responds in {leafChip}. Nothing here will resume until that is resolved. - : <>Work on this issue is paused while {ownerLabel} responds downstream. Nothing here will resume until that is resolved.; - } - if (pendingInteraction) { - if (pendingInteraction.kind === "request_confirmation") { - if (isPlanConfirmation(pendingInteraction)) { - return <>Paperclip drafted a plan and asked the board to accept or reject it. Nothing will run until that's resolved.; - } - return <>Paperclip is waiting on {ownerLabel} to accept or reject.; - } - if (pendingInteraction.kind === "ask_user_questions") { - const payload = pendingInteraction.payload as AskUserQuestionsPayload; - const count = payload?.questions?.length ?? 0; - const noun = count === 1 ? "question" : "questions"; - if (count > 0) { - return <>Paperclip asked {ownerLabel} {count} {noun} before continuing.; - } - return <>Paperclip asked {ownerLabel} for a response before continuing.; - } - if (pendingInteraction.kind === "suggest_tasks") { - const count = pendingInteraction.payload?.tasks?.length ?? 0; - if (count > 0) { - return <>Paperclip suggested {count} {count === 1 ? "task" : "tasks"}; pick which to create.; - } - return <>Paperclip suggested tasks for {ownerLabel} to pick which to create.; - } - } - if (pendingApproval) { - return <>A board approval is open against this issue.; - } - return <>This issue is on hold pending a human decision.; - })(); - - const explicitWaitingResumeLabel = (() => { - if (pendingInteraction) { - const policy = pendingInteraction.continuationPolicy ?? "none"; - if (policy === "wake_assignee" || policy === "wake_assignee_on_accept") { - if (pendingInteraction.kind === "request_confirmation") { - return `Resumes when ${ownerLabel} accepts. Rejection returns to the assignee with reason.`; - } - if (pendingInteraction.kind === "ask_user_questions") { - return `Resumes when ${ownerLabel} answers.`; - } - if (pendingInteraction.kind === "suggest_tasks") { - return `Resumes when ${ownerLabel} picks tasks to create.`; - } - } - return "Does not auto-resume — assignee must restart."; - } - if (pendingApproval) { - return "Resumes on approval decision."; - } - return null; - })(); - - const explicitWaitingDetails = (() => { - if (!showExplicitWaiting) return null; - - const labelClass = "shrink-0 text-[10px] uppercase tracking-wide text-sky-700/80 dark:text-sky-300/80"; - return ( -
-
-
Owner
-
{ownerLabel}
-
- {explicitWaitingResumeLabel ? ( -
-
Resume
-
{explicitWaitingResumeLabel}
-
- ) : null} -
- ); - })(); - - const explicitWaitingJumpAnchor = (() => { - if (!showExplicitWaiting) return null; - let anchorId: string | null = null; - let label: string | null = null; - if (pendingInteraction) { - anchorId = `interaction-${pendingInteraction.id}`; - if (pendingInteraction.kind === "request_confirmation") label = "Jump to confirmation"; - else if (pendingInteraction.kind === "ask_user_questions") label = "Jump to questions"; - else if (pendingInteraction.kind === "suggest_tasks") label = "Jump to suggestions"; - } else if (pendingApproval) { - anchorId = `approval-${pendingApproval.id}`; - label = "Jump to approval"; - } - if (!anchorId || !label) return null; - return ; - })(); - const ownerPill = (() => { if (!isRecoveryNeeded) return null; const owner = blockerAttention?.nextActionOwner ?? null; @@ -468,8 +184,6 @@ export function IssueBlockedNotice({ ); })(); - void issueId; // currently unused; reserved for future deep-anchor wiring - return (
- {showExplicitWaiting ? ( - - ) : ( - - )} +

- {showExplicitWaiting ? ( - <> - {explicitWaitingHeadline} - {explicitWaitingBody ? <> {explicitWaitingBody} : null} - - ) : isRecoveryNeeded + {isRecoveryNeeded ? isLeafSurface ? recoveryLeafCopy(blockerAttention!.reason as RecoveryReason) : recoveryParentCopy( @@ -504,35 +209,23 @@ export function IssueBlockedNotice({ : <>Work on this issue is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still wake the assignee for questions or triage. : <>Work on this issue is blocked until it is moved back to todo. Comments still wake the assignee for questions or triage.}

- {showExplicitWaiting ? ( - <> - {explicitWaitingDetails} - {isWaitChain && waitLeafBlockers.length > 0 ? ( -
- Waiting at - {waitLeafBlockers.map((blocker) => renderBlockerChip(blocker, "sky"))} -
- ) : null} - {explicitWaitingJumpAnchor} - - ) : null} - {!showExplicitWaiting && !isRecoveryNeeded && blockers.length > 0 ? ( + {!isRecoveryNeeded && blockers.length > 0 ? (
{blockers.map((blocker) => renderBlockerChip(blocker, "amber"))}
) : null} - {!showExplicitWaiting && isRecoveryNeeded && !isLeafSurface && recoveryLeafBlockers.length > 0 ? ( + {isRecoveryNeeded && !isLeafSurface && recoveryLeafBlockers.length > 0 ? (
Liveness break at {recoveryLeafBlockers.map((blocker) => renderBlockerChip(blocker, "rose"))}
) : null} - {!showExplicitWaiting && !isRecoveryNeeded && showStalledRow ? ( + {!isRecoveryNeeded && showStalledRow ? (
Stalled in review {stalledLeafBlockers.map((blocker) => renderBlockerChip(blocker, "amber"))}
- ) : !showExplicitWaiting && !isRecoveryNeeded && terminalBlockers.length > 0 ? ( + ) : !isRecoveryNeeded && terminalBlockers.length > 0 ? (
Ultimately waiting on {terminalBlockers.map((blocker) => renderBlockerChip(blocker, "amber"))} @@ -544,49 +237,3 @@ export function IssueBlockedNotice({
); } - -function ExplicitWaitingJumpAnchor({ anchorId, label }: { anchorId: string; label: string }) { - const buttonRef = useRef(null); - const [direction, setDirection] = useState<"up" | "down">("down"); - - useEffect(() => { - const button = buttonRef.current; - if (!button) return; - const update = () => { - const target = document.getElementById(anchorId); - if (!target) return; - const targetRect = target.getBoundingClientRect(); - const buttonRect = button.getBoundingClientRect(); - const targetMid = targetRect.top + targetRect.height / 2; - const buttonMid = buttonRect.top + buttonRect.height / 2; - setDirection(targetMid < buttonMid ? "up" : "down"); - }; - update(); - window.addEventListener("scroll", update, { passive: true, capture: true }); - window.addEventListener("resize", update); - return () => { - window.removeEventListener("scroll", update, { capture: true } as EventListenerOptions); - window.removeEventListener("resize", update); - }; - }, [anchorId]); - - const handleClick = (event: React.MouseEvent) => { - event.preventDefault(); - const target = document.getElementById(anchorId); - if (!target) return; - target.scrollIntoView({ behavior: "smooth", block: "start" }); - }; - - const Arrow = direction === "up" ? ArrowUp : ArrowDown; - return ( - - ); -} diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 0bb619d7a24..eb467d9de64 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -36,7 +36,6 @@ import { } from "@tanstack/react-virtual"; import type { Agent, - Approval, FeedbackDataSharingPreference, FeedbackVote, FeedbackVoteValue, @@ -256,11 +255,8 @@ interface IssueChatComposerProps { } interface IssueChatThreadProps { - issueId?: string | null; - issueIdentifier?: string | null; comments: IssueChatComment[]; interactions?: IssueThreadInteraction[]; - approvals?: Approval[]; feedbackVotes?: FeedbackVote[]; feedbackDataSharingPreference?: FeedbackDataSharingPreference; feedbackTermsUrl?: string | null; @@ -2961,11 +2957,8 @@ const IssueChatComposer = forwardRef
diff --git a/ui/src/components/IssueRow.test.tsx b/ui/src/components/IssueRow.test.tsx index 4250065584d..944712e64c9 100644 --- a/ui/src/components/IssueRow.test.tsx +++ b/ui/src/components/IssueRow.test.tsx @@ -227,40 +227,6 @@ describe("IssueRow", () => { }); }); - it("renders the sky waiting pill with the resolved owner label when blockerAttention.reason is explicit_waiting", () => { - const root = createRoot(container); - const issue = createIssue({ - blockerAttention: { - state: "covered", - reason: "explicit_waiting", - unresolvedBlockerCount: 0, - coveredBlockerCount: 0, - stalledBlockerCount: 0, - attentionBlockerCount: 0, - sampleBlockerIdentifier: null, - sampleStalledBlockerIdentifier: null, - nextActionOwner: { type: "user", agentId: null, userId: null }, - nextActionHint: "needs_human_review", - }, - }); - - act(() => { - root.render(); - }); - - const pills = Array.from(container.querySelectorAll("span")) - .filter( - (element) => element.textContent === "Waiting · board" - && element.className.includes("border-sky-200/80"), - ); - expect(pills.length).toBeGreaterThan(0); - expect(pills[0]?.getAttribute("title")).toBe("Waiting · board"); - - act(() => { - root.unmount(); - }); - }); - it("renders without error when titleSuffix is omitted", () => { const root = createRoot(container); diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index 42dbef5c36c..81aa5b95991 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -7,7 +7,6 @@ import { rememberIssueDetailLocationState, withIssueDetailHeaderSeed, } from "../lib/issueDetailBreadcrumb"; -import { formatAssigneeUserLabel } from "../lib/assignees"; import { cn } from "../lib/utils"; import { StatusIcon } from "./StatusIcon"; import { productivityReviewTriggerLabel } from "./ProductivityReviewBadge"; @@ -35,37 +34,6 @@ interface IssueRowProps { onArchive?: () => void; archiveDisabled?: boolean; className?: string; - agentNameMap?: ReadonlyMap | null; - userLabelMap?: ReadonlyMap | null; - currentUserId?: string | null; -} - -function truncateOwnerLabel(label: string, max = 18): string { - if (label.length <= max) return label; - return `${label.slice(0, Math.max(0, max - 1))}…`; -} - -function resolveWaitingOwnerLabel( - issue: Issue, - agentNameMap: IssueRowProps["agentNameMap"], - userLabelMap: IssueRowProps["userLabelMap"], - currentUserId: string | null | undefined, -): string | null { - const owner = issue.blockerAttention?.nextActionOwner ?? null; - if (!owner) return null; - if (owner.type === "user") { - if (!owner.userId) return "board"; - const resolved = formatAssigneeUserLabel(owner.userId, currentUserId ?? null, userLabelMap ?? undefined); - return resolved ? truncateOwnerLabel(resolved) : null; - } - if (owner.type === "agent") { - if (owner.agentId && agentNameMap) { - const name = agentNameMap.get(owner.agentId); - if (name) return truncateOwnerLabel(name); - } - return null; - } - return null; } export function IssueRow({ @@ -89,9 +57,6 @@ export function IssueRow({ onArchive, archiveDisabled, className, - agentNameMap, - userLabelMap, - currentUserId, }: IssueRowProps) { const issuePathId = issue.identifier ?? issue.id; const identifier = issue.identifier ?? issue.id.slice(0, 8); @@ -107,29 +72,6 @@ export function IssueRow({ const recoveryLeafLabel = recoveryLeafIdentifier && recoveryLeafIdentifier !== identifier ? `liveness break at ${recoveryLeafIdentifier}` : null; - const isExplicitWaiting = - issue.blockerAttention?.state === "covered" - && issue.blockerAttention?.reason === "explicit_waiting"; - const waitingOwnerLabel = isExplicitWaiting - ? resolveWaitingOwnerLabel(issue, agentNameMap, userLabelMap, currentUserId) - : null; - const waitingPillTitle = waitingOwnerLabel ? `Waiting · ${waitingOwnerLabel}` : "Waiting"; - const desktopWaitingPill = isExplicitWaiting ? ( - - {waitingPillTitle} - - ) : null; - const mobileWaitingPill = isExplicitWaiting ? ( - - Waiting - - ) : null; const productivityReviewIndicator = productivityReview ? ( - {(desktopTrailing || trailingMeta || desktopWaitingPill) ? ( + {(desktopTrailing || trailingMeta) ? ( - {desktopWaitingPill} {desktopTrailing} {trailingMeta ? ( {trailingMeta} ) : null} ) : null} - {mobileWaitingPill ? ( - - {mobileWaitingPill} - - ) : null} {showUnreadSlot ? ( {showUnreadDot ? ( diff --git a/ui/src/components/IssueRunLedger.test.tsx b/ui/src/components/IssueRunLedger.test.tsx index 328e03a742e..90d5bbb37a4 100644 --- a/ui/src/components/IssueRunLedger.test.tsx +++ b/ui/src/components/IssueRunLedger.test.tsx @@ -411,7 +411,6 @@ describe("IssueRunLedger", () => { expect(container.textContent).toContain("Stale-run watchdog alert"); expect(container.textContent).toContain("PAP-404"); expect(container.textContent).toContain("Stale run"); - expect(container.textContent).toContain("assigned recovery owner can cancel this bound run"); const watchdogBanner = Array.from(container.querySelectorAll("p")) .find((node) => node.textContent?.includes("Stale-run watchdog alert")) ?.closest("div"); @@ -430,20 +429,6 @@ describe("IssueRunLedger", () => { decision: "continue", evaluationIssueId: "issue-eval-1", }); - - const cancelButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("Cancel run"), - ); - expect(cancelButton).not.toBeUndefined(); - act(() => { - cancelButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - expect(onWatchdogDecision).toHaveBeenCalledWith({ - runId: "run-live-1", - decision: "cancel_run", - evaluationIssueId: "issue-eval-1", - reason: "Cancelled from stale-run recovery banner", - }); }); it("hides watchdog decision actions for known non-owner viewers", () => { @@ -460,7 +445,6 @@ describe("IssueRunLedger", () => { expect(container.textContent).not.toContain("Continue monitoring"); expect(container.textContent).not.toContain("Snooze 1h"); expect(container.textContent).not.toContain("Mark false positive"); - expect(container.textContent).not.toContain("Cancel run"); expect(container.querySelectorAll("button")).toHaveLength(0); expect(onWatchdogDecision).not.toHaveBeenCalled(); }); diff --git a/ui/src/components/IssueRunLedger.tsx b/ui/src/components/IssueRunLedger.tsx index 6d2c9433e77..9a28e389c25 100644 --- a/ui/src/components/IssueRunLedger.tsx +++ b/ui/src/components/IssueRunLedger.tsx @@ -575,9 +575,6 @@ export function IssueRunLedgerContent({ {" "}for recovery context. ) : null} - {latestSilentRun.outputSilence.level === "critical" ? ( - <> The assigned recovery owner can cancel this bound run if the evidence shows it is stuck. - ) : null}

{onWatchdogDecision && canRecordWatchdogDecisions ? (
@@ -623,22 +620,6 @@ export function IssueRunLedgerContent({ > Mark false positive - {latestSilentRun.outputSilence.level === "critical" ? ( - - ) : null}
) : null} {watchdogDecisionError ? ( diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 7730081adc0..0ab87854915 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -665,14 +665,6 @@ export function IssuesList({ return agents.find((a) => a.id === id)?.name ?? null; }, [agents]); - const agentNameById = useMemo(() => { - const map = new Map(); - for (const agent of agents ?? []) { - map.set(agent.id, agent.name); - } - return map; - }, [agents]); - const companyUserLabelMap = useMemo( () => buildCompanyUserLabelMap(companyMembers?.users), [companyMembers?.users], @@ -1481,9 +1473,6 @@ export function IssuesList({ { expect(html).toContain('aria-label="Blocked · liveness break at PAP-2642 · automatic continuation exhausted"'); }); - it("renders explicit_waiting issues with sky ring and waiting-on-board copy", () => { - const html = renderToStaticMarkup( - , - ); - - expect(html).toContain('data-blocker-attention-state="explicit_waiting"'); - expect(html).toContain('aria-label="Waiting on board"'); - expect(html).toContain("border-sky-600"); - expect(html).not.toContain("border-cyan-600"); - expect(html).not.toContain("border-red-600"); - expect(html).toContain("-bottom-0.5"); - }); - - it("prefixes the explicit waiting tooltip with Blocked when the issue is also blocked", () => { - const html = renderToStaticMarkup( - , - ); - - expect(html).toContain('aria-label="Blocked · waiting on user"'); - expect(html).toContain("border-sky-600"); - }); - it("renders stalled review chains with amber visual and stalled-leaf copy", () => { const html = renderToStaticMarkup( )} - {(isCoveredBlocked || isExplicitWaiting) && ( + {isCoveredBlocked && ( )} {isStalledBlocked && ( diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 59211346cc6..cf4675fe129 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -2156,9 +2156,6 @@ export function Inbox() { issue={issue} issueLinkState={issueLinkState} selected={selected} - agentNameMap={agentById} - userLabelMap={companyUserLabelMap} - currentUserId={currentUserId} className={ isArchiving ? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out" diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 8269f7e9efe..32da5890d50 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -27,7 +27,6 @@ const mockIssuesApi = vi.hoisted(() => ({ uploadAttachment: vi.fn(), deleteAttachment: vi.fn(), upsertDocument: vi.fn(), - listApprovals: vi.fn(), })); const mockActivityApi = vi.hoisted(() => ({ @@ -783,7 +782,6 @@ describe("IssueDetail", () => { mockIssuesApi.markRead.mockResolvedValue({ id: "issue-1", lastReadAt: new Date().toISOString() }); mockIssuesApi.getTreeControlState.mockResolvedValue({ activePauseHold: null }); mockIssuesApi.listTreeHolds.mockResolvedValue([]); - mockIssuesApi.listApprovals.mockResolvedValue([]); mockActivityApi.forIssue.mockResolvedValue([]); mockActivityApi.runsForIssue.mockResolvedValue([]); mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([]); @@ -1175,9 +1173,8 @@ describe("IssueDetail", () => { , ); }); - await waitForAssertion(() => { - expect(container.textContent).toContain("Paused by board."); - }); + await flushReact(); + await flushReact(); expect(container.textContent).toContain("Paused by board."); expect(container.textContent).toContain("in_review"); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 42f9c0bc19f..1f0ffdfd5a6 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -570,7 +570,6 @@ function InboxMobileToolbar({ type IssueDetailChatTabProps = { issueId: string; - issueIdentifier: string | null; companyId: string; projectId: string | null; issueStatus: Issue["status"]; @@ -628,7 +627,6 @@ type IssueDetailChatTabProps = { const IssueDetailChatTab = memo(function IssueDetailChatTab({ issueId, - issueIdentifier, companyId, projectId, issueStatus, @@ -704,14 +702,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ refetchInterval: hasLiveRuns ? 5000 : false, placeholderData: keepPreviousDataForSameQueryTail(issueId), }); - const { data: linkedApprovals } = useQuery({ - queryKey: queryKeys.issues.approvals(issueId), - queryFn: () => issuesApi.listApprovals(issueId), - placeholderData: keepPreviousDataForSameQueryTail>>(issueId), - }); const resolvedActivity = activity ?? []; const resolvedLinkedRuns = linkedRuns ?? []; - const resolvedLinkedApprovals = linkedApprovals ?? []; const runningIssueRun = useMemo( () => resolveRunningIssueRun(resolvedActiveRun, resolvedLiveRuns), @@ -829,11 +821,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ ) : null}

{fixture.caption}