mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
Revert "PAP-2731 Implement sky explicit-wait UI surfaces"
This reverts commit 08a9e0caf0.
This commit is contained in:
1 parent
0a345e9648
commit
caf37a355d
43 files changed
+181
-4777
No files matched your search
+11
-99
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -328,13 +328,6 @@ export type {
|
||||
IssueWorkProductProvider,
|
||||
IssueWorkProductStatus,
|
||||
IssueWorkProductReviewState,
|
||||
IssueExecutionDisposition,
|
||||
IssueExecutionDispositionKind,
|
||||
IssueExecutionHumanEscalationOwner,
|
||||
IssueExecutionInvalidReason,
|
||||
IssueExecutionLivePath,
|
||||
IssueExecutionRecoveryKind,
|
||||
IssueExecutionWaitingPath,
|
||||
Issue,
|
||||
IssueAssigneeAdapterOverrides,
|
||||
IssueBlockerAttention,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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[];
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
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<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
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<IssueSummary[]> {
|
||||
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<IssueSummary[]>("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<string, PreviewGroup>();
|
||||
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<string, number>();
|
||||
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<number> {
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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<string, unknown> | 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<IssueExecutionStateVector> & {
|
||||
issue?: Partial<IssueExecutionStateVector["issue"]>;
|
||||
},
|
||||
): IssueExecutionStateVector {
|
||||
const issue = {
|
||||
id: "issue-1",
|
||||
status: "todo" as IssueStatus,
|
||||
assigneeAgentId: agentId,
|
||||
assigneeUserId: null,
|
||||
...input.issue,
|
||||
};
|
||||
|
||||
return {
|
||||
agent: issue.assigneeAgentId
|
||||
? { id: issue.assigneeAgentId, status: "idle" }
|
||||
: null,
|
||||
...input,
|
||||
issue,
|
||||
};
|
||||
}
|
||||
|
||||
function classify(input: IssueExecutionStateVector) {
|
||||
const disposition = classifyIssueExecutionDisposition(input);
|
||||
expect(disposition.kind).toBeTruthy();
|
||||
return disposition;
|
||||
}
|
||||
|
||||
function expectDisposition(
|
||||
input: IssueExecutionStateVector,
|
||||
expected: Partial<IssueExecutionDisposition> & { kind: IssueExecutionDisposition["kind"] },
|
||||
) {
|
||||
expect(classify(input)).toMatchObject(expected);
|
||||
}
|
||||
|
||||
describe("issue execution disposition classifier", () => {
|
||||
it.each([
|
||||
["row 1 terminal done", state({ issue: { status: "done" } }), { kind: "terminal" }],
|
||||
[
|
||||
"row 1 terminal cancelled",
|
||||
state({ issue: { status: "cancelled" } }),
|
||||
{ kind: "terminal" },
|
||||
],
|
||||
[
|
||||
"row 2 dual owner",
|
||||
state({ issue: { assigneeAgentId: agentId, assigneeUserId: humanId } }),
|
||||
{ kind: "invalid", reason: "dual_assignee" },
|
||||
],
|
||||
[
|
||||
"row 3 backlog rests",
|
||||
state({ issue: { status: "backlog" } }),
|
||||
{ kind: "resting" },
|
||||
],
|
||||
[
|
||||
"row 4 unassigned todo rests",
|
||||
state({ issue: { assigneeAgentId: null, assigneeUserId: null }, agent: null }),
|
||||
{ kind: "resting" },
|
||||
],
|
||||
[
|
||||
"row 5 queued todo is live",
|
||||
state({ execution: { queuedWake: true } }),
|
||||
{ kind: "live", path: "queued_wake" },
|
||||
],
|
||||
["row 6 assigned todo dispatches", state({}), { kind: "dispatchable", wakeTarget: agentId }],
|
||||
[
|
||||
"row 7 assigned todo waits on interaction",
|
||||
state({ waits: { pendingInteraction: "live" } }),
|
||||
{ kind: "waiting", path: "interaction" },
|
||||
],
|
||||
[
|
||||
"row 8 failed todo is dispatch recoverable",
|
||||
state({ latestRun: { latestRunStatus: "failed", recoveryAttemptRemaining: true } }),
|
||||
{ kind: "recoverable_by_control_plane", recovery: "dispatch" },
|
||||
],
|
||||
[
|
||||
"row 9 paused assignee escalates",
|
||||
state({ agent: { id: agentId, status: "paused" } }),
|
||||
{ kind: "human_escalation_required", owner: "manager" },
|
||||
],
|
||||
[
|
||||
"row 10 pause hold waits",
|
||||
state({ issue: { status: "in_progress" }, waits: { pauseHold: true } }),
|
||||
{ kind: "waiting", path: "pause_hold" },
|
||||
],
|
||||
[
|
||||
"row 11 active in-progress run is live",
|
||||
state({ issue: { status: "in_progress" }, execution: { activeRun: true } }),
|
||||
{ kind: "live", path: "active_run" },
|
||||
],
|
||||
[
|
||||
"row 12 queued in-progress continuation is live",
|
||||
state({ issue: { status: "in_progress" }, execution: { scheduledRetry: true } }),
|
||||
{ kind: "live", path: "scheduled_retry" },
|
||||
],
|
||||
[
|
||||
"row 13 in-progress waits on approval",
|
||||
state({ issue: { status: "in_progress" }, waits: { pendingApproval: "live" } }),
|
||||
{ kind: "waiting", path: "approval" },
|
||||
],
|
||||
[
|
||||
"row 14 failed in-progress run is continuation recoverable",
|
||||
state({
|
||||
issue: { status: "in_progress" },
|
||||
latestRun: { latestRunStatus: "timed_out", recoveryAttemptRemaining: true },
|
||||
}),
|
||||
{ kind: "recoverable_by_control_plane", recovery: "continuation" },
|
||||
],
|
||||
[
|
||||
"row 15 successful runnable progress is continuable",
|
||||
state({
|
||||
issue: { status: "in_progress" },
|
||||
latestRun: {
|
||||
latestRunStatus: "succeeded",
|
||||
livenessState: "advanced",
|
||||
nextAction: "runnable",
|
||||
continuationAttempt: 0,
|
||||
maxContinuationAttempts: 2,
|
||||
},
|
||||
}),
|
||||
{ kind: "agent_continuable", continuationAttempt: 0, maxAttempts: 2 },
|
||||
],
|
||||
[
|
||||
"row 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<IssueExecutionDisposition> & { kind: IssueExecutionDisposition["kind"] }]>)(
|
||||
"%s",
|
||||
(_name, input, expected) => {
|
||||
expectDisposition(input, expected);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"invalid-review-leaf-pi-autoresearch",
|
||||
state({ issue: { status: "in_review" } }),
|
||||
"invalid",
|
||||
"in_review_without_action_path",
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"invalid-review-leaf-object-detection",
|
||||
state({ issue: { status: "in_review" } }),
|
||||
"invalid",
|
||||
"in_review_without_action_path",
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"healthy-review-participant",
|
||||
state({ issue: { status: "in_review" }, waits: { participant: "valid" } }),
|
||||
"waiting",
|
||||
undefined,
|
||||
"participant",
|
||||
],
|
||||
[
|
||||
"healthy-review-confirmation",
|
||||
state({ issue: { status: "in_progress" }, waits: { pendingInteraction: "live" } }),
|
||||
"waiting",
|
||||
undefined,
|
||||
"interaction",
|
||||
],
|
||||
[
|
||||
"healthy-linked-approval",
|
||||
state({ waits: { pendingApproval: "live" } }),
|
||||
"waiting",
|
||||
undefined,
|
||||
"approval",
|
||||
],
|
||||
[
|
||||
"productive-terminal-run-runnable-next-action",
|
||||
state({
|
||||
issue: { status: "in_progress" },
|
||||
latestRun: {
|
||||
latestRunStatus: "succeeded",
|
||||
livenessState: "advanced",
|
||||
nextAction: "runnable",
|
||||
continuationAttempt: 1,
|
||||
maxContinuationAttempts: 2,
|
||||
},
|
||||
}),
|
||||
"agent_continuable",
|
||||
undefined,
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"productive-terminal-run-no-next-action",
|
||||
state({
|
||||
issue: { status: "in_progress" },
|
||||
latestRun: {
|
||||
latestRunStatus: "succeeded",
|
||||
livenessState: "advanced",
|
||||
nextAction: "unknown",
|
||||
continuationAttempt: 1,
|
||||
maxContinuationAttempts: 2,
|
||||
},
|
||||
}),
|
||||
"human_escalation_required",
|
||||
undefined,
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"true-failed-continuation-recovery",
|
||||
state({
|
||||
issue: { status: "in_progress" },
|
||||
latestRun: { latestRunStatus: "cancelled", recoveryAttemptRemaining: true },
|
||||
}),
|
||||
"recoverable_by_control_plane",
|
||||
undefined,
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"explicit-productivity-review-hold",
|
||||
state({ issue: { status: "in_progress" }, waits: { openProductivityReviewIssue: true } }),
|
||||
"waiting",
|
||||
undefined,
|
||||
"review_artifact",
|
||||
],
|
||||
[
|
||||
"productivity-threshold-without-review",
|
||||
state({ issue: { status: "in_progress" }, waits: { productivityReviewNeeded: true } }),
|
||||
"human_escalation_required",
|
||||
undefined,
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"healthy-long-active-run",
|
||||
state({ issue: { status: "in_progress" }, execution: { activeRun: true } }),
|
||||
"live",
|
||||
undefined,
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"recovery-of-recovery-failure",
|
||||
state({
|
||||
issue: { status: "in_progress", originKind: "stranded_issue_recovery" },
|
||||
latestRun: { latestRunStatus: "failed", recoveryAttemptRemaining: true },
|
||||
}),
|
||||
"human_escalation_required",
|
||||
undefined,
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"normal-source-recovery-dedupe",
|
||||
state({
|
||||
issue: { status: "in_progress" },
|
||||
latestRun: { latestRunStatus: "failed", recoveryAttemptRemaining: false },
|
||||
}),
|
||||
"human_escalation_required",
|
||||
undefined,
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"blocked-chain-healthy-leaf",
|
||||
state({
|
||||
issue: { status: "blocked" },
|
||||
blockers: [
|
||||
{
|
||||
issue: {
|
||||
id: "blocker-1",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: agentId,
|
||||
assigneeUserId: null,
|
||||
},
|
||||
disposition: { kind: "live", path: "active_run" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
"waiting",
|
||||
undefined,
|
||||
"blocker_chain",
|
||||
],
|
||||
[
|
||||
"blocked-chain-cancelled-leaf",
|
||||
state({
|
||||
issue: { status: "blocked" },
|
||||
blockers: [
|
||||
{
|
||||
issue: {
|
||||
id: "blocker-1",
|
||||
status: "cancelled",
|
||||
assigneeAgentId: agentId,
|
||||
assigneeUserId: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
"invalid",
|
||||
"blocked_by_cancelled_issue",
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"budget-hard-stop",
|
||||
state({ issue: { status: "in_progress" }, gates: { budgetBlocked: true } }),
|
||||
"human_escalation_required",
|
||||
undefined,
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"pause-held-subtree",
|
||||
state({ issue: { status: "in_progress" }, waits: { pauseHold: true } }),
|
||||
"waiting",
|
||||
undefined,
|
||||
"pause_hold",
|
||||
],
|
||||
[
|
||||
"dual-assignee-write",
|
||||
state({ issue: { assigneeAgentId: agentId, assigneeUserId: humanId } }),
|
||||
"invalid",
|
||||
"dual_assignee",
|
||||
undefined,
|
||||
],
|
||||
] satisfies Array<
|
||||
[
|
||||
string,
|
||||
IssueExecutionStateVector,
|
||||
IssueExecutionDisposition["kind"],
|
||||
IssueExecutionInvalidReason | undefined,
|
||||
IssueExecutionWaitingPath | undefined,
|
||||
]
|
||||
>)("%s fixture", (_name, input, expectedKind, expectedReason, expectedPath) => {
|
||||
const disposition = classify(input);
|
||||
expect(disposition.kind).toBe(expectedKind);
|
||||
if (expectedReason) expect(disposition).toMatchObject({ reason: expectedReason });
|
||||
if (expectedPath) expect(disposition).toMatchObject({ path: expectedPath });
|
||||
});
|
||||
|
||||
it("does not treat stale waits as durable coverage", () => {
|
||||
expectDisposition(
|
||||
state({ issue: { status: "in_review" }, waits: { pendingInteraction: "stale" } }),
|
||||
{ kind: "invalid", reason: "in_review_without_action_path" },
|
||||
);
|
||||
expectDisposition(
|
||||
state({
|
||||
issue: { status: "in_progress" },
|
||||
waits: { pendingApproval: "stale" },
|
||||
latestRun: {
|
||||
latestRunStatus: "succeeded",
|
||||
livenessState: "advanced",
|
||||
nextAction: "runnable",
|
||||
continuationAttempt: 0,
|
||||
maxContinuationAttempts: 2,
|
||||
},
|
||||
}),
|
||||
{ kind: "agent_continuable", continuationAttempt: 0, maxAttempts: 2 },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed review participants as invalid wait paths", () => {
|
||||
expectDisposition(
|
||||
state({ issue: { status: "in_review" }, waits: { participant: "invalid" } }),
|
||||
{ kind: "invalid", reason: "invalid_review_participant" },
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces invalid and unassigned blocker leaves instead of covering parents", () => {
|
||||
expectDisposition(
|
||||
state({
|
||||
issue: { status: "blocked" },
|
||||
blockers: [
|
||||
{
|
||||
issue: {
|
||||
id: "blocker-1",
|
||||
status: "in_review",
|
||||
assigneeAgentId: agentId,
|
||||
assigneeUserId: null,
|
||||
},
|
||||
disposition: {
|
||||
kind: "invalid",
|
||||
reason: "in_review_without_action_path",
|
||||
suggestedCorrection: "Add an explicit wait path.",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ kind: "invalid", reason: "blocked_by_invalid_issue" },
|
||||
);
|
||||
expectDisposition(
|
||||
state({
|
||||
issue: { status: "blocked" },
|
||||
blockers: [
|
||||
{
|
||||
issue: {
|
||||
id: "blocker-1",
|
||||
status: "todo",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ kind: "invalid", reason: "blocked_by_unassigned_issue" },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,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<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
+16
-74
@@ -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
|
||||
|
||||
@@ -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<string | null>`${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<typeof heartbeatRuns.$inferSelect> = [];
|
||||
const affectedAgentIds = new Set<string>();
|
||||
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),
|
||||
|
||||
|
||||
@@ -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<string> = 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<string, unknown> | 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<T>(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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
return value.type === "user" && typeof value.userId === "string" && value.userId.length > 0;
|
||||
}
|
||||
|
||||
function participantStateForExecutionState(
|
||||
executionState: Record<string, unknown> | null | undefined,
|
||||
agentStatusById: ReadonlyMap<string, string>,
|
||||
): 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<string, unknown>).nextAction;
|
||||
return typeof next === "string" && next.trim().length > 0;
|
||||
}
|
||||
|
||||
export async function listIssueExecutionDispositionsMap(
|
||||
dbOrTx: any,
|
||||
companyId: string,
|
||||
issueRows: IssueExecutionDispositionInputNode[],
|
||||
): Promise<Map<string, IssueExecutionDisposition>> {
|
||||
const result = new Map<string, IssueExecutionDisposition>();
|
||||
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<string, Record<string, unknown> | null>();
|
||||
for (const part of chunk(reviewIdsMissingState, QUERY_CHUNK_SIZE)) {
|
||||
const rows: Array<{ id: string; executionState: Record<string, unknown> | 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<string>();
|
||||
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<string>();
|
||||
const queuedWakeIssueIds = new Set<string>();
|
||||
const explicitInteractionLive = new Set<string>();
|
||||
const explicitApprovalLive = new Set<string>();
|
||||
const openRecoveryIssueOrigins = new Set<string>();
|
||||
const openProductivityReviewOrigins = new Set<string>();
|
||||
const latestRunByIssueId = new Map<string, LatestRunRow>();
|
||||
|
||||
// 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<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
inArray(heartbeatRuns.status, [...ACTIVE_RUN_STATUSES]),
|
||||
inArray(sql<string>`${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<string | null>`${agentWakeupRequests.payload} ->> 'issueId'`,
|
||||
})
|
||||
.from(agentWakeupRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(agentWakeupRequests.companyId, companyId),
|
||||
inArray(agentWakeupRequests.status, [...ACTIVE_WAKE_STATUSES]),
|
||||
sql`${agentWakeupRequests.runId} is null`,
|
||||
inArray(sql<string>`${agentWakeupRequests.payload} ->> 'issueId'`, part),
|
||||
),
|
||||
);
|
||||
for (const row of rows) {
|
||||
if (row.issueId) queuedWakeIssueIds.add(row.issueId);
|
||||
}
|
||||
}
|
||||
|
||||
// Pending interactions.
|
||||
const interactionsByIssueId = new Map<string, ExplicitInteractionRow[]>();
|
||||
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<string, ExplicitApprovalRow[]>();
|
||||
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<string>`${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<string>`${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<string, string>();
|
||||
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<void> {
|
||||
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<string, IssueRelationIssueSummary[]>();
|
||||
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) ?? [];
|
||||
}
|
||||
}
|
||||
@@ -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<IssueExecutionDispositionIssueInput, "id" | "status" | "assigneeAgentId" | "assigneeUserId">;
|
||||
disposition?: IssueExecutionDisposition;
|
||||
stateVector?: IssueExecutionStateVector;
|
||||
}
|
||||
|
||||
export interface IssueExecutionGateVector {
|
||||
budgetBlocked?: boolean;
|
||||
}
|
||||
|
||||
export interface IssueExecutionStateVector {
|
||||
issue: IssueExecutionDispositionIssueInput;
|
||||
agent?: IssueExecutionDispositionAgentInput | null;
|
||||
execution?: IssueExecutionPathVector;
|
||||
waits?: IssueExecutionWaitVector;
|
||||
latestRun?: IssueExecutionRunEvidence | null;
|
||||
blockers?: IssueExecutionBlockerVector[];
|
||||
gates?: IssueExecutionGateVector;
|
||||
}
|
||||
|
||||
const INVOKABLE_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]);
|
||||
const RECOVERY_ORIGIN_KINDS = new Set(["stranded_issue_recovery", "harness_liveness_escalation"]);
|
||||
|
||||
function invalid(
|
||||
reason: IssueExecutionInvalidReason,
|
||||
suggestedCorrection: string,
|
||||
): IssueExecutionDisposition {
|
||||
return { kind: "invalid", reason, suggestedCorrection };
|
||||
}
|
||||
|
||||
function hasAgentOwner(issue: IssueExecutionDispositionIssueInput) {
|
||||
return Boolean(issue.assigneeAgentId);
|
||||
}
|
||||
|
||||
function hasHumanOwner(issue: IssueExecutionDispositionIssueInput) {
|
||||
return Boolean(issue.assigneeUserId);
|
||||
}
|
||||
|
||||
function agentIsInvokable(
|
||||
issue: IssueExecutionDispositionIssueInput,
|
||||
agent: IssueExecutionDispositionAgentInput | null | undefined,
|
||||
budgetBlocked: boolean,
|
||||
) {
|
||||
if (!issue.assigneeAgentId || !agent || agent.id !== issue.assigneeAgentId) return false;
|
||||
if (budgetBlocked) return false;
|
||||
return INVOKABLE_AGENT_STATUSES.has(agent.status);
|
||||
}
|
||||
|
||||
function livePath(execution: IssueExecutionPathVector | undefined): IssueExecutionLivePath | null {
|
||||
if (execution?.activeRun) return "active_run";
|
||||
if (execution?.queuedWake) return "queued_wake";
|
||||
if (execution?.scheduledRetry) return "scheduled_retry";
|
||||
if (execution?.deferredExecution) return "deferred_execution";
|
||||
return null;
|
||||
}
|
||||
|
||||
function waitingPath(
|
||||
issue: IssueExecutionDispositionIssueInput,
|
||||
waits: IssueExecutionWaitVector | undefined,
|
||||
): IssueExecutionWaitingPath | null {
|
||||
if (waits?.pauseHold) return "pause_hold";
|
||||
if (hasHumanOwner(issue)) return "human_owner";
|
||||
if (waits?.participant === "valid") return "participant";
|
||||
if (waits?.pendingInteraction === "live") return "interaction";
|
||||
if (waits?.pendingApproval === "live") return "approval";
|
||||
if (waits?.openRecoveryIssue || waits?.openWatchdogIssue || waits?.openProductivityReviewIssue) {
|
||||
return "review_artifact";
|
||||
}
|
||||
if (waits?.externalOwnerAction) return "external_owner_action";
|
||||
return null;
|
||||
}
|
||||
|
||||
function runFailed(run: IssueExecutionRunEvidence | null | undefined) {
|
||||
return (
|
||||
run?.latestRunStatus === "failed" ||
|
||||
run?.latestRunStatus === "timed_out" ||
|
||||
run?.latestRunStatus === "cancelled"
|
||||
);
|
||||
}
|
||||
|
||||
function hasRunnableContinuation(run: IssueExecutionRunEvidence | null | undefined) {
|
||||
const attempt = run?.continuationAttempt ?? 0;
|
||||
const maxAttempts = run?.maxContinuationAttempts ?? 2;
|
||||
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<string>,
|
||||
): IssueExecutionDisposition {
|
||||
if (blocker.disposition) return blocker.disposition;
|
||||
if (blocker.stateVector) return classifyIssueExecutionDisposition(blocker.stateVector, seen);
|
||||
return classifyIssueExecutionDisposition({ issue: blocker.issue as IssueExecutionDispositionIssueInput }, seen);
|
||||
}
|
||||
|
||||
function blockerInvalidReason(blocker: IssueExecutionBlockerVector): IssueExecutionInvalidReason {
|
||||
if (blocker.issue.status === "cancelled") return "blocked_by_cancelled_issue";
|
||||
if (!blocker.issue.assigneeAgentId && !blocker.issue.assigneeUserId) return "blocked_by_unassigned_issue";
|
||||
return "blocked_by_resting_issue";
|
||||
}
|
||||
|
||||
function classifyBlockedIssue(
|
||||
input: IssueExecutionStateVector,
|
||||
seen: Set<string>,
|
||||
): IssueExecutionDisposition {
|
||||
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<string>(),
|
||||
): IssueExecutionDisposition {
|
||||
const { issue } = input;
|
||||
const status = issue.status;
|
||||
const budgetBlocked = Boolean(input.gates?.budgetBlocked);
|
||||
const agentInvokable = agentIsInvokable(issue, input.agent, budgetBlocked);
|
||||
const path = livePath(input.execution);
|
||||
const wait = waitingPath(issue, input.waits);
|
||||
|
||||
if (status === "done" || status === "cancelled") return { kind: "terminal" };
|
||||
|
||||
if (seen.has(issue.id)) {
|
||||
return invalid(
|
||||
"blocked_by_invalid_issue",
|
||||
"Break the recursive blocker chain or create an explicit recovery issue.",
|
||||
);
|
||||
}
|
||||
seen.add(issue.id);
|
||||
|
||||
if (hasAgentOwner(issue) && hasHumanOwner(issue)) {
|
||||
return invalid("dual_assignee", "Keep exactly one issue assignee: agent or human user, not both.");
|
||||
}
|
||||
|
||||
if (status === "backlog") return { kind: "resting" };
|
||||
if (wait) return { kind: "waiting", path: wait };
|
||||
if (input.waits?.participant === "invalid") {
|
||||
return invalid(
|
||||
"invalid_review_participant",
|
||||
"Set a typed execution participant with a valid agentId/userId or replace it with another explicit wait path.",
|
||||
);
|
||||
}
|
||||
if (status === "blocked") return classifyBlockedIssue(input, seen);
|
||||
if (hasHumanOwner(issue)) return { kind: "waiting", path: "human_owner" };
|
||||
if (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" };
|
||||
}
|
||||
@@ -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<Db, "select">;
|
||||
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<string, unknown>;
|
||||
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<IssueExecutionDisposition, { kind: "invalid" }>) {
|
||||
return `Invalid issue transition: ${disposition.reason}`;
|
||||
}
|
||||
|
||||
async function buildIssueExecutionStateVector(
|
||||
dbOrTx: DbReader,
|
||||
issue: IssueRow,
|
||||
): Promise<IssueExecutionStateVector> {
|
||||
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<string | null>`${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<string>`${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,
|
||||
});
|
||||
}
|
||||
+23
-120
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<string, IssueLivenessAgentInput>,
|
||||
): 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<string>,
|
||||
@@ -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",
|
||||
|
||||
@@ -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<RunLivenessState>(["plan_only", "empty_response"]);
|
||||
const PROGRESS_WITH_FOLLOWUP_LIVENESS_STATES = new Set<RunLivenessState>(["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<typeof heartbeatRuns.$inferSelect, "id" | "companyId" | "agentId" | "continuationAttempt">;
|
||||
@@ -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",
|
||||
|
||||
@@ -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<PostRunIssueDisposition> {
|
||||
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<string, LivenessDependencyTimestamps>();
|
||||
if (issueIds.length === 0) return new Map<string, Date>();
|
||||
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<string, LivenessDependencyTimestamps>,
|
||||
updatedAtByIssueKey: Map<string, Date>,
|
||||
) {
|
||||
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<string, LivenessDependencyTimestamps>,
|
||||
) {
|
||||
return timestampsByIssueKey.get(
|
||||
livenessDependencyIssueKey(finding.companyId, finding.recoveryIssueId),
|
||||
)?.createdAt ?? null;
|
||||
}
|
||||
|
||||
function isLivenessFindingInsideAutoRecoveryLookback(
|
||||
finding: IssueLivenessFinding,
|
||||
cutoff: Date,
|
||||
timestampsByIssueKey: Map<string, LivenessDependencyTimestamps>,
|
||||
updatedAtByIssueKey: Map<string, Date>,
|
||||
) {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
<>
|
||||
<IssueBlockedNotice
|
||||
issueIdentifier="PAP-2708"
|
||||
issueStatus="in_review"
|
||||
blockers={[]}
|
||||
blockerAttention={{
|
||||
state: "covered",
|
||||
reason: "explicit_waiting",
|
||||
unresolvedBlockerCount: 0,
|
||||
coveredBlockerCount: 0,
|
||||
stalledBlockerCount: 0,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-2708",
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
nextActionOwner: { type: "user", agentId: null, userId: null },
|
||||
nextActionHint: "needs_human_review",
|
||||
}}
|
||||
interactions={[
|
||||
{
|
||||
id: "interaction-1",
|
||||
companyId: "co-1",
|
||||
issueId: "issue-1",
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
createdAt: "2026-04-28T22:00:00.000Z",
|
||||
updatedAt: "2026-04-28T22:00:00.000Z",
|
||||
createdByAgentId: "agent-coder",
|
||||
createdByUserId: null,
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve plan",
|
||||
target: {
|
||||
type: "issue_document",
|
||||
key: "plan",
|
||||
revisionId: "rev-1",
|
||||
revisionNumber: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
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*</);
|
||||
expect(html).toContain(">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(
|
||||
<>
|
||||
<IssueBlockedNotice
|
||||
issueIdentifier="PAP-2710"
|
||||
issueStatus="in_review"
|
||||
blockers={[]}
|
||||
blockerAttention={{
|
||||
state: "covered",
|
||||
reason: "explicit_waiting",
|
||||
unresolvedBlockerCount: 0,
|
||||
coveredBlockerCount: 0,
|
||||
stalledBlockerCount: 0,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-2710",
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
nextActionOwner: { type: "user", agentId: null, userId: null },
|
||||
nextActionHint: "needs_human_review",
|
||||
}}
|
||||
interactions={[]}
|
||||
approvals={[
|
||||
{
|
||||
id: "approval-abcdef12",
|
||||
companyId: "co-1",
|
||||
type: "request_board_approval",
|
||||
requestedByAgentId: "agent-1",
|
||||
requestedByUserId: null,
|
||||
status: "pending",
|
||||
payload: {},
|
||||
decisionNote: null,
|
||||
decidedByUserId: null,
|
||||
decidedAt: null,
|
||||
createdAt: new Date("2026-04-28T22:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-28T22:00:00.000Z"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
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(
|
||||
<>
|
||||
|
||||
@@ -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<NonNullable<IssueBlockerAttention["nextActionHint
|
||||
reassign: "Reassign",
|
||||
};
|
||||
|
||||
type WaitingActionableInteraction =
|
||||
| RequestConfirmationInteraction
|
||||
| AskUserQuestionsInteraction
|
||||
| SuggestTasksInteraction;
|
||||
|
||||
const WAITING_INTERACTION_KINDS: ReadonlySet<string> = 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<string, { name: string }> | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | 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<typeof variant, string> = {
|
||||
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<typeof variant, string> = {
|
||||
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 (
|
||||
<IssueLinkQuicklook
|
||||
key={blocker.id}
|
||||
issuePathId={issuePathId}
|
||||
to={createIssueDetailPath(issuePathId)}
|
||||
className={chipClassByVariant[variant]}
|
||||
className={chipClass}
|
||||
>
|
||||
<span>{blocker.identifier ?? blocker.id.slice(0, 8)}</span>
|
||||
<span className={labelClassByVariant[variant]}>{blocker.title}</span>
|
||||
<span className={labelClass}>{blocker.title}</span>
|
||||
</IssueLinkQuicklook>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
? <span className="font-mono text-xs">[{waitLeafIdentifier}]</span>
|
||||
: 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 (
|
||||
<dl className="space-y-0.5 pt-0.5 text-xs text-sky-900 dark:text-sky-100">
|
||||
<div className="flex flex-wrap items-baseline gap-1.5">
|
||||
<dt className={labelClass}>Owner</dt>
|
||||
<dd className="min-w-0 break-words">{ownerLabel}</dd>
|
||||
</div>
|
||||
{explicitWaitingResumeLabel ? (
|
||||
<div className="flex flex-wrap items-baseline gap-1.5">
|
||||
<dt className={labelClass}>Resume</dt>
|
||||
<dd className="min-w-0 break-words">{explicitWaitingResumeLabel}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
);
|
||||
})();
|
||||
|
||||
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 <ExplicitWaitingJumpAnchor anchorId={anchorId} label={label} />;
|
||||
})();
|
||||
|
||||
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 (
|
||||
<div
|
||||
data-blocker-attention-state={blockerAttention?.state}
|
||||
@@ -477,19 +191,10 @@ export function IssueBlockedNotice({
|
||||
className={containerClass}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{showExplicitWaiting ? (
|
||||
<Hourglass className={iconClass} aria-hidden />
|
||||
) : (
|
||||
<AlertTriangle className={iconClass} />
|
||||
)}
|
||||
<AlertTriangle className={iconClass} />
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<p className="leading-5">
|
||||
{showExplicitWaiting ? (
|
||||
<>
|
||||
<strong>{explicitWaitingHeadline}</strong>
|
||||
{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.</>}
|
||||
</p>
|
||||
{showExplicitWaiting ? (
|
||||
<>
|
||||
{explicitWaitingDetails}
|
||||
{isWaitChain && waitLeafBlockers.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
<span className={subRowLabelClass}>Waiting at</span>
|
||||
{waitLeafBlockers.map((blocker) => renderBlockerChip(blocker, "sky"))}
|
||||
</div>
|
||||
) : null}
|
||||
{explicitWaitingJumpAnchor}
|
||||
</>
|
||||
) : null}
|
||||
{!showExplicitWaiting && !isRecoveryNeeded && blockers.length > 0 ? (
|
||||
{!isRecoveryNeeded && blockers.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{blockers.map((blocker) => renderBlockerChip(blocker, "amber"))}
|
||||
</div>
|
||||
) : null}
|
||||
{!showExplicitWaiting && isRecoveryNeeded && !isLeafSurface && recoveryLeafBlockers.length > 0 ? (
|
||||
{isRecoveryNeeded && !isLeafSurface && recoveryLeafBlockers.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
<span className={subRowLabelClass}>Liveness break at</span>
|
||||
{recoveryLeafBlockers.map((blocker) => renderBlockerChip(blocker, "rose"))}
|
||||
</div>
|
||||
) : null}
|
||||
{!showExplicitWaiting && !isRecoveryNeeded && showStalledRow ? (
|
||||
{!isRecoveryNeeded && showStalledRow ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
<span className={subRowLabelClass}>Stalled in review</span>
|
||||
{stalledLeafBlockers.map((blocker) => renderBlockerChip(blocker, "amber"))}
|
||||
</div>
|
||||
) : !showExplicitWaiting && !isRecoveryNeeded && terminalBlockers.length > 0 ? (
|
||||
) : !isRecoveryNeeded && terminalBlockers.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
<span className={subRowLabelClass}>Ultimately waiting on</span>
|
||||
{terminalBlockers.map((blocker) => renderBlockerChip(blocker, "amber"))}
|
||||
@@ -544,49 +237,3 @@ export function IssueBlockedNotice({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExplicitWaitingJumpAnchor({ anchorId, label }: { anchorId: string; label: string }) {
|
||||
const buttonRef = useRef<HTMLButtonElement>(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<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
const target = document.getElementById(anchorId);
|
||||
if (!target) return;
|
||||
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
|
||||
const Arrow = direction === "up" ? ArrowUp : ArrowDown;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={buttonRef}
|
||||
onClick={handleClick}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-sky-700 hover:underline dark:text-sky-300"
|
||||
>
|
||||
{label}
|
||||
<Arrow className="h-3 w-3" aria-hidden />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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<IssueChatComposerHandle, IssueChatComposerP
|
||||
});
|
||||
|
||||
export function IssueChatThread({
|
||||
issueId = null,
|
||||
issueIdentifier = null,
|
||||
comments,
|
||||
interactions = [],
|
||||
approvals = [],
|
||||
feedbackVotes = [],
|
||||
feedbackDataSharingPreference = "prompt",
|
||||
feedbackTermsUrl = null,
|
||||
@@ -3535,16 +3528,9 @@ export function IssueChatThread({
|
||||
{showComposer ? (
|
||||
<div data-testid="issue-chat-thread-notices" className="space-y-2">
|
||||
<IssueBlockedNotice
|
||||
issueId={issueId}
|
||||
issueIdentifier={issueIdentifier}
|
||||
issueStatus={issueStatus}
|
||||
blockers={unresolvedBlockers}
|
||||
blockerAttention={blockerAttention}
|
||||
interactions={interactions}
|
||||
approvals={approvals}
|
||||
agentMap={agentMap}
|
||||
userLabelMap={userLabelMap}
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
<IssueAssigneePausedNotice agent={assignedAgent} />
|
||||
</div>
|
||||
|
||||
@@ -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(<IssueRow issue={issue} />);
|
||||
});
|
||||
|
||||
const pills = Array.from(container.querySelectorAll<HTMLSpanElement>("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);
|
||||
|
||||
|
||||
@@ -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<string, string> | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | 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 ? (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center rounded border border-sky-200/80 bg-sky-50 px-1.5 py-0.5 text-[11px] font-medium text-sky-700 dark:border-sky-500/40 dark:bg-sky-500/10 dark:text-sky-200"
|
||||
title={waitingPillTitle}
|
||||
>
|
||||
{waitingPillTitle}
|
||||
</span>
|
||||
) : null;
|
||||
const mobileWaitingPill = isExplicitWaiting ? (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center rounded border border-sky-200/80 bg-sky-50 px-1.5 py-0.5 text-[11px] font-medium text-sky-700 dark:border-sky-500/40 dark:bg-sky-500/10 dark:text-sky-200"
|
||||
title={waitingPillTitle}
|
||||
>
|
||||
Waiting
|
||||
</span>
|
||||
) : null;
|
||||
const productivityReviewIndicator = productivityReview ? (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -213,20 +155,14 @@ export function IssueRow({
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
{(desktopTrailing || trailingMeta || desktopWaitingPill) ? (
|
||||
{(desktopTrailing || trailingMeta) ? (
|
||||
<span className="ml-auto hidden shrink-0 items-center gap-2 sm:order-3 sm:flex sm:gap-3">
|
||||
{desktopWaitingPill}
|
||||
{desktopTrailing}
|
||||
{trailingMeta ? (
|
||||
<span className="text-xs text-muted-foreground">{trailingMeta}</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
{mobileWaitingPill ? (
|
||||
<span className="ml-auto inline-flex shrink-0 items-center gap-1.5 sm:hidden">
|
||||
{mobileWaitingPill}
|
||||
</span>
|
||||
) : null}
|
||||
{showUnreadSlot ? (
|
||||
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
|
||||
{showUnreadDot ? (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
</p>
|
||||
{onWatchdogDecision && canRecordWatchdogDecisions ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
@@ -623,22 +620,6 @@ export function IssueRunLedgerContent({
|
||||
>
|
||||
Mark false positive
|
||||
</button>
|
||||
{latestSilentRun.outputSilence.level === "critical" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-red-500/40 bg-red-500/10 px-2 py-1 text-[11px] text-red-900 hover:bg-red-500/15 dark:text-red-200"
|
||||
onClick={() =>
|
||||
onWatchdogDecision({
|
||||
runId: latestSilentRun.runId,
|
||||
decision: "cancel_run",
|
||||
evaluationIssueId: latestSilentRun.outputSilence?.evaluationIssueId ?? null,
|
||||
reason: "Cancelled from stale-run recovery banner",
|
||||
})}
|
||||
disabled={pendingWatchdogDecision != null}
|
||||
>
|
||||
Cancel run
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{watchdogDecisionError ? (
|
||||
|
||||
@@ -665,14 +665,6 @@ export function IssuesList({
|
||||
return agents.find((a) => a.id === id)?.name ?? null;
|
||||
}, [agents]);
|
||||
|
||||
const agentNameById = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
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({
|
||||
<IssueRow
|
||||
issue={issue}
|
||||
issueLinkState={issueLinkState}
|
||||
agentNameMap={agentNameById}
|
||||
userLabelMap={companyUserLabelMap}
|
||||
currentUserId={currentUserId}
|
||||
checklistStepNumber={checklistStepNumber}
|
||||
checklistCurrentStep={checklistMeta?.currentStepIssueId === issue.id}
|
||||
checklistDependencyChips={checklistDependencyChips}
|
||||
|
||||
@@ -129,56 +129,6 @@ describe("StatusIcon", () => {
|
||||
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(
|
||||
<StatusIcon
|
||||
status="in_progress"
|
||||
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",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<StatusIcon
|
||||
status="blocked"
|
||||
blockerAttention={{
|
||||
state: "covered",
|
||||
reason: "explicit_waiting",
|
||||
unresolvedBlockerCount: 0,
|
||||
coveredBlockerCount: 0,
|
||||
stalledBlockerCount: 0,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-3000",
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
nextActionOwner: { type: "user", agentId: null, userId: "user-1" },
|
||||
nextActionHint: "needs_human_review",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<StatusIcon
|
||||
|
||||
@@ -20,25 +20,9 @@ interface StatusIconProps {
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
function explicitWaitingTooltipBase(blockerAttention: IssueBlockerAttention | null | undefined): string {
|
||||
const owner = blockerAttention?.nextActionOwner ?? null;
|
||||
if (!owner) return "Waiting on a decision";
|
||||
if (owner.type === "user") {
|
||||
return owner.userId ? "Waiting on user" : "Waiting on board";
|
||||
}
|
||||
if (owner.type === "agent") {
|
||||
return "Waiting on agent";
|
||||
}
|
||||
return "Waiting on a decision";
|
||||
}
|
||||
|
||||
function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null | undefined) {
|
||||
if (!blockerAttention || blockerAttention.state === "none") return "Blocked";
|
||||
|
||||
if (blockerAttention.reason === "explicit_waiting") {
|
||||
return explicitWaitingTooltipBase(blockerAttention);
|
||||
}
|
||||
|
||||
if (blockerAttention.reason === "active_child") {
|
||||
const count = blockerAttention.coveredBlockerCount;
|
||||
if (count === 1 && blockerAttention.sampleBlockerIdentifier) {
|
||||
@@ -95,42 +79,27 @@ function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null |
|
||||
|
||||
export function StatusIcon({ status, blockerAttention, onChange, className, showLabel }: StatusIconProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isExplicitWaiting =
|
||||
blockerAttention?.state === "covered"
|
||||
&& blockerAttention?.reason === "explicit_waiting";
|
||||
const isCoveredBlocked = status === "blocked"
|
||||
&& blockerAttention?.state === "covered"
|
||||
&& !isExplicitWaiting;
|
||||
const isCoveredBlocked = status === "blocked" && blockerAttention?.state === "covered";
|
||||
const isStalledBlocked = status === "blocked" && blockerAttention?.state === "stalled";
|
||||
const isRecoveryNeeded = blockerAttention?.state === "recovery_needed";
|
||||
const colorClass = isExplicitWaiting
|
||||
? "text-sky-600 border-sky-600 dark:text-sky-400 dark:border-sky-400"
|
||||
: isCoveredBlocked
|
||||
? "text-cyan-600 border-cyan-600 dark:text-cyan-400 dark:border-cyan-400"
|
||||
: isStalledBlocked
|
||||
? "text-amber-600 border-amber-600 dark:text-amber-400 dark:border-amber-400"
|
||||
: isRecoveryNeeded
|
||||
? "text-red-600 border-red-600 dark:text-red-400 dark:border-red-400"
|
||||
: issueStatusIcon[status] ?? issueStatusIconDefault;
|
||||
const colorClass = isCoveredBlocked
|
||||
? "text-cyan-600 border-cyan-600 dark:text-cyan-400 dark:border-cyan-400"
|
||||
: isStalledBlocked
|
||||
? "text-amber-600 border-amber-600 dark:text-amber-400 dark:border-amber-400"
|
||||
: isRecoveryNeeded
|
||||
? "text-red-600 border-red-600 dark:text-red-400 dark:border-red-400"
|
||||
: issueStatusIcon[status] ?? issueStatusIconDefault;
|
||||
const isDone = status === "done";
|
||||
const explicitWaitingAriaLabel = isExplicitWaiting
|
||||
? (status === "blocked"
|
||||
? `Blocked · ${explicitWaitingTooltipBase(blockerAttention).toLowerCase()}`
|
||||
: explicitWaitingTooltipBase(blockerAttention))
|
||||
: null;
|
||||
const recoveryAriaLabel = isRecoveryNeeded ? blockedAttentionLabel(blockerAttention) : null;
|
||||
const ariaLabel = explicitWaitingAriaLabel
|
||||
?? recoveryAriaLabel
|
||||
const ariaLabel = recoveryAriaLabel
|
||||
?? (status === "blocked" ? blockedAttentionLabel(blockerAttention) : statusLabel(status));
|
||||
const blockerAttentionState = isExplicitWaiting
|
||||
? "explicit_waiting"
|
||||
: isCoveredBlocked
|
||||
? "covered"
|
||||
: isStalledBlocked
|
||||
? "stalled"
|
||||
: isRecoveryNeeded
|
||||
? "recovery_needed"
|
||||
: undefined;
|
||||
const blockerAttentionState = isCoveredBlocked
|
||||
? "covered"
|
||||
: isStalledBlocked
|
||||
? "stalled"
|
||||
: isRecoveryNeeded
|
||||
? "recovery_needed"
|
||||
: undefined;
|
||||
|
||||
const circle = (
|
||||
<span
|
||||
@@ -147,7 +116,7 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
|
||||
{isDone && (
|
||||
<span className="absolute inset-0 m-auto h-2 w-2 rounded-full bg-current" />
|
||||
)}
|
||||
{(isCoveredBlocked || isExplicitWaiting) && (
|
||||
{isCoveredBlocked && (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-background bg-current" />
|
||||
)}
|
||||
{isStalledBlocked && (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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", () => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
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");
|
||||
|
||||
@@ -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<RunForIssue[]>(issueId),
|
||||
});
|
||||
const { data: linkedApprovals } = useQuery({
|
||||
queryKey: queryKeys.issues.approvals(issueId),
|
||||
queryFn: () => issuesApi.listApprovals(issueId),
|
||||
placeholderData: keepPreviousDataForSameQueryTail<Awaited<ReturnType<typeof issuesApi.listApprovals>>>(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}
|
||||
<IssueChatThread
|
||||
composerRef={composerRef}
|
||||
issueId={issueId}
|
||||
issueIdentifier={issueIdentifier}
|
||||
comments={commentsWithRunMeta}
|
||||
interactions={interactions}
|
||||
approvals={resolvedLinkedApprovals}
|
||||
feedbackVotes={feedbackVotes}
|
||||
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
||||
feedbackTermsUrl={feedbackTermsUrl}
|
||||
@@ -3630,7 +3619,6 @@ export function IssueDetail() {
|
||||
{detailTab === "chat" ? (
|
||||
<IssueDetailChatTab
|
||||
issueId={issue.id}
|
||||
issueIdentifier={issue.identifier ?? null}
|
||||
companyId={issue.companyId}
|
||||
projectId={issue.projectId ?? null}
|
||||
issueStatus={issue.status}
|
||||
|
||||
@@ -248,34 +248,6 @@ const coveredBlockedMatrix: CoveredBlockedCell[] = [
|
||||
expectedVisual: "yellow ring + alert overlay",
|
||||
expectedCopy: "Blocked · liveness break at PAP-2642 · productive run stopped without continuation",
|
||||
},
|
||||
{
|
||||
label: "Explicit waiting (board confirmation)",
|
||||
status: "in_review",
|
||||
blockerAttention: attention({
|
||||
state: "covered",
|
||||
reason: "explicit_waiting",
|
||||
coveredBlockerCount: 1,
|
||||
sampleBlockerIdentifier: "PAP-2708",
|
||||
nextActionOwner: { type: "user", agentId: null, userId: null },
|
||||
nextActionHint: "needs_human_review",
|
||||
}),
|
||||
expectedVisual: "sky ring + small dot",
|
||||
expectedCopy: "Waiting on board",
|
||||
},
|
||||
{
|
||||
label: "Explicit waiting (specific user)",
|
||||
status: "in_review",
|
||||
blockerAttention: attention({
|
||||
state: "covered",
|
||||
reason: "explicit_waiting",
|
||||
coveredBlockerCount: 1,
|
||||
sampleBlockerIdentifier: "PAP-2710",
|
||||
nextActionOwner: { type: "user", agentId: null, userId: "user-1" },
|
||||
nextActionHint: "needs_human_review",
|
||||
}),
|
||||
expectedVisual: "sky ring + small dot",
|
||||
expectedCopy: "Waiting on user",
|
||||
},
|
||||
];
|
||||
|
||||
const coveredBlockedIssue = createIssue({
|
||||
@@ -310,22 +282,15 @@ type BlockedNoticeStateLabel =
|
||||
| "Stalled (single leaf)"
|
||||
| "Stalled (multiple leaves)"
|
||||
| "Recovery needed (parent perspective)"
|
||||
| "Recovery needed (leaf perspective)"
|
||||
| "Explicit wait (plan confirmation)"
|
||||
| "Explicit wait (user response)"
|
||||
| "Explicit wait (board approval)"
|
||||
| "Explicit wait (chain — leaf elsewhere)";
|
||||
| "Recovery needed (leaf perspective)";
|
||||
|
||||
type BlockedNoticeFixture = {
|
||||
label: BlockedNoticeStateLabel;
|
||||
caption: string;
|
||||
issueStatus: string;
|
||||
issueIdentifier?: string;
|
||||
blockers: IssueRelationIssueSummary[];
|
||||
blockerAttention: IssueBlockerAttention;
|
||||
ownerAgentName?: string;
|
||||
interactions?: import("@paperclipai/shared").IssueThreadInteraction[];
|
||||
approvals?: import("@paperclipai/shared").Approval[];
|
||||
};
|
||||
|
||||
const stalledLeafSingle = summaryBlocker({
|
||||
@@ -464,134 +429,6 @@ const blockedNoticeFixtures: BlockedNoticeFixture[] = [
|
||||
}),
|
||||
ownerAgentName: "CodexSparkCoder",
|
||||
},
|
||||
{
|
||||
label: "Explicit wait (plan confirmation)",
|
||||
caption: "A pending board confirmation against the latest plan revision — sky banner, owner/resume rows, jump anchor.",
|
||||
issueStatus: "in_review",
|
||||
issueIdentifier: "PAP-2708",
|
||||
blockers: [],
|
||||
blockerAttention: attention({
|
||||
state: "covered",
|
||||
reason: "explicit_waiting",
|
||||
coveredBlockerCount: 1,
|
||||
sampleBlockerIdentifier: "PAP-2708",
|
||||
nextActionOwner: { type: "user", agentId: null, userId: null },
|
||||
nextActionHint: "needs_human_review",
|
||||
}),
|
||||
interactions: [
|
||||
{
|
||||
id: "interaction-plan",
|
||||
companyId: "co-1",
|
||||
issueId: "issue-pap-2708",
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
createdAt: "2026-04-28T22:00:00.000Z",
|
||||
updatedAt: "2026-04-28T22:00:00.000Z",
|
||||
createdByAgentId: "agent-coder",
|
||||
createdByUserId: null,
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve plan",
|
||||
target: {
|
||||
type: "issue_document",
|
||||
key: "plan",
|
||||
revisionId: "rev-1",
|
||||
revisionNumber: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Explicit wait (user response)",
|
||||
caption: "Pending ask_user_questions waiting on a specific user — sky banner with owner/resume rows and a Jump to questions anchor.",
|
||||
issueStatus: "in_review",
|
||||
issueIdentifier: "PAP-2710",
|
||||
blockers: [],
|
||||
blockerAttention: attention({
|
||||
state: "covered",
|
||||
reason: "explicit_waiting",
|
||||
coveredBlockerCount: 1,
|
||||
sampleBlockerIdentifier: "PAP-2710",
|
||||
nextActionOwner: { type: "user", agentId: null, userId: "user-1" },
|
||||
nextActionHint: "needs_human_review",
|
||||
}),
|
||||
interactions: [
|
||||
{
|
||||
id: "interaction-questions",
|
||||
companyId: "co-1",
|
||||
issueId: "issue-pap-2710",
|
||||
kind: "ask_user_questions",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
createdAt: "2026-04-28T22:00:00.000Z",
|
||||
updatedAt: "2026-04-28T22:00:00.000Z",
|
||||
createdByAgentId: "agent-coder",
|
||||
createdByUserId: null,
|
||||
payload: {
|
||||
version: 1,
|
||||
questions: [
|
||||
{ id: "q1", prompt: "Question 1?", selectionMode: "single", options: [] },
|
||||
{ id: "q2", prompt: "Question 2?", selectionMode: "single", options: [] },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Explicit wait (board approval)",
|
||||
caption: "Pending board approval, no thread interaction — sky banner falls back to board-approval headline and Jump to approval anchor.",
|
||||
issueStatus: "in_review",
|
||||
issueIdentifier: "PAP-2711",
|
||||
blockers: [],
|
||||
blockerAttention: attention({
|
||||
state: "covered",
|
||||
reason: "explicit_waiting",
|
||||
coveredBlockerCount: 1,
|
||||
sampleBlockerIdentifier: "PAP-2711",
|
||||
nextActionOwner: { type: "user", agentId: null, userId: null },
|
||||
nextActionHint: "needs_human_review",
|
||||
}),
|
||||
approvals: [
|
||||
{
|
||||
id: "ca6ba09d-b558-4a53-a552-e7ef87e54a1b",
|
||||
companyId: "co-1",
|
||||
type: "request_board_approval",
|
||||
requestedByAgentId: "agent-coder",
|
||||
requestedByUserId: null,
|
||||
status: "pending",
|
||||
payload: {},
|
||||
decisionNote: null,
|
||||
decidedByUserId: null,
|
||||
decidedAt: null,
|
||||
createdAt: new Date("2026-04-28T22:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-28T22:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Explicit wait (chain — leaf elsewhere)",
|
||||
caption: "Parent issue whose downstream leaf has the pending wait — sky banner with the 'Waiting at' chip strip.",
|
||||
issueStatus: "blocked",
|
||||
issueIdentifier: "PAP-2712",
|
||||
blockers: [
|
||||
summaryBlocker({
|
||||
id: "issue-wait-chain-leaf",
|
||||
identifier: "PAP-2708",
|
||||
title: "Plan confirmation pending downstream",
|
||||
status: "in_review",
|
||||
}),
|
||||
],
|
||||
blockerAttention: attention({
|
||||
state: "covered",
|
||||
reason: "explicit_waiting",
|
||||
coveredBlockerCount: 1,
|
||||
sampleBlockerIdentifier: "PAP-2708",
|
||||
nextActionOwner: { type: "user", agentId: null, userId: null },
|
||||
nextActionHint: "needs_human_review",
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function BlockedNoticeSurface({
|
||||
@@ -616,13 +453,10 @@ function BlockedNoticeSurface({
|
||||
</div>
|
||||
<div className={isMobile ? "max-w-[358px] px-3 py-3" : "min-w-[620px] px-4 py-3"}>
|
||||
<IssueBlockedNotice
|
||||
issueIdentifier={fixture.issueIdentifier}
|
||||
issueStatus={fixture.issueStatus}
|
||||
blockers={fixture.blockers}
|
||||
blockerAttention={fixture.blockerAttention}
|
||||
ownerAgentName={fixture.ownerAgentName}
|
||||
interactions={fixture.interactions}
|
||||
approvals={fixture.approvals}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">{fixture.caption}</p>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user