mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
Revert "Split run progress from liveness disposition"
This reverts commit 55518170ad.
This commit is contained in:
1 parent
caf37a355d
commit
dd57bfba21
40 files changed
+141
-3563
No files matched your search
@@ -434,7 +434,6 @@ Side effects:
|
||||
V1 non-terminal liveness rule:
|
||||
|
||||
- agent-owned `todo`, `in_progress`, `in_review`, and `blocked` issues must have a live execution path, an explicit waiting path, or an explicit recovery path
|
||||
- pending issue-thread interactions and linked pending approvals are explicit waiting paths for any non-terminal status when they name the next responder/action and remain fresh or tied to a resolvable human owner; stale agent-authored or ownerless waits must fall back to stalled/recovery handling
|
||||
- `in_review` is healthy only when a typed execution participant, pending issue-thread interaction or approval, user owner, active run, queued wake, or explicit recovery issue owns the next action
|
||||
- a blocked chain is covered only when each unresolved leaf issue is live or explicitly waiting
|
||||
- when Paperclip cannot safely infer the next action, it surfaces the problem through visible blocked/recovery work instead of silently completing or reassigning work
|
||||
|
||||
+17
-135
@@ -1,7 +1,7 @@
|
||||
# Execution Semantics
|
||||
|
||||
Status: Current implementation guide
|
||||
Date: 2026-04-28
|
||||
Date: 2026-04-26
|
||||
Audience: Product and engineering
|
||||
|
||||
This document explains how Paperclip interprets issue assignment, issue status, execution runs, wakeups, parent/sub-issue structure, and blocker relationships.
|
||||
@@ -162,18 +162,12 @@ The valid action-path primitives are:
|
||||
|
||||
- an active run linked to the issue
|
||||
- a queued wake or continuation that can be delivered to the responsible agent
|
||||
- a scheduled retry or deferred issue-execution wake that is tied to the issue
|
||||
- an active subtree pause hold that intentionally suppresses execution for the issue
|
||||
- a typed execution-policy participant, such as `executionState.currentParticipant`
|
||||
- a pending issue-thread interaction or linked approval that is waiting for a specific responder and is fresh or tied to a resolvable human owner
|
||||
- a pending issue-thread interaction or linked approval that is waiting for a specific responder
|
||||
- a human owner via `assigneeUserId`
|
||||
- a first-class blocker chain whose unresolved leaf issues are themselves healthy
|
||||
- an open explicit recovery issue that names the owner and action needed to restore liveness
|
||||
|
||||
Run progress and issue liveness are separate questions. A finished heartbeat can prove that the agent did useful work during that run, but the finished run is not itself a live path for a non-terminal agent-owned issue. After a heartbeat exits, a non-terminal issue must still have a terminal state, explicit waiting path, explicit live path, or recovery/review path.
|
||||
|
||||
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.
|
||||
|
||||
### Agent-assigned `todo`
|
||||
|
||||
This is dispatch state: ready to start, not yet actively claimed.
|
||||
@@ -186,8 +180,6 @@ A healthy dispatch state means at least one of these is true:
|
||||
|
||||
An assigned `todo` issue is stalled when dispatch was interrupted, no wake remains queued or running, and no recovery path has been opened.
|
||||
|
||||
If an assigned `todo` issue has no latest issue-linked run, no queued issue wake, no active execution path, no active pause hold, and the assignee is invokable and not budget-blocked, periodic recovery may enqueue one initial assignment dispatch backstop. This is a dispatch backstop, not proof that the issue has already been worked.
|
||||
|
||||
### Agent-assigned `in_progress`
|
||||
|
||||
This is active-work state.
|
||||
@@ -196,23 +188,9 @@ A healthy active-work state means at least one of these is true:
|
||||
|
||||
- there is an active run for the issue
|
||||
- there is already a queued continuation wake
|
||||
- there is a scheduled retry or deferred issue-execution wake for the issue
|
||||
- the issue is covered by an active pause hold
|
||||
- the issue is waiting on a live pending interaction or linked approval that clearly owns the next action
|
||||
- there is an open explicit recovery issue for the lost execution path
|
||||
|
||||
An agent-owned `in_progress` issue is stalled when it has no active run, no queued continuation, no explicit waiting path, and no explicit recovery surface. A still-running but silent process is not automatically stalled; it is handled by the active-run watchdog contract.
|
||||
|
||||
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 post-run issue disposition instead of treating success as liveness. The disposition is:
|
||||
|
||||
- `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.
|
||||
An agent-owned `in_progress` issue is stalled when it has no active run, no queued continuation, and no explicit recovery surface. A still-running but silent process is not automatically stalled; it is handled by the active-run watchdog contract.
|
||||
|
||||
### `in_review`
|
||||
|
||||
@@ -221,7 +199,7 @@ This is review/approval state: execution is paused because the next move belongs
|
||||
A healthy `in_review` issue has at least one valid action path:
|
||||
|
||||
- a typed execution-policy participant who can approve or request changes
|
||||
- a live pending issue-thread interaction or linked approval waiting for a named responder
|
||||
- a pending issue-thread interaction or linked approval waiting for a named responder
|
||||
- a human owner via `assigneeUserId`
|
||||
- an active run or queued wake that is expected to process the review state
|
||||
- an open explicit recovery issue for an ambiguous review handoff
|
||||
@@ -238,11 +216,11 @@ A healthy `blocked` issue has an explicit waiting path:
|
||||
|
||||
- first-class blockers exist, and each unresolved leaf has a valid action path under this contract
|
||||
- the issue is blocked on an explicit recovery issue that itself has a live or waiting path
|
||||
- the issue is waiting on a live pending interaction, linked approval, human owner, or clearly named external owner/action
|
||||
- the issue is waiting on a pending interaction, linked approval, human owner, or clearly named external owner/action
|
||||
|
||||
A blocker chain is covered only when its unresolved leaf is live or explicitly waiting. An intermediate `blocked` issue does not make the chain healthy by itself.
|
||||
|
||||
A `blocked` issue is stalled when the unresolved blocker leaf has no active run, queued wake, typed participant, live pending interaction or approval, user owner, external owner/action, or recovery issue. In that case the parent should show the first stalled leaf instead of presenting the dependency as calmly covered.
|
||||
A `blocked` issue is stalled when the unresolved blocker leaf has no active run, queued wake, typed participant, pending interaction or approval, user owner, external owner/action, or recovery issue. In that case the parent should show the first stalled leaf instead of presenting the dependency as calmly covered.
|
||||
|
||||
## 8. Crash and Restart Recovery
|
||||
|
||||
@@ -256,12 +234,11 @@ Example:
|
||||
|
||||
- issue is assigned to an agent
|
||||
- status is `todo`
|
||||
- the original wake/run died during or after dispatch, or dispatch never produced a run
|
||||
- the original wake/run died during or after dispatch
|
||||
- after restart there is no queued wake and nothing picks the issue back up
|
||||
|
||||
Recovery rule:
|
||||
|
||||
- if no issue-linked run exists yet, no queued wake exists, no pause hold suppresses recovery, and the assignee is invokable and not budget-blocked, Paperclip queues an initial assignment dispatch wake
|
||||
- if the latest issue-linked run failed/timed out/cancelled and no live execution path remains, Paperclip queues one automatic assignment recovery wake
|
||||
- if that recovery wake also finishes and the issue is still stranded, Paperclip moves the issue to `blocked` and posts a visible comment
|
||||
|
||||
@@ -278,64 +255,23 @@ 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 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
|
||||
- Paperclip queues one automatic continuation wake
|
||||
- if that continuation wake also finishes and the issue is still stranded, Paperclip moves the issue to `blocked` and posts a visible comment
|
||||
|
||||
This is an active-work continuity recovery.
|
||||
|
||||
### 8.3 Run liveness classification and bounded continuation
|
||||
|
||||
Run liveness classification describes what the just-finished run did. It is not the same thing as issue liveness.
|
||||
|
||||
The current classifier records:
|
||||
|
||||
- `completed` when the issue is terminal
|
||||
- `advanced` when the run produced concrete action evidence such as comments, document revisions, work products, activity events, or tool/action events
|
||||
- `blocked` when the issue or run output declares a concrete blocker
|
||||
- `needs_followup` when output indicates review, ambiguous follow-up, or useful output without concrete action evidence
|
||||
- `plan_only` when the run described runnable future work without concrete action evidence
|
||||
- `empty_response` when the run succeeded without useful output or concrete action evidence
|
||||
- `failed` when the run itself did not succeed
|
||||
|
||||
The classifier also extracts a `nextAction` and actionability signal: `runnable`, `manager_review`, `blocked_external`, `approval_required`, or `unknown`.
|
||||
|
||||
Bounded liveness continuation may be queued for:
|
||||
|
||||
- `plan_only`
|
||||
- `empty_response`
|
||||
- `advanced` only when there is an explicit runnable `nextAction`
|
||||
|
||||
Bounded continuation is not queued when:
|
||||
|
||||
- the issue is terminal, `blocked`, `in_review`, or otherwise not in `todo`/`in_progress`
|
||||
- the issue is no longer assigned to the source run agent
|
||||
- execution-policy state owns the next decision
|
||||
- a live pending issue-thread interaction or linked approval owns the next decision
|
||||
- the assignee is not invokable
|
||||
- a budget hard stop applies
|
||||
- an equivalent continuation wake already exists
|
||||
- the max continuation attempts have been used
|
||||
- a productivity review or active subtree pause hold suppresses continuation
|
||||
|
||||
The default max continuation attempts is 2. Exhaustion records a "Bounded liveness continuation exhausted" comment rather than silently spinning.
|
||||
|
||||
## 9. Startup and Periodic Reconciliation
|
||||
|
||||
Startup recovery and periodic recovery are different from normal wakeup delivery.
|
||||
|
||||
On startup and on the periodic recovery loop, Paperclip now does these things in sequence:
|
||||
On startup and on the periodic recovery loop, Paperclip now does four things in sequence:
|
||||
|
||||
1. reap orphaned `running` runs
|
||||
2. promote due scheduled retries
|
||||
3. resume persisted `queued` runs
|
||||
4. reconcile stranded assigned work
|
||||
5. reconcile issue-graph liveness
|
||||
6. scan silent active runs and create or update explicit watchdog review issues
|
||||
7. reconcile productivity/progression reviews
|
||||
2. resume persisted `queued` runs
|
||||
3. reconcile stranded assigned work
|
||||
4. scan silent active runs and create or update explicit watchdog review issues
|
||||
|
||||
The stranded-work pass closes the gap where issue state survives a crash but the wake/run path does not. The issue-graph pass covers invalid blocker/review dependency leaves. The silent-run scan covers the separate case where a live process exists but has stopped producing observable output. The productivity-review pass covers unusual but still productive execution patterns that need manager judgment, not automatic failure recovery.
|
||||
The stranded-work pass closes the gap where issue state survives a crash but the wake/run path does not. The silent-run scan covers the separate case where a live process exists but has stopped producing observable output.
|
||||
|
||||
## 10. Silent Active-Run Watchdog
|
||||
|
||||
@@ -362,52 +298,7 @@ Operators should prefer `snooze` for known time-bounded quiet periods. `continue
|
||||
|
||||
The board can record watchdog decisions. The assigned owner of the watchdog evaluation issue can also record them. Other agents cannot.
|
||||
|
||||
## 11. Productivity and Progression Review
|
||||
|
||||
Productivity review is a separate lane from liveness recovery.
|
||||
|
||||
Liveness recovery asks: "does this issue have an accountable live/waiting/recovery path?" Productivity review asks: "is this autonomous progression pattern still a good use of time?"
|
||||
|
||||
The productivity review reconciler scans agent-owned `todo` and `in_progress` issues. It skips user-owned issues, hidden issues, productivity-review issues, and descendants of productivity-review issues.
|
||||
|
||||
It creates or updates at most one open `issue_productivity_review` child issue per source issue when one of these triggers fires:
|
||||
|
||||
- no-comment streak: 10 consecutive completed issue-linked runs by the assigned agent without a run-created issue comment
|
||||
- long active duration: default 6 hours in the current active episode
|
||||
- high churn: default 10 runs or assignee run-linked comments in 1 hour, or 30 in 6 hours
|
||||
|
||||
The review issue includes source issue, assigned agent, trigger reasons, sampled run/comment evidence, cost evidence when available, thresholds, and current next action. It is assigned to the source assignee's manager when possible, then creator, project lead, CTO/CEO-style fallback, subject to same-company and invokable/budget checks. The source issue is not automatically cancelled or reassigned by this review.
|
||||
|
||||
Open productivity reviews can hold further automatic liveness continuations for soft-stop triggers:
|
||||
|
||||
- no-comment streak
|
||||
- high churn
|
||||
|
||||
Long active duration creates review work but does not interrupt an already active run by itself.
|
||||
|
||||
Closing a productivity review as `done` acts as a short snooze for repeat review creation on the same source issue. The default resolved-review snooze is 6 hours.
|
||||
|
||||
Productivity review issues must not create productivity-review descendants. They are manager decision points, not new autonomous loops.
|
||||
|
||||
## 12. Pause Holds and Recovery Suppression
|
||||
|
||||
Subtree pause/cancel/restore holds are execution-control primitives. While an issue is covered by an active pause hold, Paperclip treats the hold as an explicit waiting path and suppresses automatic recovery and liveness continuation for that issue.
|
||||
|
||||
This includes:
|
||||
|
||||
- assigned `todo` dispatch backstops
|
||||
- assigned `todo` or `in_progress` recovery wakes
|
||||
- bounded `run_liveness_continuation` wakes
|
||||
- scheduled retry promotion
|
||||
- deferred issue-execution promotion
|
||||
|
||||
Pause holds do not make the issue complete. They intentionally stop automatic execution while preserving visible ownership, blockers, comments, and recovery evidence. Explicit human/operator actions can still resolve, reassign, unblock, or restore the held tree through the relevant tree-control flow.
|
||||
|
||||
During startup and periodic reconciliation, pause-hold checks run inside the affected passes rather than as a separate top-level phase. The current sequence remains: reap orphaned `running` runs, promote due scheduled retries, resume persisted `queued` runs, reconcile stranded assigned work, reconcile issue-graph liveness, scan silent active runs, then reconcile productivity/progression reviews. Any pass that would otherwise dispatch, promote, continue, or escalate held issue work must recognize the active pause hold first and leave the issue quiet until the hold is released.
|
||||
|
||||
User-visible history should retain attribution for these control actions. `issue.tree_hold_created` records who paused or cancelled the tree, and `issue.tree_hold_released` records who resumed or restored it. Raw heartbeat-run cancellation caused by the hold is a process interruption detail; it is not itself the same as pausing issue work.
|
||||
|
||||
## 13. Auto-Recover vs Explicit Recovery vs Human Escalation
|
||||
## 11. Auto-Recover vs Explicit Recovery vs Human Escalation
|
||||
|
||||
Paperclip uses three different recovery outcomes, depending on how much it can safely infer.
|
||||
|
||||
@@ -417,10 +308,8 @@ Auto-recovery is allowed when ownership is clear and the control plane only lost
|
||||
|
||||
Examples:
|
||||
|
||||
- queue an initial dispatch wake for an assigned `todo` issue with no run and no queued wake
|
||||
- requeue one dispatch wake for an assigned `todo` issue whose latest run failed, timed out, or was cancelled
|
||||
- requeue one continuation wake for an assigned `in_progress` issue whose live execution path disappeared
|
||||
- queue bounded liveness continuation when a terminal successful run left a runnable next action but no live path
|
||||
- assign an orphan blocker back to its creator when that blocker is already preventing other work
|
||||
|
||||
Auto-recovery preserves the existing owner. It does not choose a replacement agent.
|
||||
@@ -434,8 +323,6 @@ Examples:
|
||||
- automatic stranded-work retry was already exhausted
|
||||
- a dependency graph has an invalid/uninvokable owner, unassigned blocker, or invalid review participant
|
||||
- an active run is silent past the watchdog threshold
|
||||
- a terminal successful run left a non-terminal issue with no safe runnable action path
|
||||
- a stranded recovery issue itself failed; recovery issues are blocked in place rather than spawning nested stranded-recovery issues
|
||||
|
||||
The source issue remains visible and blocked on the recovery issue when blocking is necessary for correctness. The recovery owner must restore a live path, resolve the source issue manually, or record the reason it is a false positive.
|
||||
|
||||
@@ -453,7 +340,7 @@ Examples:
|
||||
|
||||
In these cases Paperclip should leave a visible issue/comment trail instead of silently retrying.
|
||||
|
||||
## 14. What This Does Not Mean
|
||||
## 12. What This Does Not Mean
|
||||
|
||||
These semantics do not change V1 into an auto-reassignment system.
|
||||
|
||||
@@ -462,25 +349,20 @@ Paperclip still does not:
|
||||
- automatically reassign work to a different agent
|
||||
- infer dependency semantics from `parentId` alone
|
||||
- treat human-held work as heartbeat-managed execution
|
||||
- treat productivity review as proof of failure or as permission to cancel active work automatically
|
||||
|
||||
The recovery model is intentionally conservative:
|
||||
|
||||
- preserve ownership
|
||||
- retry once when the control plane lost execution continuity
|
||||
- continue productive work only through bounded, idempotent continuation paths
|
||||
- create explicit recovery work when the system can identify a bounded recovery owner/action
|
||||
- create productivity review work when the work is progressing but unusual enough to need manager judgment
|
||||
- escalate visibly when the system cannot safely keep going
|
||||
|
||||
## 15. Practical Interpretation
|
||||
## 13. Practical Interpretation
|
||||
|
||||
For a board operator, the intended meaning is:
|
||||
|
||||
- agent-owned `in_progress` should mean \"this is live work or clearly surfaced as a problem\"
|
||||
- agent-owned `todo` should not stay assigned forever after a crash with no remaining wake path
|
||||
- productive work can continue, but unusual no-comment or high-churn patterns become manager-visible productivity reviews
|
||||
- pause-held trees are intentionally quiet until restored or manually acted on
|
||||
- parent/sub-issue explains structure
|
||||
- blockers explain waiting
|
||||
|
||||
|
||||
@@ -333,9 +333,6 @@ export type {
|
||||
IssueBlockerAttention,
|
||||
IssueBlockerAttentionReason,
|
||||
IssueBlockerAttentionState,
|
||||
IssueBlockerNextActionHint,
|
||||
IssueBlockerNextActionOwner,
|
||||
IssueBlockerNextActionOwnerType,
|
||||
IssueProductivityReview,
|
||||
IssueProductivityReviewTrigger,
|
||||
IssueReferenceSource,
|
||||
|
||||
@@ -136,9 +136,6 @@ export type {
|
||||
IssueBlockerAttention,
|
||||
IssueBlockerAttentionReason,
|
||||
IssueBlockerAttentionState,
|
||||
IssueBlockerNextActionHint,
|
||||
IssueBlockerNextActionOwner,
|
||||
IssueBlockerNextActionOwnerType,
|
||||
IssueProductivityReview,
|
||||
IssueProductivityReviewTrigger,
|
||||
IssueReferenceSource,
|
||||
|
||||
@@ -119,37 +119,13 @@ export interface IssueRelationIssueSummary {
|
||||
terminalBlockers?: IssueRelationIssueSummary[];
|
||||
}
|
||||
|
||||
export type IssueBlockerAttentionState =
|
||||
| "none"
|
||||
| "covered"
|
||||
| "stalled"
|
||||
| "needs_attention"
|
||||
| "recovery_needed";
|
||||
export type IssueBlockerAttentionState = "none" | "covered" | "stalled" | "needs_attention";
|
||||
|
||||
export type IssueBlockerAttentionReason =
|
||||
| "active_child"
|
||||
| "active_dependency"
|
||||
| "stalled_review"
|
||||
| "attention_required"
|
||||
| "explicit_waiting"
|
||||
| "productive_run_stopped"
|
||||
| "continuation_exhausted"
|
||||
| "continuation_suppressed"
|
||||
| null;
|
||||
|
||||
export type IssueBlockerNextActionOwnerType = "agent" | "user" | "none";
|
||||
|
||||
export interface IssueBlockerNextActionOwner {
|
||||
type: IssueBlockerNextActionOwnerType;
|
||||
agentId?: string | null;
|
||||
userId?: string | null;
|
||||
}
|
||||
|
||||
export type IssueBlockerNextActionHint =
|
||||
| "wake_to_continue"
|
||||
| "needs_human_review"
|
||||
| "create_recovery_issue"
|
||||
| "reassign"
|
||||
| null;
|
||||
|
||||
export interface IssueBlockerAttention {
|
||||
@@ -161,8 +137,6 @@ export interface IssueBlockerAttention {
|
||||
attentionBlockerCount: number;
|
||||
sampleBlockerIdentifier: string | null;
|
||||
sampleStalledBlockerIdentifier: string | null;
|
||||
nextActionOwner: IssueBlockerNextActionOwner | null;
|
||||
nextActionHint: IssueBlockerNextActionHint;
|
||||
}
|
||||
|
||||
export type IssueProductivityReviewTrigger =
|
||||
|
||||
@@ -260,48 +260,6 @@ describe("approval routes idempotent retries", () => {
|
||||
expect(mockApprovalService.reject).toHaveBeenCalledWith("approval-5", "user-1", "not now");
|
||||
});
|
||||
|
||||
it("wakes the requesting agent when an approval is rejected", async () => {
|
||||
mockApprovalService.getById.mockResolvedValue({
|
||||
id: "approval-7",
|
||||
companyId: "company-1",
|
||||
type: "request_board_approval",
|
||||
status: "pending",
|
||||
payload: {},
|
||||
requestedByAgentId: "agent-1",
|
||||
});
|
||||
mockApprovalService.reject.mockResolvedValue({
|
||||
approval: {
|
||||
id: "approval-7",
|
||||
companyId: "company-1",
|
||||
type: "request_board_approval",
|
||||
status: "rejected",
|
||||
payload: {},
|
||||
requestedByAgentId: "agent-1",
|
||||
},
|
||||
applied: true,
|
||||
});
|
||||
mockIssueApprovalService.listIssuesForApproval.mockResolvedValue([{ id: "issue-1" }, { id: "issue-2" }]);
|
||||
|
||||
const res = await request(await createApp())
|
||||
.post("/api/approvals/approval-7/reject")
|
||||
.send({ decisionNote: "not approved" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith("agent-1", expect.objectContaining({
|
||||
reason: "approval_rejected",
|
||||
payload: expect.objectContaining({
|
||||
approvalId: "approval-7",
|
||||
approvalStatus: "rejected",
|
||||
issueId: "issue-1",
|
||||
issueIds: ["issue-1", "issue-2"],
|
||||
}),
|
||||
contextSnapshot: expect.objectContaining({
|
||||
wakeReason: "approval_rejected",
|
||||
taskId: "issue-1",
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("derives approval attribution from the authenticated actor on request revision", async () => {
|
||||
mockApprovalService.getById.mockResolvedValue({
|
||||
id: "approval-6",
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
agents,
|
||||
agentRuntimeState,
|
||||
agentWakeupRequests,
|
||||
approvals,
|
||||
budgetPolicies,
|
||||
companySkills,
|
||||
companies,
|
||||
@@ -18,10 +17,8 @@ import {
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issueApprovals,
|
||||
issueDocuments,
|
||||
issueRelations,
|
||||
issueThreadInteractions,
|
||||
issueTreeHoldMembers,
|
||||
issueTreeHolds,
|
||||
issues,
|
||||
@@ -313,19 +310,14 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
await db.delete(companySkills);
|
||||
await db.delete(costEvents);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueThreadInteractions);
|
||||
await db.delete(issueApprovals);
|
||||
await db.delete(issueDocuments);
|
||||
await db.delete(documentRevisions);
|
||||
await db.delete(documents);
|
||||
await db.delete(approvals);
|
||||
await db.delete(issueRelations);
|
||||
await db.delete(issueTreeHoldMembers);
|
||||
await db.delete(issueTreeHolds);
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
await db.delete(issueComments);
|
||||
await db.delete(issueThreadInteractions);
|
||||
await db.delete(issueApprovals);
|
||||
await db.delete(issueDocuments);
|
||||
try {
|
||||
await db.delete(issues);
|
||||
@@ -348,7 +340,6 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
}
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(budgetPolicies);
|
||||
await db.delete(approvals);
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
await db.delete(agentRuntimeState);
|
||||
try {
|
||||
@@ -482,9 +473,6 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
assignToUser?: boolean;
|
||||
activePauseHold?: boolean;
|
||||
livenessState?: "completed" | "advanced" | "plan_only" | "empty_response" | "blocked" | "failed" | "needs_followup" | null;
|
||||
livenessReason?: string | null;
|
||||
nextAction?: string | null;
|
||||
continuationAttempt?: number | null;
|
||||
runErrorCode?: string | null;
|
||||
runError?: string | null;
|
||||
}) {
|
||||
@@ -559,9 +547,6 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
? null
|
||||
: ("runError" in input ? input.runError : "run failed before issue advanced"),
|
||||
livenessState: input.livenessState ?? null,
|
||||
livenessReason: input.livenessReason ?? null,
|
||||
nextAction: input.nextAction ?? null,
|
||||
continuationAttempt: input.continuationAttempt ?? 0,
|
||||
});
|
||||
|
||||
await db.insert(issues).values([
|
||||
@@ -2125,126 +2110,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
expect(wakeups).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("queues a bounded liveness continuation for productive successful stranded in-progress work", async () => {
|
||||
const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
status: "in_progress",
|
||||
runStatus: "succeeded",
|
||||
livenessState: "advanced",
|
||||
livenessReason: "Run made progress and left follow-up work",
|
||||
nextAction: "Implement the remaining recovery branch.",
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reconcileStrandedAssignedIssues();
|
||||
expect(result.continuationRequeued).toBe(1);
|
||||
expect(result.escalated).toBe(0);
|
||||
expect(result.issueIds).toEqual([issueId]);
|
||||
|
||||
const wakeups = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(and(eq(agentWakeupRequests.agentId, agentId), eq(agentWakeupRequests.reason, "run_liveness_continuation")));
|
||||
expect(wakeups).toHaveLength(1);
|
||||
expect(wakeups[0]?.payload).toMatchObject({
|
||||
issueId,
|
||||
sourceRunId: runId,
|
||||
livenessState: "advanced",
|
||||
continuationAttempt: 1,
|
||||
instruction: "Implement the remaining recovery branch.",
|
||||
});
|
||||
expect(wakeups[0]?.idempotencyKey).toBe(`run_liveness_continuation:${issueId}:${runId}:advanced:1`);
|
||||
|
||||
const sourceRun = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)).then((rows) => rows[0] ?? null);
|
||||
expect(sourceRun?.continuationAttempt).toBe(1);
|
||||
|
||||
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
|
||||
expect(issue?.status).toBe("in_progress");
|
||||
|
||||
const recoveryIssues = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stranded_issue_recovery")));
|
||||
expect(recoveryIssues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("blocks productive successful stranded work when bounded liveness continuation is exhausted", async () => {
|
||||
const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
status: "in_progress",
|
||||
runStatus: "succeeded",
|
||||
livenessState: "advanced",
|
||||
livenessReason: "Run still has follow-up work",
|
||||
nextAction: "Try the remaining implementation step.",
|
||||
continuationAttempt: 2,
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reconcileStrandedAssignedIssues();
|
||||
expect(result.continuationRequeued).toBe(0);
|
||||
expect(result.escalated).toBe(1);
|
||||
expect(result.issueIds).toEqual([issueId]);
|
||||
|
||||
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
|
||||
expect(issue?.status).toBe("blocked");
|
||||
|
||||
const recovery = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.originKind, "stranded_issue_recovery"),
|
||||
eq(issues.originId, issueId),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!recovery) throw new Error("Expected stranded recovery issue");
|
||||
expect(recovery).toMatchObject({
|
||||
companyId,
|
||||
parentId: issueId,
|
||||
assigneeAgentId: agentId,
|
||||
originKind: "stranded_issue_recovery",
|
||||
originId: issueId,
|
||||
originRunId: runId,
|
||||
});
|
||||
await expect(sourceBlockerIssueIds(companyId, issueId)).resolves.toEqual([recovery.id]);
|
||||
|
||||
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0]?.body).toContain("Bounded liveness continuation exhausted");
|
||||
expect(comments[0]?.body).toContain(`Recovery issue: [${recovery.identifier}]`);
|
||||
});
|
||||
|
||||
it("does not duplicate an existing bounded liveness continuation wake for productive successful work", async () => {
|
||||
const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
status: "in_progress",
|
||||
runStatus: "succeeded",
|
||||
livenessState: "advanced",
|
||||
nextAction: "Resume implementation from the recorded next action.",
|
||||
});
|
||||
await db.insert(agentWakeupRequests).values({
|
||||
companyId,
|
||||
agentId,
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "run_liveness_continuation",
|
||||
payload: { issueId, sourceRunId: runId, livenessState: "advanced", continuationAttempt: 1 },
|
||||
status: "queued",
|
||||
idempotencyKey: `run_liveness_continuation:${issueId}:${runId}:advanced:1`,
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reconcileStrandedAssignedIssues();
|
||||
expect(result.continuationRequeued).toBe(0);
|
||||
expect(result.escalated).toBe(0);
|
||||
expect(result.skipped).toBe(1);
|
||||
|
||||
const wakeups = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(and(eq(agentWakeupRequests.agentId, agentId), eq(agentWakeupRequests.reason, "run_liveness_continuation")));
|
||||
expect(wakeups).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("escalates a successful automatic continuation when no live post-run path remains", async () => {
|
||||
const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
it("records productive continuation instead of recovery when the latest automatic continuation succeeded", async () => {
|
||||
const { agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
status: "in_progress",
|
||||
runStatus: "succeeded",
|
||||
retryReason: "issue_continuation_needed",
|
||||
@@ -2254,36 +2121,28 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
|
||||
const result = await heartbeat.reconcileStrandedAssignedIssues();
|
||||
expect(result.continuationRequeued).toBe(0);
|
||||
expect(result.productiveContinuationObserved).toBe(0);
|
||||
expect(result.productiveContinuationObserved).toBe(1);
|
||||
expect(result.successfulContinuationObserved).toBe(0);
|
||||
expect(result.escalated).toBe(1);
|
||||
expect(result.issueIds).toEqual([issueId]);
|
||||
expect(result.escalated).toBe(0);
|
||||
expect(result.issueIds).toEqual([]);
|
||||
|
||||
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
|
||||
expect(issue?.status).toBe("blocked");
|
||||
expect(issue?.status).toBe("in_progress");
|
||||
|
||||
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0]?.body).toContain("successful automatic continuation");
|
||||
expect(comments).toHaveLength(0);
|
||||
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId));
|
||||
const sourceIssueRuns = runs.filter((row) => (row.contextSnapshot as Record<string, unknown> | null)?.issueId === issueId);
|
||||
expect(sourceIssueRuns.map((row) => row.id)).toEqual([runId]);
|
||||
|
||||
const recoveryIssues = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stranded_issue_recovery")));
|
||||
expect(recoveryIssues).toHaveLength(1);
|
||||
expect(runs.map((row) => row.id)).toEqual([runId]);
|
||||
|
||||
const wakeups = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, agentId));
|
||||
expect(wakeups.some((row) => (row.payload as Record<string, unknown> | null)?.issueId === recoveryIssues[0]?.id)).toBe(true);
|
||||
expect(wakeups).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not treat a productive terminal run as healthy when in-progress work has no live path", async () => {
|
||||
it.fails("does not treat a productive terminal run as healthy when in-progress work has no live path", async () => {
|
||||
const { companyId, agentId, issueId } = await seedStrandedIssueFixture({
|
||||
status: "in_progress",
|
||||
runStatus: "succeeded",
|
||||
@@ -2348,408 +2207,4 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
||||
expect(runs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("suppresses bounded liveness continuation wakes when the issue is held by an active pause hold", async () => {
|
||||
const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
status: "in_progress",
|
||||
runStatus: "succeeded",
|
||||
livenessState: "plan_only",
|
||||
livenessReason: "Run made no concrete progress",
|
||||
nextAction: "Take the first concrete action now.",
|
||||
activePauseHold: true,
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const sourceRun = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!sourceRun) throw new Error("Expected source run");
|
||||
|
||||
await heartbeat.handleRunLivenessContinuationForTest(sourceRun);
|
||||
|
||||
const wakeups = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.reason, "run_liveness_continuation")));
|
||||
expect(wakeups).toHaveLength(0);
|
||||
|
||||
const refreshedRun = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(refreshedRun?.continuationAttempt).toBe(0);
|
||||
expect(refreshedRun?.livenessReason).toContain("continuation suppressed by active pause hold");
|
||||
|
||||
const recoveryIssues = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stranded_issue_recovery")));
|
||||
expect(recoveryIssues).toHaveLength(0);
|
||||
|
||||
const refreshedIssue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
|
||||
expect(refreshedIssue?.status).toBe("in_progress");
|
||||
expect(refreshedIssue?.assigneeAgentId).toBe(agentId);
|
||||
});
|
||||
|
||||
it("suppresses bounded liveness continuation wakes when the issue has a pending confirmation", async () => {
|
||||
const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
status: "in_progress",
|
||||
runStatus: "succeeded",
|
||||
livenessState: "plan_only",
|
||||
livenessReason: "Run produced a plan and needs approval",
|
||||
nextAction: "Wait for the board to confirm the plan.",
|
||||
});
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
companyId,
|
||||
issueId,
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve the plan?",
|
||||
},
|
||||
createdByAgentId: agentId,
|
||||
sourceRunId: runId,
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const sourceRun = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!sourceRun) throw new Error("Expected source run");
|
||||
|
||||
await heartbeat.handleRunLivenessContinuationForTest(sourceRun);
|
||||
|
||||
const wakeups = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.reason, "run_liveness_continuation")));
|
||||
expect(wakeups).toHaveLength(0);
|
||||
|
||||
const refreshedRun = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(refreshedRun?.continuationAttempt).toBe(0);
|
||||
expect(refreshedRun?.livenessReason).toContain("continuation suppressed by explicit interaction or approval wait");
|
||||
});
|
||||
|
||||
it("does not suppress bounded liveness continuation for stale agent-authored confirmations", async () => {
|
||||
const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
status: "in_progress",
|
||||
runStatus: "succeeded",
|
||||
livenessState: "plan_only",
|
||||
livenessReason: "Run produced a plan and needs approval",
|
||||
nextAction: "Continue after recovering from the stale confirmation.",
|
||||
});
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
companyId,
|
||||
issueId,
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve the stale plan?",
|
||||
},
|
||||
createdByAgentId: agentId,
|
||||
sourceRunId: runId,
|
||||
createdAt: new Date("2026-03-19T00:06:00.000Z"),
|
||||
updatedAt: new Date("2026-03-19T00:06:00.000Z"),
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
const sourceRun = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)).then((rows) => rows[0] ?? null);
|
||||
if (!sourceRun) throw new Error("Expected source run");
|
||||
|
||||
await heartbeat.handleRunLivenessContinuationForTest(sourceRun);
|
||||
|
||||
const wakeups = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.reason, "run_liveness_continuation")));
|
||||
expect(wakeups).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not suppress bounded liveness continuation for stale agent-requested approvals", async () => {
|
||||
const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
status: "in_progress",
|
||||
runStatus: "succeeded",
|
||||
livenessState: "plan_only",
|
||||
livenessReason: "Run requested approval before continuing",
|
||||
nextAction: "Continue after recovering from the stale approval.",
|
||||
});
|
||||
const approvalId = randomUUID();
|
||||
await db.insert(approvals).values({
|
||||
id: approvalId,
|
||||
companyId,
|
||||
type: "request_board_approval",
|
||||
requestedByAgentId: agentId,
|
||||
status: "pending",
|
||||
payload: {},
|
||||
createdAt: new Date("2026-03-19T00:06:00.000Z"),
|
||||
updatedAt: new Date("2026-03-19T00:06:00.000Z"),
|
||||
});
|
||||
await db.insert(issueApprovals).values({
|
||||
companyId,
|
||||
issueId,
|
||||
approvalId,
|
||||
linkedByAgentId: agentId,
|
||||
createdAt: new Date("2026-03-19T00:06:00.000Z"),
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
const sourceRun = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)).then((rows) => rows[0] ?? null);
|
||||
if (!sourceRun) throw new Error("Expected source run");
|
||||
|
||||
await heartbeat.handleRunLivenessContinuationForTest(sourceRun);
|
||||
|
||||
const wakeups = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.reason, "run_liveness_continuation")));
|
||||
expect(wakeups).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not re-dispatch assigned work while a live pending confirmation owns the next step", async () => {
|
||||
const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
status: "todo",
|
||||
runStatus: "failed",
|
||||
});
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
companyId,
|
||||
issueId,
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve the next action?",
|
||||
},
|
||||
createdByAgentId: agentId,
|
||||
sourceRunId: runId,
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reconcileStrandedAssignedIssues();
|
||||
expect(result.dispatchRequeued).toBe(0);
|
||||
expect(result.continuationRequeued).toBe(0);
|
||||
expect(result.skipped).toBeGreaterThan(0);
|
||||
|
||||
const wakeups = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.reason, "issue_assignment_recovery")));
|
||||
expect(wakeups).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("expires a stale confirmation target and then queues bounded liveness continuation", async () => {
|
||||
const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
status: "in_progress",
|
||||
runStatus: "succeeded",
|
||||
livenessState: "plan_only",
|
||||
livenessReason: "Run produced a plan and needs approval",
|
||||
nextAction: "Continue from the current plan revision.",
|
||||
});
|
||||
const documentId = randomUUID();
|
||||
const oldRevisionId = randomUUID();
|
||||
const currentRevisionId = randomUUID();
|
||||
const interactionId = randomUUID();
|
||||
await db.insert(documents).values({
|
||||
id: documentId,
|
||||
companyId,
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
latestBody: "current plan",
|
||||
latestRevisionId: currentRevisionId,
|
||||
latestRevisionNumber: 2,
|
||||
});
|
||||
await db.insert(issueDocuments).values({
|
||||
companyId,
|
||||
issueId,
|
||||
documentId,
|
||||
key: "plan",
|
||||
});
|
||||
await db.insert(documentRevisions).values([
|
||||
{
|
||||
id: oldRevisionId,
|
||||
companyId,
|
||||
documentId,
|
||||
revisionNumber: 1,
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
body: "old plan",
|
||||
},
|
||||
{
|
||||
id: currentRevisionId,
|
||||
companyId,
|
||||
documentId,
|
||||
revisionNumber: 2,
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
body: "current plan",
|
||||
},
|
||||
]);
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
id: interactionId,
|
||||
companyId,
|
||||
issueId,
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee_on_accept",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve the plan?",
|
||||
target: {
|
||||
type: "issue_document",
|
||||
issueId,
|
||||
documentId,
|
||||
key: "plan",
|
||||
revisionId: oldRevisionId,
|
||||
revisionNumber: 1,
|
||||
},
|
||||
},
|
||||
createdByAgentId: agentId,
|
||||
sourceRunId: runId,
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
const sourceRun = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)).then((rows) => rows[0] ?? null);
|
||||
if (!sourceRun) throw new Error("Expected source run");
|
||||
|
||||
await heartbeat.handleRunLivenessContinuationForTest(sourceRun);
|
||||
|
||||
const [interaction] = await db
|
||||
.select()
|
||||
.from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.id, interactionId));
|
||||
expect(interaction?.status).toBe("expired");
|
||||
expect((interaction?.result as Record<string, unknown> | null)?.outcome).toBe("stale_target");
|
||||
|
||||
const wakeups = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.reason, "run_liveness_continuation")));
|
||||
expect(wakeups).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("expires a confirmation superseded by a user comment and then queues bounded liveness continuation", async () => {
|
||||
const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
status: "in_progress",
|
||||
runStatus: "succeeded",
|
||||
livenessState: "plan_only",
|
||||
livenessReason: "Run asked the board to confirm the next step",
|
||||
nextAction: "Continue after incorporating the board comment.",
|
||||
});
|
||||
const interactionId = randomUUID();
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
id: interactionId,
|
||||
companyId,
|
||||
issueId,
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve the next step?",
|
||||
supersedeOnUserComment: true,
|
||||
},
|
||||
createdByAgentId: agentId,
|
||||
sourceRunId: runId,
|
||||
createdAt: new Date("2026-03-19T00:06:00.000Z"),
|
||||
});
|
||||
const commentId = randomUUID();
|
||||
await db.insert(issueComments).values({
|
||||
id: commentId,
|
||||
companyId,
|
||||
issueId,
|
||||
authorUserId: "board",
|
||||
body: "Use the narrower implementation path.",
|
||||
createdAt: new Date("2026-03-19T00:07:00.000Z"),
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
const sourceRun = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)).then((rows) => rows[0] ?? null);
|
||||
if (!sourceRun) throw new Error("Expected source run");
|
||||
|
||||
await heartbeat.handleRunLivenessContinuationForTest(sourceRun);
|
||||
|
||||
const [interaction] = await db
|
||||
.select()
|
||||
.from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.id, interactionId));
|
||||
expect(interaction?.status).toBe("expired");
|
||||
expect(interaction?.result).toMatchObject({
|
||||
outcome: "superseded_by_comment",
|
||||
commentId,
|
||||
});
|
||||
|
||||
const wakeups = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.reason, "run_liveness_continuation")));
|
||||
expect(wakeups).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("queues bounded liveness continuation once a linked approval is resolved", async () => {
|
||||
const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
status: "in_progress",
|
||||
runStatus: "succeeded",
|
||||
livenessState: "plan_only",
|
||||
livenessReason: "Run requested approval before continuing",
|
||||
nextAction: "Continue after the approval decision.",
|
||||
});
|
||||
const approvalId = randomUUID();
|
||||
await db.insert(approvals).values({
|
||||
id: approvalId,
|
||||
companyId,
|
||||
type: "request_board_approval",
|
||||
requestedByAgentId: agentId,
|
||||
status: "rejected",
|
||||
payload: {},
|
||||
decidedByUserId: "board",
|
||||
decidedAt: new Date("2026-03-19T00:06:00.000Z"),
|
||||
});
|
||||
await db.insert(issueApprovals).values({
|
||||
companyId,
|
||||
issueId,
|
||||
approvalId,
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
const sourceRun = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)).then((rows) => rows[0] ?? null);
|
||||
if (!sourceRun) throw new Error("Expected source run");
|
||||
|
||||
await heartbeat.handleRunLivenessContinuationForTest(sourceRun);
|
||||
|
||||
const wakeups = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.reason, "run_liveness_continuation")));
|
||||
expect(wakeups).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("queues a bounded liveness continuation when no pause hold suppresses it", async () => {
|
||||
const { companyId, runId } = await seedStrandedIssueFixture({
|
||||
status: "in_progress",
|
||||
runStatus: "succeeded",
|
||||
livenessState: "plan_only",
|
||||
livenessReason: "Run made no concrete progress",
|
||||
nextAction: "Take the first concrete action now.",
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const sourceRun = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!sourceRun) throw new Error("Expected source run");
|
||||
|
||||
await heartbeat.handleRunLivenessContinuationForTest(sourceRun);
|
||||
|
||||
const wakeups = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.reason, "run_liveness_continuation")));
|
||||
expect(wakeups).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
agentRuntimeState,
|
||||
agentWakeupRequests,
|
||||
@@ -11,8 +10,6 @@ import {
|
||||
environmentLeases,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueTreeHoldMembers,
|
||||
issueTreeHolds,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
@@ -45,11 +42,8 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(activityLog);
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(environmentLeases);
|
||||
await db.delete(issueTreeHoldMembers);
|
||||
await db.delete(issueTreeHolds);
|
||||
await db.delete(issues);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agentWakeupRequests);
|
||||
@@ -840,132 +834,4 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => {
|
||||
retryNotBefore.toISOString(),
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels a due scheduled retry whose issue is held by an active pause hold", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const sourceRunId = randomUUID();
|
||||
const now = new Date("2026-04-20T20:00:00.000Z");
|
||||
|
||||
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: agentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
wakeOnDemand: true,
|
||||
maxConcurrentRuns: 1,
|
||||
},
|
||||
},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: sourceRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: "system",
|
||||
status: "failed",
|
||||
error: "upstream overload",
|
||||
errorCode: "adapter_failed",
|
||||
finishedAt: now,
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
wakeReason: "issue_assigned",
|
||||
},
|
||||
updatedAt: now,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Pause-hold scheduled retry",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
executionRunId: sourceRunId,
|
||||
executionAgentNameKey: "codexcoder",
|
||||
executionLockedAt: now,
|
||||
issueNumber: 1,
|
||||
identifier: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}-1`,
|
||||
});
|
||||
|
||||
const scheduled = await heartbeat.scheduleBoundedRetry(sourceRunId, {
|
||||
now,
|
||||
random: () => 0.5,
|
||||
});
|
||||
expect(scheduled.outcome).toBe("scheduled");
|
||||
if (scheduled.outcome !== "scheduled") return;
|
||||
|
||||
await db.insert(issueTreeHolds).values({
|
||||
companyId,
|
||||
rootIssueId: issueId,
|
||||
mode: "pause",
|
||||
status: "active",
|
||||
reason: "pause scheduled retry promotion",
|
||||
releasePolicy: { strategy: "manual" },
|
||||
});
|
||||
|
||||
const promotion = await heartbeat.promoteDueScheduledRetries(scheduled.dueAt);
|
||||
expect(promotion).toEqual({ promoted: 0, runIds: [] });
|
||||
|
||||
const cancelled = await db
|
||||
.select({
|
||||
status: heartbeatRuns.status,
|
||||
errorCode: heartbeatRuns.errorCode,
|
||||
error: heartbeatRuns.error,
|
||||
wakeupRequestId: heartbeatRuns.wakeupRequestId,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, scheduled.run.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(cancelled).toMatchObject({
|
||||
status: "cancelled",
|
||||
errorCode: "issue_paused",
|
||||
});
|
||||
expect(cancelled?.error).toContain("active subtree pause hold");
|
||||
|
||||
const wakeupRequestId = cancelled?.wakeupRequestId ?? null;
|
||||
if (wakeupRequestId) {
|
||||
const wakeup = await db
|
||||
.select({ status: agentWakeupRequests.status })
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.id, wakeupRequestId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(wakeup?.status).toBe("cancelled");
|
||||
}
|
||||
|
||||
const issue = await db
|
||||
.select({ executionRunId: issues.executionRunId })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(issue?.executionRunId).toBeNull();
|
||||
|
||||
const event = await db
|
||||
.select({
|
||||
message: heartbeatRunEvents.message,
|
||||
payload: heartbeatRunEvents.payload,
|
||||
})
|
||||
.from(heartbeatRunEvents)
|
||||
.where(eq(heartbeatRunEvents.runId, scheduled.run.id))
|
||||
.orderBy(sql`${heartbeatRunEvents.id} desc`)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(event?.message).toContain("active subtree pause hold");
|
||||
expect(event?.payload).toMatchObject({ issueId });
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,10 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
agentWakeupRequests,
|
||||
approvals,
|
||||
companies,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
issueApprovals,
|
||||
issueRelations,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
@@ -42,9 +39,6 @@ describeEmbeddedPostgres("issue blocker attention", () => {
|
||||
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);
|
||||
@@ -121,56 +115,6 @@ describeEmbeddedPostgres("issue blocker attention", () => {
|
||||
return runId;
|
||||
}
|
||||
|
||||
async function pendingInteraction(input: {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
kind?: "request_confirmation" | "ask_user_questions";
|
||||
agentId?: string | null;
|
||||
userId?: string | null;
|
||||
createdAt?: Date;
|
||||
}) {
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
companyId: input.companyId,
|
||||
issueId: input.issueId,
|
||||
kind: input.kind ?? "request_confirmation",
|
||||
status: "pending",
|
||||
createdByAgentId: input.agentId ?? null,
|
||||
createdByUserId: input.userId ?? null,
|
||||
createdAt: input.createdAt,
|
||||
updatedAt: input.createdAt,
|
||||
payload: {},
|
||||
});
|
||||
}
|
||||
|
||||
async function linkedPendingApproval(input: {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
agentId: string;
|
||||
userId?: string | null;
|
||||
createdAt?: Date;
|
||||
}) {
|
||||
const approvalId = randomUUID();
|
||||
await db.insert(approvals).values({
|
||||
id: approvalId,
|
||||
companyId: input.companyId,
|
||||
type: "request_board_approval",
|
||||
requestedByAgentId: input.userId ? null : input.agentId,
|
||||
requestedByUserId: input.userId ?? null,
|
||||
status: "pending",
|
||||
payload: {},
|
||||
createdAt: input.createdAt,
|
||||
updatedAt: input.createdAt,
|
||||
});
|
||||
await db.insert(issueApprovals).values({
|
||||
companyId: input.companyId,
|
||||
issueId: input.issueId,
|
||||
approvalId,
|
||||
linkedByAgentId: input.agentId,
|
||||
linkedByUserId: input.userId ?? null,
|
||||
createdAt: input.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
it("classifies a blocked parent as covered when its child has a running execution path", async () => {
|
||||
const { companyId, agentId } = await createCompany("PBC");
|
||||
const parentId = await insertIssue({ companyId, identifier: "PBC-1", title: "Parent", status: "blocked" });
|
||||
@@ -412,212 +356,6 @@ describeEmbeddedPostgres("issue blocker attention", () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function recordedRun(input: {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
issueId: string;
|
||||
livenessState?: string | null;
|
||||
continuationAttempt?: number;
|
||||
}) {
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId: input.companyId,
|
||||
agentId: input.agentId,
|
||||
status: "succeeded",
|
||||
contextSnapshot: { issueId: input.issueId },
|
||||
livenessState: (input.livenessState ?? "advanced") as never,
|
||||
continuationAttempt: input.continuationAttempt ?? 0,
|
||||
});
|
||||
return runId;
|
||||
}
|
||||
|
||||
it("classifies a chain whose leaf had a productive run that exited without a continuation as recovery_needed/productive_run_stopped", async () => {
|
||||
const { companyId, agentId } = await createCompany("PRR");
|
||||
const parentId = await insertIssue({ companyId, identifier: "PRR-1", title: "Parent", status: "blocked" });
|
||||
const leafId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "PRR-2",
|
||||
title: "Recovery leaf",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
await block({ companyId, blockerIssueId: leafId, blockedIssueId: parentId });
|
||||
await recordedRun({ companyId, agentId, issueId: leafId, livenessState: "advanced" });
|
||||
|
||||
const issuesList = await svc.list(companyId, { status: "blocked,in_progress" });
|
||||
const parent = issuesList.find((issue) => issue.id === parentId);
|
||||
const leaf = issuesList.find((issue) => issue.id === leafId);
|
||||
|
||||
expect(parent?.blockerAttention).toMatchObject({
|
||||
state: "recovery_needed",
|
||||
reason: "productive_run_stopped",
|
||||
sampleBlockerIdentifier: "PRR-2",
|
||||
nextActionHint: "wake_to_continue",
|
||||
});
|
||||
expect(parent?.blockerAttention?.nextActionOwner).toMatchObject({ type: "agent", agentId });
|
||||
|
||||
// Leaf surface gets the same recovery_needed classification on its own
|
||||
// blockerAttention so the leaf-perspective notice can render.
|
||||
expect(leaf?.blockerAttention).toMatchObject({
|
||||
state: "recovery_needed",
|
||||
reason: "productive_run_stopped",
|
||||
nextActionHint: "wake_to_continue",
|
||||
});
|
||||
expect(leaf?.blockerAttention?.nextActionOwner).toMatchObject({ type: "agent", agentId });
|
||||
});
|
||||
|
||||
it("treats a pending plan confirmation on an in-progress leaf as explicit waiting instead of recovery", async () => {
|
||||
const { companyId, agentId } = await createCompany("PRW");
|
||||
const parentId = await insertIssue({ companyId, identifier: "PRW-1", title: "Parent", status: "blocked" });
|
||||
const leafId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "PRW-2",
|
||||
title: "Plan confirmation leaf",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
await block({ companyId, blockerIssueId: leafId, blockedIssueId: parentId });
|
||||
await recordedRun({ companyId, agentId, issueId: leafId, livenessState: "advanced" });
|
||||
await pendingInteraction({ companyId, issueId: leafId, kind: "request_confirmation" });
|
||||
|
||||
const issuesList = await svc.list(companyId, { status: "blocked,in_progress" });
|
||||
const parent = issuesList.find((issue) => issue.id === parentId);
|
||||
const leaf = issuesList.find((issue) => issue.id === leafId);
|
||||
|
||||
expect(parent?.blockerAttention).toMatchObject({
|
||||
state: "covered",
|
||||
reason: "explicit_waiting",
|
||||
coveredBlockerCount: 1,
|
||||
sampleBlockerIdentifier: "PRW-2",
|
||||
nextActionHint: "needs_human_review",
|
||||
});
|
||||
expect(parent?.blockerAttention?.nextActionOwner).toMatchObject({ type: "user" });
|
||||
expect(leaf?.blockerAttention).toMatchObject({
|
||||
state: "covered",
|
||||
reason: "explicit_waiting",
|
||||
sampleBlockerIdentifier: "PRW-2",
|
||||
nextActionHint: "needs_human_review",
|
||||
});
|
||||
expect(leaf?.blockerAttention?.nextActionOwner).toMatchObject({ type: "user" });
|
||||
});
|
||||
|
||||
it("treats linked pending approvals on blocked leaves as explicit waiting", async () => {
|
||||
const { companyId, agentId } = await createCompany("PRA");
|
||||
const parentId = await insertIssue({ companyId, identifier: "PRA-1", title: "Parent", status: "blocked" });
|
||||
const leafId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "PRA-2",
|
||||
title: "Approval leaf",
|
||||
status: "blocked",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
await block({ companyId, blockerIssueId: leafId, blockedIssueId: parentId });
|
||||
await linkedPendingApproval({ companyId, issueId: leafId, agentId });
|
||||
|
||||
const parent = (await svc.list(companyId, { status: "blocked" })).find((issue) => issue.id === parentId);
|
||||
|
||||
expect(parent?.blockerAttention).toMatchObject({
|
||||
state: "covered",
|
||||
reason: "explicit_waiting",
|
||||
coveredBlockerCount: 1,
|
||||
sampleBlockerIdentifier: "PRA-2",
|
||||
nextActionHint: "needs_human_review",
|
||||
});
|
||||
expect(parent?.blockerAttention?.nextActionOwner).toMatchObject({ type: "user" });
|
||||
});
|
||||
|
||||
it("does not let stale agent-authored confirmations suppress recovery attention", async () => {
|
||||
const { companyId, agentId } = await createCompany("PRS");
|
||||
const parentId = await insertIssue({ companyId, identifier: "PRS-1", title: "Parent", status: "blocked" });
|
||||
const leafId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "PRS-2",
|
||||
title: "Stale confirmation leaf",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
await block({ companyId, blockerIssueId: leafId, blockedIssueId: parentId });
|
||||
await recordedRun({ companyId, agentId, issueId: leafId, livenessState: "advanced" });
|
||||
await pendingInteraction({
|
||||
companyId,
|
||||
issueId: leafId,
|
||||
kind: "request_confirmation",
|
||||
agentId,
|
||||
createdAt: new Date(Date.now() - 25 * 60 * 60 * 1000),
|
||||
});
|
||||
|
||||
const issuesList = await svc.list(companyId, { status: "blocked,in_progress" });
|
||||
const parent = issuesList.find((issue) => issue.id === parentId);
|
||||
const leaf = issuesList.find((issue) => issue.id === leafId);
|
||||
|
||||
expect(parent?.blockerAttention).toMatchObject({
|
||||
state: "recovery_needed",
|
||||
reason: "productive_run_stopped",
|
||||
sampleBlockerIdentifier: "PRS-2",
|
||||
});
|
||||
expect(leaf?.blockerAttention).toMatchObject({
|
||||
state: "recovery_needed",
|
||||
reason: "productive_run_stopped",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let stale agent-requested approvals suppress recovery attention", async () => {
|
||||
const { companyId, agentId } = await createCompany("PRZ");
|
||||
const parentId = await insertIssue({ companyId, identifier: "PRZ-1", title: "Parent", status: "blocked" });
|
||||
const leafId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "PRZ-2",
|
||||
title: "Stale approval leaf",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
await block({ companyId, blockerIssueId: leafId, blockedIssueId: parentId });
|
||||
await recordedRun({ companyId, agentId, issueId: leafId, livenessState: "advanced" });
|
||||
await linkedPendingApproval({
|
||||
companyId,
|
||||
issueId: leafId,
|
||||
agentId,
|
||||
createdAt: new Date(Date.now() - 25 * 60 * 60 * 1000),
|
||||
});
|
||||
|
||||
const issuesList = await svc.list(companyId, { status: "blocked,in_progress" });
|
||||
const parent = issuesList.find((issue) => issue.id === parentId);
|
||||
const leaf = issuesList.find((issue) => issue.id === leafId);
|
||||
|
||||
expect(parent?.blockerAttention).toMatchObject({
|
||||
state: "recovery_needed",
|
||||
reason: "productive_run_stopped",
|
||||
sampleBlockerIdentifier: "PRZ-2",
|
||||
});
|
||||
expect(leaf?.blockerAttention).toMatchObject({
|
||||
state: "recovery_needed",
|
||||
reason: "productive_run_stopped",
|
||||
});
|
||||
});
|
||||
|
||||
it("escalates to continuation_exhausted when the latest run already used its bounded continuations", async () => {
|
||||
const { companyId, agentId } = await createCompany("PRX");
|
||||
const parentId = await insertIssue({ companyId, identifier: "PRX-1", title: "Parent", status: "blocked" });
|
||||
const leafId = await insertIssue({
|
||||
companyId,
|
||||
identifier: "PRX-2",
|
||||
title: "Exhausted leaf",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
await block({ companyId, blockerIssueId: leafId, blockedIssueId: parentId });
|
||||
await recordedRun({ companyId, agentId, issueId: leafId, livenessState: "advanced", continuationAttempt: 2 });
|
||||
|
||||
const parent = (await svc.list(companyId, { status: "blocked" })).find((issue) => issue.id === parentId);
|
||||
expect(parent?.blockerAttention).toMatchObject({
|
||||
state: "recovery_needed",
|
||||
reason: "continuation_exhausted",
|
||||
sampleBlockerIdentifier: "PRX-2",
|
||||
nextActionHint: "create_recovery_issue",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat a scheduled retry as actively covered work", async () => {
|
||||
const { companyId, agentId } = await createCompany("PBY");
|
||||
const parentId = await insertIssue({ companyId, identifier: "PBY-1", title: "Parent", status: "blocked" });
|
||||
|
||||
@@ -365,23 +365,12 @@ describe("issue graph liveness classifier", () => {
|
||||
{
|
||||
name: "pending interaction",
|
||||
issue: baseReviewIssue,
|
||||
pendingInteractions: [{
|
||||
companyId,
|
||||
issueId: reviewIssueId,
|
||||
status: "pending",
|
||||
createdAt: new Date(),
|
||||
}],
|
||||
pendingInteractions: [{ companyId, issueId: reviewIssueId, status: "pending" }],
|
||||
},
|
||||
{
|
||||
name: "pending approval",
|
||||
issue: baseReviewIssue,
|
||||
pendingApprovals: [{
|
||||
companyId,
|
||||
issueId: reviewIssueId,
|
||||
status: "pending",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}],
|
||||
pendingApprovals: [{ companyId, issueId: reviewIssueId, status: "pending" }],
|
||||
},
|
||||
{
|
||||
name: "open recovery issue",
|
||||
|
||||
@@ -49,13 +49,10 @@ function registerModuleMocks() {
|
||||
})),
|
||||
}),
|
||||
clampIssueListLimit: (value: number) => value,
|
||||
companyService: () => ({}),
|
||||
ISSUE_LIST_DEFAULT_LIMIT: 500,
|
||||
ISSUE_LIST_MAX_LIMIT: 1000,
|
||||
documentService: () => ({}),
|
||||
environmentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
executionWorkspaceServiceDirect: () => ({}),
|
||||
feedbackService: () => ({
|
||||
listIssueVotesForUser: vi.fn(async () => []),
|
||||
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
|
||||
@@ -573,108 +570,6 @@ describe.sequential("issue thread interaction routes", () => {
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("wakes the current assignee when a rejected confirmation uses wake-assignee continuation", async () => {
|
||||
mockInteractionService.rejectInteraction.mockResolvedValueOnce({
|
||||
id: "interaction-7",
|
||||
companyId: "company-1",
|
||||
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
kind: "request_confirmation",
|
||||
status: "rejected",
|
||||
continuationPolicy: "wake_assignee",
|
||||
idempotencyKey: null,
|
||||
sourceCommentId: null,
|
||||
sourceRunId: "run-7",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Apply this plan?",
|
||||
},
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "rejected",
|
||||
reason: "Needs changes",
|
||||
},
|
||||
createdAt: "2026-04-20T12:00:00.000Z",
|
||||
updatedAt: "2026-04-20T12:05:00.000Z",
|
||||
resolvedAt: "2026-04-20T12:05:00.000Z",
|
||||
});
|
||||
const app = await createApp();
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-7/reject")
|
||||
.send({ reason: "Needs changes" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||
ASSIGNEE_AGENT_ID,
|
||||
expect.objectContaining({
|
||||
reason: "issue_commented",
|
||||
payload: expect.objectContaining({
|
||||
interactionId: "interaction-7",
|
||||
interactionKind: "request_confirmation",
|
||||
interactionStatus: "rejected",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("wakes the current assignee when an accepted confirmation expires because its target is stale", async () => {
|
||||
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
|
||||
interaction: {
|
||||
id: "interaction-6",
|
||||
companyId: "company-1",
|
||||
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
kind: "request_confirmation",
|
||||
status: "expired",
|
||||
continuationPolicy: "wake_assignee_on_accept",
|
||||
idempotencyKey: null,
|
||||
sourceCommentId: null,
|
||||
sourceRunId: "run-6",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve this plan?",
|
||||
target: {
|
||||
type: "issue_document",
|
||||
key: "plan",
|
||||
revisionId: "old-revision",
|
||||
},
|
||||
},
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "stale_target",
|
||||
staleTarget: {
|
||||
type: "issue_document",
|
||||
key: "plan",
|
||||
revisionId: "old-revision",
|
||||
},
|
||||
},
|
||||
createdAt: "2026-04-20T12:00:00.000Z",
|
||||
updatedAt: "2026-04-20T12:05:00.000Z",
|
||||
resolvedAt: "2026-04-20T12:05:00.000Z",
|
||||
},
|
||||
createdIssues: [],
|
||||
});
|
||||
const app = await createApp();
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-6/accept")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||
ASSIGNEE_AGENT_ID,
|
||||
expect.objectContaining({
|
||||
reason: "issue_commented",
|
||||
payload: expect.objectContaining({
|
||||
interactionId: "interaction-6",
|
||||
interactionKind: "request_confirmation",
|
||||
interactionStatus: "expired",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not emit an accept-only continuation wake for rejected suggested tasks", async () => {
|
||||
mockInteractionService.rejectInteraction.mockResolvedValueOnce({
|
||||
id: "interaction-5",
|
||||
|
||||
@@ -768,206 +768,6 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("moves agent-authored plan confirmations into review while preserving the return assignee", async () => {
|
||||
const companyId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const documentId = randomUUID();
|
||||
const revisionId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
||||
await db.insert(goals).values({
|
||||
id: goalId,
|
||||
companyId,
|
||||
title: "Confirm a plan",
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
title: "Plan the implementation",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
await db.insert(documents).values({
|
||||
id: documentId,
|
||||
companyId,
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
latestBody: "v1",
|
||||
latestRevisionId: revisionId,
|
||||
latestRevisionNumber: 1,
|
||||
});
|
||||
await db.insert(issueDocuments).values({
|
||||
companyId,
|
||||
issueId,
|
||||
documentId,
|
||||
key: "plan",
|
||||
});
|
||||
await db.insert(documentRevisions).values({
|
||||
id: revisionId,
|
||||
companyId,
|
||||
documentId,
|
||||
revisionNumber: 1,
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
body: "v1",
|
||||
});
|
||||
|
||||
const created = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "request_confirmation",
|
||||
idempotencyKey: `confirmation:${issueId}:plan:${revisionId}`,
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve this plan?",
|
||||
target: {
|
||||
type: "issue_document",
|
||||
issueId,
|
||||
documentId,
|
||||
key: "plan",
|
||||
revisionId,
|
||||
revisionNumber: 1,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
agentId,
|
||||
});
|
||||
|
||||
expect(created).toMatchObject({
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
});
|
||||
|
||||
const [reviewIssue] = await db.select().from(issues);
|
||||
expect(reviewIssue).toMatchObject({
|
||||
id: issueId,
|
||||
status: "in_review",
|
||||
assigneeAgentId: agentId,
|
||||
assigneeUserId: null,
|
||||
});
|
||||
|
||||
const idempotent = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "request_confirmation",
|
||||
idempotencyKey: `confirmation:${issueId}:plan:${revisionId}`,
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve this plan?",
|
||||
target: {
|
||||
type: "issue_document",
|
||||
issueId,
|
||||
documentId,
|
||||
key: "plan",
|
||||
revisionId,
|
||||
revisionNumber: 1,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
agentId,
|
||||
});
|
||||
|
||||
expect(idempotent.id).toBe(created.id);
|
||||
expect(await db.select().from(issueThreadInteractions)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("moves agent-authored questions into review while preserving the return assignee", async () => {
|
||||
const companyId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
|
||||
await db.insert(goals).values({
|
||||
id: goalId,
|
||||
companyId,
|
||||
title: "Ask a question",
|
||||
level: "task",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
goalId,
|
||||
title: "Clarify the implementation",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
});
|
||||
|
||||
await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "ask_user_questions",
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
version: 1,
|
||||
questions: [{
|
||||
id: "scope",
|
||||
prompt: "Which scope?",
|
||||
selectionMode: "single",
|
||||
options: [{ id: "minimal", label: "Minimal" }],
|
||||
}],
|
||||
},
|
||||
}, {
|
||||
agentId,
|
||||
});
|
||||
|
||||
const [reviewIssue] = await db.select().from(issues);
|
||||
expect(reviewIssue).toMatchObject({
|
||||
id: issueId,
|
||||
status: "in_review",
|
||||
assigneeAgentId: agentId,
|
||||
assigneeUserId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("expires supersedable request confirmations when a user comments", async () => {
|
||||
const companyId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
|
||||
@@ -106,7 +106,7 @@ describe("run liveness continuations", () => {
|
||||
expect(decision.nextAttempt).toBe(2);
|
||||
});
|
||||
|
||||
it("treats an advanced terminal run as progress evidence, not a live continuation path", () => {
|
||||
it.fails("treats an advanced terminal run as progress evidence, not a live continuation path", () => {
|
||||
const decision = decideRunLivenessContinuation({
|
||||
run: run(),
|
||||
issue: issue(),
|
||||
|
||||
@@ -58,57 +58,6 @@ describe("run liveness classifier", () => {
|
||||
expect(classification.lastUsefulActionAt).toBe(latestEvidenceAt);
|
||||
});
|
||||
|
||||
it("preserves runnable next actions when concrete progress still leaves non-terminal work", () => {
|
||||
const classification = classifyRunLiveness({
|
||||
...baseInput,
|
||||
resultJson: {
|
||||
summary: "Updated the service and tests.\nNext action: run pnpm vitest server/src/__tests__/run-liveness.test.ts.",
|
||||
},
|
||||
evidence: {
|
||||
issueCommentsCreated: 1,
|
||||
latestEvidenceAt: new Date("2026-04-18T12:00:00Z"),
|
||||
},
|
||||
});
|
||||
|
||||
expect(classification.livenessState).toBe("advanced");
|
||||
expect(classification.actionability).toBe("runnable");
|
||||
expect(classification.nextAction).toBe("run pnpm vitest server/src/__tests__/run-liveness.test.ts.");
|
||||
});
|
||||
|
||||
it("routes concrete progress with manager-review next action to visible follow-up", () => {
|
||||
const classification = classifyRunLiveness({
|
||||
...baseInput,
|
||||
resultJson: {
|
||||
summary: "Prepared the release notes.\nNext action: deploy to production and verify live traffic.",
|
||||
},
|
||||
evidence: {
|
||||
issueCommentsCreated: 1,
|
||||
latestEvidenceAt: new Date("2026-04-18T12:00:00Z"),
|
||||
},
|
||||
});
|
||||
|
||||
expect(classification.livenessState).toBe("needs_followup");
|
||||
expect(classification.actionability).toBe("manager_review");
|
||||
expect(classification.nextAction).toBe("deploy to production and verify live traffic.");
|
||||
});
|
||||
|
||||
it("routes concrete progress with ambiguous next action to visible follow-up", () => {
|
||||
const classification = classifyRunLiveness({
|
||||
...baseInput,
|
||||
resultJson: {
|
||||
summary: "Captured the current state.\nNext action: decide what to do with the remaining uncertainty.",
|
||||
},
|
||||
evidence: {
|
||||
issueCommentsCreated: 1,
|
||||
latestEvidenceAt: new Date("2026-04-18T12:00:00Z"),
|
||||
},
|
||||
});
|
||||
|
||||
expect(classification.livenessState).toBe("needs_followup");
|
||||
expect(classification.actionability).toBe("unknown");
|
||||
expect(classification.nextAction).toBe("decide what to do with the remaining uncertainty.");
|
||||
});
|
||||
|
||||
it("does not treat workspace operations alone as concrete progress", () => {
|
||||
const classification = classifyRunLiveness({
|
||||
...baseInput,
|
||||
@@ -157,22 +106,6 @@ describe("run liveness classifier", () => {
|
||||
expect(classification.livenessState).toBe("advanced");
|
||||
});
|
||||
|
||||
it("keeps in-review work on the explicit waiting path", () => {
|
||||
const classification = classifyRunLiveness({
|
||||
...baseInput,
|
||||
issue: {
|
||||
...baseInput.issue,
|
||||
status: "in_review",
|
||||
},
|
||||
resultJson: {
|
||||
summary: "Next action: wait for manager review.",
|
||||
},
|
||||
});
|
||||
|
||||
expect(classification.livenessState).toBe("needs_followup");
|
||||
expect(classification.nextAction).toBe("wait for manager review.");
|
||||
});
|
||||
|
||||
it("classifies done issues as completed", () => {
|
||||
const classification = classifyRunLiveness({
|
||||
...baseInput,
|
||||
|
||||
@@ -240,10 +240,6 @@ export function approvalRoutes(
|
||||
const { approval, applied } = await svc.reject(id, decidedByUserId, req.body.decisionNote);
|
||||
|
||||
if (applied) {
|
||||
const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id);
|
||||
const linkedIssueIds = linkedIssues.map((issue) => issue.id);
|
||||
const primaryIssueId = linkedIssueIds[0] ?? null;
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: approval.companyId,
|
||||
actorType: "user",
|
||||
@@ -251,75 +247,8 @@ export function approvalRoutes(
|
||||
action: "approval.rejected",
|
||||
entityType: "approval",
|
||||
entityId: approval.id,
|
||||
details: {
|
||||
type: approval.type,
|
||||
requestedByAgentId: approval.requestedByAgentId,
|
||||
linkedIssueIds,
|
||||
},
|
||||
details: { type: approval.type },
|
||||
});
|
||||
|
||||
if (approval.requestedByAgentId) {
|
||||
try {
|
||||
const wakeRun = await heartbeat.wakeup(approval.requestedByAgentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "approval_rejected",
|
||||
payload: {
|
||||
approvalId: approval.id,
|
||||
approvalStatus: approval.status,
|
||||
issueId: primaryIssueId,
|
||||
issueIds: linkedIssueIds,
|
||||
},
|
||||
requestedByActorType: "user",
|
||||
requestedByActorId: req.actor.userId ?? "board",
|
||||
contextSnapshot: {
|
||||
source: "approval.rejected",
|
||||
approvalId: approval.id,
|
||||
approvalStatus: approval.status,
|
||||
issueId: primaryIssueId,
|
||||
issueIds: linkedIssueIds,
|
||||
taskId: primaryIssueId,
|
||||
wakeReason: "approval_rejected",
|
||||
},
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: approval.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "approval.requester_wakeup_queued",
|
||||
entityType: "approval",
|
||||
entityId: approval.id,
|
||||
details: {
|
||||
requesterAgentId: approval.requestedByAgentId,
|
||||
wakeRunId: wakeRun?.id ?? null,
|
||||
linkedIssueIds,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{
|
||||
err,
|
||||
approvalId: approval.id,
|
||||
requestedByAgentId: approval.requestedByAgentId,
|
||||
},
|
||||
"failed to queue requester wakeup after approval rejection",
|
||||
);
|
||||
await logActivity(db, {
|
||||
companyId: approval.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "approval.requester_wakeup_failed",
|
||||
entityType: "approval",
|
||||
entityId: approval.id,
|
||||
details: {
|
||||
requesterAgentId: approval.requestedByAgentId,
|
||||
linkedIssueIds,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json(redactApprovalPayload(approval));
|
||||
|
||||
@@ -208,13 +208,6 @@ function isExplicitResumeCapableStatus(status: string | null | undefined) {
|
||||
return status === "done" || status === "blocked" || status === "todo" || status === "in_progress";
|
||||
}
|
||||
|
||||
function getRequestConfirmationResolutionOutcome(interaction: { kind: string; result?: unknown }) {
|
||||
if (interaction.kind !== "request_confirmation") return null;
|
||||
if (typeof interaction.result !== "object" || interaction.result === null) return null;
|
||||
const outcome = (interaction.result as { outcome?: unknown }).outcome;
|
||||
return typeof outcome === "string" ? outcome : null;
|
||||
}
|
||||
|
||||
function queueResolvedInteractionContinuationWakeup(input: {
|
||||
heartbeat: ReturnType<typeof heartbeatService>;
|
||||
issue: { id: string; assigneeAgentId: string | null; status: string };
|
||||
@@ -225,7 +218,6 @@ function queueResolvedInteractionContinuationWakeup(input: {
|
||||
continuationPolicy: string;
|
||||
sourceCommentId?: string | null;
|
||||
sourceRunId?: string | null;
|
||||
result?: unknown;
|
||||
};
|
||||
actor: { actorType: "user" | "agent"; actorId: string };
|
||||
source: string;
|
||||
@@ -237,18 +229,8 @@ function queueResolvedInteractionContinuationWakeup(input: {
|
||||
if (
|
||||
input.interaction.continuationPolicy === "wake_assignee_on_accept"
|
||||
&& input.interaction.status !== "accepted"
|
||||
&& !(input.interaction.status === "expired" && getRequestConfirmationResolutionOutcome(input.interaction) === "stale_target")
|
||||
) return;
|
||||
if (
|
||||
input.interaction.status === "expired"
|
||||
&& !(
|
||||
getRequestConfirmationResolutionOutcome(input.interaction) === "stale_target"
|
||||
|| (
|
||||
input.interaction.continuationPolicy === "wake_assignee"
|
||||
&& getRequestConfirmationResolutionOutcome(input.interaction) === "superseded_by_comment"
|
||||
)
|
||||
)
|
||||
) return;
|
||||
if (input.interaction.status === "expired") return;
|
||||
if (!input.issue.assigneeAgentId || isClosedIssueStatus(input.issue.status)) return;
|
||||
|
||||
void input.heartbeat.wakeup(input.issue.assigneeAgentId, {
|
||||
@@ -3060,41 +3042,6 @@ export function issueRoutes(
|
||||
},
|
||||
});
|
||||
|
||||
const refreshedIssue = await svc.getById(id);
|
||||
if (
|
||||
refreshedIssue
|
||||
&& (
|
||||
refreshedIssue.status !== issue.status
|
||||
|| refreshedIssue.assigneeAgentId !== issue.assigneeAgentId
|
||||
|| refreshedIssue.assigneeUserId !== issue.assigneeUserId
|
||||
)
|
||||
) {
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
identifier: issue.identifier,
|
||||
status: refreshedIssue.status,
|
||||
assigneeAgentId: refreshedIssue.assigneeAgentId ?? null,
|
||||
assigneeUserId: refreshedIssue.assigneeUserId ?? null,
|
||||
source: "issue.thread_interaction_created",
|
||||
interactionId: interaction.id,
|
||||
interactionKind: interaction.kind,
|
||||
_previous: {
|
||||
status: issue.status,
|
||||
assigneeAgentId: issue.assigneeAgentId ?? null,
|
||||
assigneeUserId: issue.assigneeUserId ?? null,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json(interaction);
|
||||
});
|
||||
|
||||
|
||||
@@ -26,11 +26,8 @@ import {
|
||||
issueDocuments,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
approvals,
|
||||
issueApprovals,
|
||||
issueComments,
|
||||
issueRelations,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
issueWorkProducts,
|
||||
projects,
|
||||
@@ -80,7 +77,6 @@ import {
|
||||
sanitizeRuntimeServiceBaseEnv,
|
||||
} from "./workspace-runtime.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { issueThreadInteractionService } from "./issue-thread-interactions.js";
|
||||
import { parseIssueExecutionState } from "./issue-execution-policy.js";
|
||||
import {
|
||||
ISSUE_TREE_CONTROL_INTERACTION_WAKE_REASONS,
|
||||
@@ -113,10 +109,6 @@ import {
|
||||
readContinuationAttempt,
|
||||
} from "./recovery/index.js";
|
||||
import { isAutomaticRecoverySuppressedByPauseHold } from "./recovery/pause-hold-guard.js";
|
||||
import {
|
||||
isLiveExplicitApprovalWaitingPath,
|
||||
isLiveExplicitInteractionWaitingPath,
|
||||
} from "./recovery/explicit-waiting-paths.js";
|
||||
import { recoveryService } from "./recovery/service.js";
|
||||
import { productivityReviewService } from "./productivity-review.js";
|
||||
import { withAgentStartLock } from "./agent-start-lock.js";
|
||||
@@ -136,8 +128,6 @@ import { environmentRuntimeService } from "./environment-runtime.js";
|
||||
import { environmentRunOrchestrator } from "./environment-run-orchestrator.js";
|
||||
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
|
||||
const EXPLICIT_WAITING_INTERACTION_KINDS = ["request_confirmation", "ask_user_questions"];
|
||||
|
||||
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
||||
const MAX_PERSISTED_LOG_CHUNK_CHARS = 64 * 1024;
|
||||
const MAX_RUN_EVENT_PAYLOAD_STRING_CHARS = 16 * 1024;
|
||||
@@ -2778,68 +2768,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
});
|
||||
}
|
||||
|
||||
async function hasExplicitIssueWait(issue: {
|
||||
companyId: string;
|
||||
id: string;
|
||||
assigneeUserId?: string | null;
|
||||
executionState?: Record<string, unknown> | null;
|
||||
}) {
|
||||
await issueThreadInteractionService(db).expireInvalidPendingRequestConfirmationsForIssue(
|
||||
{ id: issue.id, companyId: issue.companyId },
|
||||
{ agentId: null, userId: null },
|
||||
);
|
||||
|
||||
const [interactionRows, approvalRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
issueId: issueThreadInteractions.issueId,
|
||||
companyId: issueThreadInteractions.companyId,
|
||||
status: issueThreadInteractions.status,
|
||||
createdByUserId: issueThreadInteractions.createdByUserId,
|
||||
createdAt: issueThreadInteractions.createdAt,
|
||||
})
|
||||
.from(issueThreadInteractions)
|
||||
.where(
|
||||
and(
|
||||
eq(issueThreadInteractions.companyId, issue.companyId),
|
||||
eq(issueThreadInteractions.issueId, issue.id),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
inArray(issueThreadInteractions.kind, EXPLICIT_WAITING_INTERACTION_KINDS),
|
||||
),
|
||||
)
|
||||
.limit(5),
|
||||
db
|
||||
.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", "revision_requested"]),
|
||||
),
|
||||
)
|
||||
.limit(5),
|
||||
]);
|
||||
|
||||
return interactionRows.some((row) => isLiveExplicitInteractionWaitingPath(issue, row)) ||
|
||||
approvalRows.some((row) => isLiveExplicitApprovalWaitingPath(issue, row));
|
||||
}
|
||||
|
||||
async function handleRunLivenessContinuation(run: typeof heartbeatRuns.$inferSelect) {
|
||||
const livenessState = run.livenessState as RunLivenessState | null;
|
||||
if (!livenessState) return;
|
||||
const hasProgressFollowup = livenessState === "advanced" && Boolean(run.nextAction?.trim());
|
||||
if (livenessState !== "plan_only" && livenessState !== "empty_response" && !hasProgressFollowup) return;
|
||||
if (livenessState !== "plan_only" && livenessState !== "empty_response") return;
|
||||
|
||||
const context = parseObject(run.contextSnapshot);
|
||||
const issueId = readNonEmptyString(context.issueId);
|
||||
@@ -2854,7 +2785,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
executionState: issues.executionState,
|
||||
projectId: issues.projectId,
|
||||
})
|
||||
@@ -2880,14 +2810,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
})
|
||||
: null;
|
||||
if (issue) {
|
||||
if (await hasExplicitIssueWait(issue)) {
|
||||
await setRunStatus(run.id, run.status, {
|
||||
livenessReason:
|
||||
`${run.livenessReason ?? "Run ended without concrete progress"}; continuation suppressed by explicit interaction or approval wait`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const productivityHold = await productivityReviews.isProductivityReviewContinuationHoldActive({
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
@@ -2909,14 +2831,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (await isAutomaticRecoverySuppressedByPauseHold(db, issue.companyId, issue.id, treeControlSvc)) {
|
||||
await setRunStatus(run.id, run.status, {
|
||||
livenessReason:
|
||||
`${run.livenessReason ?? "Run ended without concrete progress"}; continuation suppressed by active pause hold`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const nextAttempt = readContinuationAttempt(run.continuationAttempt) + 1;
|
||||
@@ -3741,88 +3655,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const activePauseHold = await treeControlSvc.getActivePauseHoldGate(dueRun.companyId, dueRunIssueId);
|
||||
if (activePauseHold) {
|
||||
const reason = "Cancelled because issue is held by an active subtree pause hold";
|
||||
const cancelled = await db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
finishedAt: now,
|
||||
error: reason,
|
||||
errorCode: "issue_paused",
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.id, dueRun.id),
|
||||
eq(heartbeatRuns.status, "scheduled_retry"),
|
||||
lte(heartbeatRuns.scheduledRetryAt, now),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!cancelled) continue;
|
||||
|
||||
if (cancelled.wakeupRequestId) {
|
||||
await db
|
||||
.update(agentWakeupRequests)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
finishedAt: now,
|
||||
error: reason,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(agentWakeupRequests.id, cancelled.wakeupRequestId));
|
||||
}
|
||||
|
||||
if (issue && issue.executionRunId === cancelled.id) {
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(issues.id, issue.id), eq(issues.executionRunId, cancelled.id)));
|
||||
}
|
||||
|
||||
await appendRunEvent(cancelled, await nextRunEventSeq(cancelled.id), {
|
||||
eventType: "lifecycle",
|
||||
stream: "system",
|
||||
level: "warn",
|
||||
message: "Scheduled retry cancelled because issue is held by an active subtree pause hold",
|
||||
payload: {
|
||||
issueId: dueRunIssueId,
|
||||
holdId: activePauseHold.holdId,
|
||||
rootIssueId: activePauseHold.rootIssueId,
|
||||
scheduledRetryAttempt: cancelled.scheduledRetryAttempt,
|
||||
scheduledRetryAt: cancelled.scheduledRetryAt ? new Date(cancelled.scheduledRetryAt).toISOString() : null,
|
||||
scheduledRetryReason: cancelled.scheduledRetryReason,
|
||||
},
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: cancelled.companyId,
|
||||
actorType: "system",
|
||||
actorId: "system",
|
||||
agentId: cancelled.agentId,
|
||||
runId: cancelled.id,
|
||||
action: "issue.tree_hold_run_interrupted",
|
||||
entityType: "heartbeat_run",
|
||||
entityId: cancelled.id,
|
||||
details: {
|
||||
issueId: dueRunIssueId,
|
||||
holdId: activePauseHold.holdId,
|
||||
rootIssueId: activePauseHold.rootIssueId,
|
||||
source: "heartbeat.promote_due_scheduled_retries",
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const promoted = await db
|
||||
@@ -7663,8 +7495,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
|
||||
reapOrphanedRuns,
|
||||
|
||||
handleRunLivenessContinuationForTest: handleRunLivenessContinuation,
|
||||
|
||||
promoteDueScheduledRetries,
|
||||
|
||||
resumeQueuedRuns,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { and, asc, eq, gt, inArray, sql } from "drizzle-orm";
|
||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
documents,
|
||||
@@ -68,45 +68,6 @@ type IssueResolutionContext = {
|
||||
assigneeUserId: string | null;
|
||||
};
|
||||
|
||||
function isAgentAuthoredWaitingInteraction(input: CreateIssueThreadInteraction, actor: InteractionActor) {
|
||||
return Boolean(actor.agentId) && (input.kind === "request_confirmation" || input.kind === "ask_user_questions");
|
||||
}
|
||||
|
||||
async function moveIssueToReviewForAgentWaitingInteraction(
|
||||
db: Db,
|
||||
issue: { id: string; companyId: string },
|
||||
input: CreateIssueThreadInteraction,
|
||||
actor: InteractionActor,
|
||||
) {
|
||||
if (!actor.agentId || !isAgentAuthoredWaitingInteraction(input, actor)) return null;
|
||||
|
||||
const issueContext = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
status: issues.status,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issue.id))
|
||||
.then((rows: IssueResolutionContext[]) => rows[0] ?? null);
|
||||
|
||||
if (!issueContext || issueContext.companyId !== issue.companyId) {
|
||||
throw notFound("Issue not found");
|
||||
}
|
||||
if (issueContext.status !== "todo" && issueContext.status !== "in_progress") return null;
|
||||
if (issueContext.assigneeUserId) return null;
|
||||
|
||||
return issueService(db).update(issue.id, {
|
||||
status: "in_review",
|
||||
assigneeAgentId: issueContext.assigneeAgentId ?? actor.agentId,
|
||||
assigneeUserId: null,
|
||||
actorAgentId: actor.agentId ?? null,
|
||||
actorUserId: actor.userId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function isIssueThreadInteractionIdempotencyConflict(error: unknown): boolean {
|
||||
if (typeof error !== "object" || error === null) return false;
|
||||
const err = error as { code?: string; constraint?: string; constraint_name?: string };
|
||||
@@ -463,55 +424,6 @@ async function expireStaleRequestConfirmationTarget(db: Db | any, args: {
|
||||
return hydrateInteraction(updated);
|
||||
}
|
||||
|
||||
async function expireRequestConfirmationSupersededByLatestUserComment(db: Db | any, args: {
|
||||
row: IssueThreadInteractionRow;
|
||||
actor: InteractionActor;
|
||||
}): Promise<IssueThreadInteraction | null> {
|
||||
if (args.row.kind !== "request_confirmation" || args.row.status !== "pending") return null;
|
||||
const interaction = hydrateInteraction(args.row) as RequestConfirmationInteraction;
|
||||
if (interaction.payload.supersedeOnUserComment !== true) return null;
|
||||
|
||||
const [comment] = await db
|
||||
.select({ id: issueComments.id })
|
||||
.from(issueComments)
|
||||
.where(and(
|
||||
eq(issueComments.companyId, args.row.companyId),
|
||||
eq(issueComments.issueId, args.row.issueId),
|
||||
gt(issueComments.createdAt, args.row.createdAt),
|
||||
sql`${issueComments.authorUserId} is not null`,
|
||||
))
|
||||
.orderBy(asc(issueComments.createdAt))
|
||||
.limit(1);
|
||||
if (!comment) return null;
|
||||
|
||||
const now = new Date();
|
||||
const [updated] = await db
|
||||
.update(issueThreadInteractions)
|
||||
.set({
|
||||
status: "expired",
|
||||
result: {
|
||||
version: 1,
|
||||
outcome: "superseded_by_comment",
|
||||
commentId: comment.id,
|
||||
},
|
||||
resolvedByAgentId: args.actor.agentId ?? null,
|
||||
resolvedByUserId: args.actor.userId ?? null,
|
||||
resolvedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.id, args.row.id),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw conflict("Interaction has already been resolved");
|
||||
}
|
||||
await touchIssue(db, args.row.issueId);
|
||||
return hydrateInteraction(updated);
|
||||
}
|
||||
|
||||
export function issueThreadInteractionService(db: Db) {
|
||||
async function getIdempotentInteraction(args: {
|
||||
issueId: string;
|
||||
@@ -728,7 +640,6 @@ export function issueThreadInteractionService(db: Db) {
|
||||
idempotencyKey: data.idempotencyKey,
|
||||
});
|
||||
}
|
||||
await moveIssueToReviewForAgentWaitingInteraction(db, issue, data, actor);
|
||||
return hydrateInteraction(existing);
|
||||
}
|
||||
}
|
||||
@@ -806,7 +717,6 @@ export function issueThreadInteractionService(db: Db) {
|
||||
return hydrateInteraction(existing);
|
||||
}
|
||||
|
||||
await moveIssueToReviewForAgentWaitingInteraction(db, issue, data, actor);
|
||||
await touchIssue(db, issue.id);
|
||||
return hydrateInteraction(created);
|
||||
},
|
||||
@@ -1182,33 +1092,6 @@ export function issueThreadInteractionService(db: Db) {
|
||||
return expired;
|
||||
},
|
||||
|
||||
expireInvalidPendingRequestConfirmationsForIssue: async (
|
||||
issue: { id: string; companyId: string },
|
||||
actor: InteractionActor,
|
||||
) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(issueThreadInteractions)
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.companyId, issue.companyId),
|
||||
eq(issueThreadInteractions.issueId, issue.id),
|
||||
eq(issueThreadInteractions.kind, "request_confirmation"),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
));
|
||||
|
||||
const expired: IssueThreadInteraction[] = [];
|
||||
for (const row of rows) {
|
||||
const staleTarget = await expireStaleRequestConfirmationTarget(db, { row, actor });
|
||||
if (staleTarget) {
|
||||
expired.push(staleTarget);
|
||||
continue;
|
||||
}
|
||||
const superseded = await expireRequestConfirmationSupersededByLatestUserComment(db, { row, actor });
|
||||
if (superseded) expired.push(superseded);
|
||||
}
|
||||
return expired;
|
||||
},
|
||||
|
||||
answerQuestions: async (
|
||||
issue: { id: string; companyId: string },
|
||||
interactionId: string,
|
||||
|
||||
+40
-462
@@ -29,10 +29,6 @@ import {
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
IssueBlockerAttention,
|
||||
IssueBlockerAttentionReason,
|
||||
IssueBlockerAttentionState,
|
||||
IssueBlockerNextActionHint,
|
||||
IssueBlockerNextActionOwner,
|
||||
IssueProductivityReview,
|
||||
IssueProductivityReviewTrigger,
|
||||
IssueRelationIssueSummary,
|
||||
@@ -55,10 +51,6 @@ import {
|
||||
issueTreeControlService,
|
||||
type ActiveIssueTreePauseHoldGate,
|
||||
} from "./issue-tree-control.js";
|
||||
import {
|
||||
isLiveExplicitApprovalWaitingPath,
|
||||
isLiveExplicitInteractionWaitingPath,
|
||||
} from "./recovery/explicit-waiting-paths.js";
|
||||
|
||||
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
|
||||
const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500;
|
||||
@@ -678,7 +670,6 @@ const ACTIVE_RUN_STATUSES = ["queued", "running"];
|
||||
const BLOCKER_ATTENTION_ACTIVE_RUN_STATUSES = ["queued", "running"];
|
||||
const BLOCKER_ATTENTION_ACTIVE_WAKE_STATUSES = ["queued", "deferred_issue_execution"];
|
||||
const BLOCKER_ATTENTION_PENDING_INTERACTION_STATUSES = ["pending"];
|
||||
const BLOCKER_ATTENTION_WAITING_INTERACTION_KINDS = ["request_confirmation", "ask_user_questions"];
|
||||
const BLOCKER_ATTENTION_PENDING_APPROVAL_STATUSES = ["pending", "revision_requested"];
|
||||
const BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND = "harness_liveness_escalation";
|
||||
const PRODUCTIVITY_REVIEW_ORIGIN_KIND = "issue_productivity_review";
|
||||
@@ -696,18 +687,6 @@ const BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES = ["done", "cancelled"];
|
||||
const BLOCKER_ATTENTION_MAX_DEPTH = 8;
|
||||
const BLOCKER_ATTENTION_MAX_NODES = 2000;
|
||||
const BLOCKER_ATTENTION_INVOKABLE_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]);
|
||||
// Leaves whose latest issue-linked run looks "productive but stranded" — the
|
||||
// run produced concrete progress or described future work, but the issue is
|
||||
// non-terminal and no continuation/wake/interaction took ownership of the next
|
||||
// step. These leaves drive the recovery_needed surface.
|
||||
const BLOCKER_ATTENTION_RECOVERY_LIVENESS_STATES = new Set([
|
||||
"advanced",
|
||||
"needs_followup",
|
||||
"plan_only",
|
||||
"empty_response",
|
||||
]);
|
||||
const BLOCKER_ATTENTION_RECOVERY_LEAF_STATUSES = new Set(["todo", "in_progress"]);
|
||||
const BLOCKER_ATTENTION_DEFAULT_MAX_CONTINUATION_ATTEMPTS = 2;
|
||||
|
||||
type IssueBlockerAttentionNode = {
|
||||
id: string;
|
||||
@@ -717,7 +696,6 @@ type IssueBlockerAttentionNode = {
|
||||
title: string;
|
||||
status: string;
|
||||
executionRunId?: string | null;
|
||||
executionState?: Record<string, unknown> | null;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
};
|
||||
@@ -744,10 +722,6 @@ type IssueBlockerAttentionAgentRow = {
|
||||
companyId: string;
|
||||
status: string;
|
||||
};
|
||||
type IssueBlockerExplicitWaitingPath = {
|
||||
owner: IssueBlockerNextActionOwner;
|
||||
hint: IssueBlockerNextActionHint;
|
||||
};
|
||||
|
||||
async function activeRunMapForIssues(
|
||||
dbOrTx: any,
|
||||
@@ -796,113 +770,9 @@ function createIssueBlockerAttention(input: Partial<IssueBlockerAttention> = {})
|
||||
attentionBlockerCount: input.attentionBlockerCount ?? 0,
|
||||
sampleBlockerIdentifier: input.sampleBlockerIdentifier ?? null,
|
||||
sampleStalledBlockerIdentifier: input.sampleStalledBlockerIdentifier ?? null,
|
||||
nextActionOwner: input.nextActionOwner ?? null,
|
||||
nextActionHint: input.nextActionHint ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
type LeafRecoveryClassification = {
|
||||
reason: Extract<
|
||||
IssueBlockerAttentionReason,
|
||||
"productive_run_stopped" | "continuation_exhausted" | "continuation_suppressed"
|
||||
>;
|
||||
hint: IssueBlockerNextActionHint;
|
||||
owner: IssueBlockerNextActionOwner;
|
||||
};
|
||||
|
||||
function leafRecoveryHintForReason(
|
||||
reason: LeafRecoveryClassification["reason"],
|
||||
owner: IssueBlockerNextActionOwner,
|
||||
): IssueBlockerNextActionHint {
|
||||
if (owner.type === "user") return "reassign";
|
||||
if (owner.type === "none") return "reassign";
|
||||
if (reason === "productive_run_stopped") return "wake_to_continue";
|
||||
if (reason === "continuation_exhausted") return "create_recovery_issue";
|
||||
return "needs_human_review";
|
||||
}
|
||||
|
||||
function ownerForLeafRecovery(node: IssueBlockerAttentionNode): IssueBlockerNextActionOwner {
|
||||
if (node.assigneeAgentId) {
|
||||
return { type: "agent", agentId: node.assigneeAgentId, userId: null };
|
||||
}
|
||||
if (node.assigneeUserId) {
|
||||
return { type: "user", agentId: null, userId: node.assigneeUserId };
|
||||
}
|
||||
return { type: "none", agentId: null, userId: null };
|
||||
}
|
||||
|
||||
function ownerForExplicitWait(input: { assigneeAgentId?: string | null; assigneeUserId?: string | null }): IssueBlockerNextActionOwner {
|
||||
if (input.assigneeUserId) {
|
||||
return { type: "user", agentId: null, userId: input.assigneeUserId };
|
||||
}
|
||||
if (input.assigneeAgentId) {
|
||||
return { type: "agent", agentId: input.assigneeAgentId, userId: null };
|
||||
}
|
||||
return { type: "user", agentId: null, userId: null };
|
||||
}
|
||||
|
||||
function explicitWaitingPathForOwner(owner: IssueBlockerNextActionOwner): IssueBlockerExplicitWaitingPath {
|
||||
return {
|
||||
owner,
|
||||
hint: "needs_human_review",
|
||||
};
|
||||
}
|
||||
|
||||
type IssueBlockerAttentionLatestRunRow = {
|
||||
issueId: string | null;
|
||||
status: string;
|
||||
livenessState: string | null;
|
||||
continuationAttempt: number | null;
|
||||
finishedAt: Date | null;
|
||||
};
|
||||
|
||||
type IssueBlockerAttentionLatestRun = {
|
||||
status: string;
|
||||
livenessState: string | null;
|
||||
continuationAttempt: number | null;
|
||||
finishedAt: Date | null;
|
||||
};
|
||||
|
||||
function classifyLeafRecovery(input: {
|
||||
node: IssueBlockerAttentionNode;
|
||||
latestRun: IssueBlockerAttentionLatestRun | null;
|
||||
hasActivePath: boolean;
|
||||
hasWaitingPath: boolean;
|
||||
hasProductivityReview: boolean;
|
||||
agentInvokable: boolean;
|
||||
maxContinuationAttempts: number;
|
||||
}): LeafRecoveryClassification | null {
|
||||
const { node, latestRun, hasActivePath, hasWaitingPath, hasProductivityReview, agentInvokable, maxContinuationAttempts } = input;
|
||||
if (!BLOCKER_ATTENTION_RECOVERY_LEAF_STATUSES.has(node.status)) return null;
|
||||
if (hasActivePath || hasWaitingPath) return null;
|
||||
if (!latestRun) return null;
|
||||
if (latestRun.status !== "succeeded") return null;
|
||||
if (!latestRun.livenessState || !BLOCKER_ATTENTION_RECOVERY_LIVENESS_STATES.has(latestRun.livenessState)) return null;
|
||||
|
||||
const owner = ownerForLeafRecovery(node);
|
||||
// If the leaf has no agent owner at all, fall through to needs_attention so
|
||||
// the existing "no owner" surface continues to apply.
|
||||
if (owner.type === "none") return null;
|
||||
|
||||
let reason: LeafRecoveryClassification["reason"];
|
||||
if (hasProductivityReview) {
|
||||
reason = "continuation_suppressed";
|
||||
} else if ((latestRun.continuationAttempt ?? 0) >= maxContinuationAttempts) {
|
||||
reason = "continuation_exhausted";
|
||||
} else {
|
||||
reason = "productive_run_stopped";
|
||||
}
|
||||
|
||||
// An invokable agent who can be woken keeps the wake_to_continue affordance;
|
||||
// otherwise the user needs to reassign or escalate.
|
||||
let hint = leafRecoveryHintForReason(reason, owner);
|
||||
if (owner.type === "agent" && !agentInvokable) {
|
||||
hint = reason === "continuation_exhausted" ? "create_recovery_issue" : "needs_human_review";
|
||||
}
|
||||
|
||||
return { reason, hint, owner };
|
||||
}
|
||||
|
||||
function blockerSampleIdentifier(node: IssueBlockerAttentionNode | null | undefined) {
|
||||
return node?.identifier ?? node?.id ?? null;
|
||||
}
|
||||
@@ -1136,27 +1006,18 @@ async function listIssueBlockerAttentionMap(
|
||||
companyId: string,
|
||||
issueRows: IssueBlockerAttentionInputNode[],
|
||||
): Promise<Map<string, IssueBlockerAttention>> {
|
||||
const sameCompanyRows = issueRows.filter((row) => row.companyId === companyId);
|
||||
const roots = sameCompanyRows.filter((row) => row.status === "blocked");
|
||||
// Non-blocked, non-terminal input issues with an agent owner are eligible to
|
||||
// surface as invalid leaves on their own detail/inbox surfaces.
|
||||
const recoverySeedRows = sameCompanyRows.filter((row) =>
|
||||
BLOCKER_ATTENTION_RECOVERY_LEAF_STATUSES.has(row.status) && Boolean(row.assigneeAgentId),
|
||||
);
|
||||
const roots = issueRows.filter((row) => row.companyId === companyId && row.status === "blocked");
|
||||
const attentionMap = new Map<string, IssueBlockerAttention>();
|
||||
for (const row of issueRows) {
|
||||
if (row.status !== "blocked") {
|
||||
attentionMap.set(row.id, createIssueBlockerAttention());
|
||||
}
|
||||
}
|
||||
if (roots.length === 0 && recoverySeedRows.length === 0) return attentionMap;
|
||||
if (roots.length === 0) return attentionMap;
|
||||
|
||||
const nodesById = new Map<string, IssueBlockerAttentionNode>();
|
||||
const edgesByIssueId = new Map<string, IssueBlockerAttentionEdge[]>();
|
||||
for (const root of roots) nodesById.set(root.id, { ...root });
|
||||
for (const seed of recoverySeedRows) {
|
||||
if (!nodesById.has(seed.id)) nodesById.set(seed.id, { ...seed });
|
||||
}
|
||||
|
||||
let frontier = roots.map((root) => root.id);
|
||||
let truncated = false;
|
||||
@@ -1175,7 +1036,6 @@ async function listIssueBlockerAttentionMap(
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
executionRunId: issues.executionRunId,
|
||||
executionState: issues.executionState,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
})
|
||||
@@ -1201,7 +1061,6 @@ async function listIssueBlockerAttentionMap(
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
executionRunId: issues.executionRunId,
|
||||
executionState: issues.executionState,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
})
|
||||
@@ -1301,73 +1160,26 @@ async function listIssueBlockerAttentionMap(
|
||||
}
|
||||
}
|
||||
|
||||
const nonTerminalNodeIds = [...nodesById.values()]
|
||||
.filter((node) => node.status !== "done" && node.status !== "cancelled")
|
||||
const reviewNodeIds = [...nodesById.values()]
|
||||
.filter((node) => node.status === "in_review")
|
||||
.map((node) => node.id);
|
||||
const explicitWaitingByIssueId = new Map<string, IssueBlockerExplicitWaitingPath>();
|
||||
const setExplicitWaitingPath = (issueId: string | null | undefined, path: IssueBlockerExplicitWaitingPath) => {
|
||||
if (!issueId || explicitWaitingByIssueId.has(issueId)) return;
|
||||
explicitWaitingByIssueId.set(issueId, path);
|
||||
};
|
||||
for (const node of nodesById.values()) {
|
||||
if (node.status === "done" || node.status === "cancelled") continue;
|
||||
if (node.assigneeUserId) {
|
||||
setExplicitWaitingPath(node.id, explicitWaitingPathForOwner(ownerForExplicitWait(node)));
|
||||
}
|
||||
}
|
||||
if (nonTerminalNodeIds.length > 0) {
|
||||
for (const chunk of chunkList(nonTerminalNodeIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
|
||||
const interactionRows: Array<{
|
||||
issueId: string;
|
||||
companyId: string;
|
||||
status: string;
|
||||
createdByUserId: string | null;
|
||||
createdAt: Date;
|
||||
}> = await dbOrTx
|
||||
.select({
|
||||
issueId: issueThreadInteractions.issueId,
|
||||
companyId: issueThreadInteractions.companyId,
|
||||
status: issueThreadInteractions.status,
|
||||
createdByUserId: issueThreadInteractions.createdByUserId,
|
||||
createdAt: issueThreadInteractions.createdAt,
|
||||
})
|
||||
const explicitWaitingIssueIds = new Set<string>();
|
||||
if (reviewNodeIds.length > 0) {
|
||||
for (const chunk of chunkList(reviewNodeIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
|
||||
const interactionRows: Array<{ issueId: string }> = await dbOrTx
|
||||
.select({ issueId: issueThreadInteractions.issueId })
|
||||
.from(issueThreadInteractions)
|
||||
.where(
|
||||
and(
|
||||
eq(issueThreadInteractions.companyId, companyId),
|
||||
inArray(issueThreadInteractions.status, BLOCKER_ATTENTION_PENDING_INTERACTION_STATUSES),
|
||||
inArray(issueThreadInteractions.kind, BLOCKER_ATTENTION_WAITING_INTERACTION_KINDS),
|
||||
inArray(issueThreadInteractions.issueId, chunk),
|
||||
),
|
||||
);
|
||||
for (const row of interactionRows) {
|
||||
const node = nodesById.get(row.issueId);
|
||||
if (!node || !isLiveExplicitInteractionWaitingPath(node, row)) continue;
|
||||
setExplicitWaitingPath(row.issueId, explicitWaitingPathForOwner(ownerForExplicitWait({
|
||||
assigneeUserId: node?.assigneeUserId ?? null,
|
||||
})));
|
||||
}
|
||||
for (const row of interactionRows) explicitWaitingIssueIds.add(row.issueId);
|
||||
|
||||
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,
|
||||
})
|
||||
const approvalRows: Array<{ issueId: string }> = await dbOrTx
|
||||
.select({ issueId: issueApprovals.issueId })
|
||||
.from(issueApprovals)
|
||||
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
|
||||
.where(
|
||||
@@ -1377,24 +1189,10 @@ async function listIssueBlockerAttentionMap(
|
||||
inArray(issueApprovals.issueId, chunk),
|
||||
),
|
||||
);
|
||||
for (const row of approvalRows) {
|
||||
const node = nodesById.get(row.issueId);
|
||||
if (!node || !isLiveExplicitApprovalWaitingPath(node, row)) continue;
|
||||
setExplicitWaitingPath(row.issueId, explicitWaitingPathForOwner(ownerForExplicitWait({
|
||||
assigneeUserId: node?.assigneeUserId ?? null,
|
||||
})));
|
||||
}
|
||||
for (const row of approvalRows) explicitWaitingIssueIds.add(row.issueId);
|
||||
|
||||
const recoveryRows: Array<{
|
||||
originId: string | null;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
}> = await dbOrTx
|
||||
.select({
|
||||
originId: issues.originId,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
})
|
||||
const recoveryRows: Array<{ originId: string | null }> = await dbOrTx
|
||||
.select({ originId: issues.originId })
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
@@ -1406,7 +1204,7 @@ async function listIssueBlockerAttentionMap(
|
||||
),
|
||||
);
|
||||
for (const row of recoveryRows) {
|
||||
setExplicitWaitingPath(row.originId, explicitWaitingPathForOwner(ownerForExplicitWait(row)));
|
||||
if (row.originId) explicitWaitingIssueIds.add(row.originId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1423,165 +1221,40 @@ async function listIssueBlockerAttentionMap(
|
||||
: [];
|
||||
const agentsById = new Map(agentRows.map((agent) => [agent.id, agent]));
|
||||
|
||||
// Recovery-leaf inputs: latest issue-linked run + productivity-review hold.
|
||||
// We only need this for non-blocked agent-owned non-terminal nodes; gate the
|
||||
// queries on that subset so we do not pull runs for every blocker chain.
|
||||
const recoveryCandidateIds = [...nodesById.values()]
|
||||
.filter((node) =>
|
||||
BLOCKER_ATTENTION_RECOVERY_LEAF_STATUSES.has(node.status) &&
|
||||
Boolean(node.assigneeAgentId) &&
|
||||
!activeIssueIds.has(node.id) &&
|
||||
!explicitWaitingByIssueId.has(node.id),
|
||||
)
|
||||
.map((node) => node.id);
|
||||
|
||||
const latestRunByIssueId = new Map<string, IssueBlockerAttentionLatestRun>();
|
||||
if (recoveryCandidateIds.length > 0) {
|
||||
for (const chunk of chunkList(recoveryCandidateIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
|
||||
const runRows: IssueBlockerAttentionLatestRunRow[] = await dbOrTx
|
||||
.select({
|
||||
issueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`,
|
||||
status: heartbeatRuns.status,
|
||||
livenessState: heartbeatRuns.livenessState,
|
||||
continuationAttempt: heartbeatRuns.continuationAttempt,
|
||||
finishedAt: heartbeatRuns.finishedAt,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
inArray(sql<string>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, chunk),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id));
|
||||
for (const row of runRows) {
|
||||
if (!row.issueId) continue;
|
||||
if (latestRunByIssueId.has(row.issueId)) continue;
|
||||
latestRunByIssueId.set(row.issueId, {
|
||||
status: row.status,
|
||||
livenessState: row.livenessState,
|
||||
continuationAttempt: row.continuationAttempt,
|
||||
finishedAt: row.finishedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const productivityReviewIssueIds = new Set<string>();
|
||||
if (recoveryCandidateIds.length > 0) {
|
||||
for (const chunk of chunkList(recoveryCandidateIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
|
||||
const productivityRows: 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, chunk),
|
||||
notInArray(issues.status, PRODUCTIVITY_REVIEW_TERMINAL_STATUSES),
|
||||
),
|
||||
);
|
||||
for (const row of productivityRows) {
|
||||
if (row.originId) productivityReviewIssueIds.add(row.originId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const leafRecoveryByNodeId = new Map<string, LeafRecoveryClassification>();
|
||||
for (const nodeId of recoveryCandidateIds) {
|
||||
const node = nodesById.get(nodeId);
|
||||
if (!node) continue;
|
||||
const assignee = node.assigneeAgentId ? agentsById.get(node.assigneeAgentId) : null;
|
||||
const agentInvokable = Boolean(
|
||||
assignee && assignee.companyId === companyId && BLOCKER_ATTENTION_INVOKABLE_AGENT_STATUSES.has(assignee.status),
|
||||
);
|
||||
const classification = classifyLeafRecovery({
|
||||
node,
|
||||
latestRun: latestRunByIssueId.get(nodeId) ?? null,
|
||||
hasActivePath: activeIssueIds.has(nodeId),
|
||||
hasWaitingPath: explicitWaitingByIssueId.has(nodeId),
|
||||
hasProductivityReview: productivityReviewIssueIds.has(nodeId),
|
||||
agentInvokable,
|
||||
maxContinuationAttempts: BLOCKER_ATTENTION_DEFAULT_MAX_CONTINUATION_ATTEMPTS,
|
||||
});
|
||||
if (classification) leafRecoveryByNodeId.set(nodeId, classification);
|
||||
}
|
||||
|
||||
type PathClassification = {
|
||||
covered: boolean;
|
||||
coveredReason: IssueBlockerAttentionReason | null;
|
||||
stalled: boolean;
|
||||
sampleBlockerIdentifier: string | null;
|
||||
sampleStalledBlockerIdentifier: string | null;
|
||||
recoveryLeafIdentifier: string | null;
|
||||
recovery: LeafRecoveryClassification | null;
|
||||
waitingPath: IssueBlockerExplicitWaitingPath | null;
|
||||
};
|
||||
const emptyPath = (sample: string | null): PathClassification => ({
|
||||
covered: false,
|
||||
coveredReason: null,
|
||||
stalled: false,
|
||||
sampleBlockerIdentifier: sample,
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
recoveryLeafIdentifier: null,
|
||||
recovery: null,
|
||||
waitingPath: null,
|
||||
});
|
||||
const coveredPath = (
|
||||
sample: string | null,
|
||||
coveredReason: IssueBlockerAttentionReason = null,
|
||||
waitingPath: IssueBlockerExplicitWaitingPath | null = null,
|
||||
): PathClassification => ({
|
||||
covered: true,
|
||||
coveredReason,
|
||||
stalled: false,
|
||||
sampleBlockerIdentifier: sample,
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
recoveryLeafIdentifier: null,
|
||||
recovery: null,
|
||||
waitingPath,
|
||||
});
|
||||
const classifyPath = (
|
||||
nodeId: string,
|
||||
seen: Set<string>,
|
||||
): PathClassification => {
|
||||
const sample = blockerSampleIdentifier(nodesById.get(nodeId));
|
||||
if (truncated || seen.has(nodeId)) {
|
||||
return emptyPath(sample);
|
||||
return { covered: false, stalled: false, sampleBlockerIdentifier: sample, sampleStalledBlockerIdentifier: null };
|
||||
}
|
||||
const node = nodesById.get(nodeId);
|
||||
if (!node || node.companyId !== companyId) {
|
||||
return emptyPath(nodeId);
|
||||
return { covered: false, stalled: false, sampleBlockerIdentifier: nodeId, sampleStalledBlockerIdentifier: null };
|
||||
}
|
||||
const nodeSample = blockerSampleIdentifier(node);
|
||||
if (node.status === "done") {
|
||||
return coveredPath(nodeSample);
|
||||
}
|
||||
const explicitWaitingPath = explicitWaitingByIssueId.get(node.id) ?? null;
|
||||
if (explicitWaitingPath) {
|
||||
return coveredPath(nodeSample, "explicit_waiting", explicitWaitingPath);
|
||||
return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
|
||||
}
|
||||
if (node.status === "in_review") {
|
||||
if (activeIssueIds.has(node.id)) {
|
||||
return coveredPath(nodeSample);
|
||||
const hasWaitingPath = activeIssueIds.has(node.id) || Boolean(node.assigneeUserId) || explicitWaitingIssueIds.has(node.id);
|
||||
if (hasWaitingPath) {
|
||||
return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
|
||||
}
|
||||
return {
|
||||
covered: false,
|
||||
coveredReason: null,
|
||||
stalled: true,
|
||||
sampleBlockerIdentifier: nodeSample,
|
||||
sampleStalledBlockerIdentifier: nodeSample,
|
||||
recoveryLeafIdentifier: null,
|
||||
recovery: null,
|
||||
waitingPath: null,
|
||||
};
|
||||
return { covered: false, stalled: true, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: nodeSample };
|
||||
}
|
||||
if (activeIssueIds.has(node.id)) {
|
||||
return coveredPath(nodeSample);
|
||||
return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
|
||||
}
|
||||
if (node.status === "cancelled") {
|
||||
return emptyPath(nodeSample);
|
||||
return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
|
||||
}
|
||||
|
||||
const downstream = (edgesByIssueId.get(node.id) ?? []).filter((edge) => nodesById.get(edge.blockerIssueId)?.status !== "done");
|
||||
@@ -1591,95 +1264,48 @@ async function listIssueBlockerAttentionMap(
|
||||
const classified = downstream.map((edge) => classifyPath(edge.blockerIssueId, nextSeen));
|
||||
const stalledChild = classified.find((result) => result.stalled || result.sampleStalledBlockerIdentifier);
|
||||
const sampleStalled = stalledChild?.sampleStalledBlockerIdentifier ?? null;
|
||||
const recoveryChild = classified.find((result) => result.recovery && result.recoveryLeafIdentifier);
|
||||
const recoveryLeafIdentifier = recoveryChild?.recoveryLeafIdentifier ?? null;
|
||||
const recovery = recoveryChild?.recovery ?? null;
|
||||
const waitingEntry = classified.find((result) => result.coveredReason === "explicit_waiting" && result.waitingPath);
|
||||
const waitingPath = waitingEntry?.waitingPath ?? null;
|
||||
const hardAttention = classified.find((result) => !result.covered && !result.stalled && !result.recovery);
|
||||
const hardAttention = classified.find((result) => !result.covered && !result.stalled);
|
||||
if (hardAttention) {
|
||||
return {
|
||||
covered: false,
|
||||
coveredReason: null,
|
||||
stalled: false,
|
||||
sampleBlockerIdentifier: hardAttention.sampleBlockerIdentifier,
|
||||
sampleStalledBlockerIdentifier: sampleStalled,
|
||||
recoveryLeafIdentifier,
|
||||
recovery,
|
||||
waitingPath,
|
||||
};
|
||||
}
|
||||
if (recoveryChild) {
|
||||
return {
|
||||
covered: false,
|
||||
coveredReason: null,
|
||||
stalled: false,
|
||||
sampleBlockerIdentifier: recoveryLeafIdentifier ?? recoveryChild.sampleBlockerIdentifier,
|
||||
sampleStalledBlockerIdentifier: sampleStalled,
|
||||
recoveryLeafIdentifier,
|
||||
recovery,
|
||||
waitingPath,
|
||||
};
|
||||
}
|
||||
const stalledEntry = classified.find((result) => result.stalled);
|
||||
if (stalledEntry) {
|
||||
return {
|
||||
covered: false,
|
||||
coveredReason: null,
|
||||
stalled: true,
|
||||
sampleBlockerIdentifier: stalledEntry.sampleBlockerIdentifier,
|
||||
sampleStalledBlockerIdentifier: sampleStalled,
|
||||
recoveryLeafIdentifier: null,
|
||||
recovery: null,
|
||||
waitingPath,
|
||||
};
|
||||
}
|
||||
return {
|
||||
covered: true,
|
||||
coveredReason: waitingPath ? "explicit_waiting" : null,
|
||||
stalled: false,
|
||||
sampleBlockerIdentifier: waitingEntry?.sampleBlockerIdentifier ?? classified[0]?.sampleBlockerIdentifier ?? nodeSample,
|
||||
sampleBlockerIdentifier: classified[0]?.sampleBlockerIdentifier ?? nodeSample,
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
recoveryLeafIdentifier: null,
|
||||
recovery: null,
|
||||
waitingPath,
|
||||
};
|
||||
}
|
||||
|
||||
const recovery = leafRecoveryByNodeId.get(node.id) ?? null;
|
||||
if (recovery) {
|
||||
return {
|
||||
covered: false,
|
||||
coveredReason: null,
|
||||
stalled: false,
|
||||
sampleBlockerIdentifier: nodeSample,
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
recoveryLeafIdentifier: nodeSample,
|
||||
recovery,
|
||||
waitingPath: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (node.assigneeAgentId) {
|
||||
const assignee = agentsById.get(node.assigneeAgentId);
|
||||
if (!assignee || assignee.companyId !== companyId || !BLOCKER_ATTENTION_INVOKABLE_AGENT_STATUSES.has(assignee.status)) {
|
||||
return emptyPath(nodeSample);
|
||||
return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
|
||||
}
|
||||
}
|
||||
|
||||
return emptyPath(nodeSample);
|
||||
return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
|
||||
};
|
||||
|
||||
for (const root of roots) {
|
||||
const topLevelEdges = (edgesByIssueId.get(root.id) ?? []).filter((edge) => nodesById.get(edge.blockerIssueId)?.status !== "done");
|
||||
if (topLevelEdges.length === 0) {
|
||||
const waitingPath = explicitWaitingByIssueId.get(root.id) ?? null;
|
||||
attentionMap.set(root.id, createIssueBlockerAttention({
|
||||
state: waitingPath ? "covered" : "needs_attention",
|
||||
reason: waitingPath ? "explicit_waiting" : "attention_required",
|
||||
sampleBlockerIdentifier: waitingPath ? blockerSampleIdentifier(nodesById.get(root.id)) : null,
|
||||
nextActionOwner: waitingPath?.owner ?? null,
|
||||
nextActionHint: waitingPath?.hint ?? null,
|
||||
state: "needs_attention",
|
||||
reason: "attention_required",
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
@@ -1690,48 +1316,28 @@ async function listIssueBlockerAttentionMap(
|
||||
}));
|
||||
const coveredBlockerCount = classified.filter((entry) => entry.result.covered).length;
|
||||
const stalledBlockerCount = classified.filter((entry) => entry.result.stalled).length;
|
||||
const recoveryEntry = classified.find((entry) => entry.result.recovery && entry.result.recoveryLeafIdentifier);
|
||||
const recoveryBlockerCount = classified.filter((entry) => entry.result.recovery).length;
|
||||
const attentionBlockerCount = classified.length - coveredBlockerCount - stalledBlockerCount - recoveryBlockerCount;
|
||||
const hardAttentionEntry = classified.find((entry) => !entry.result.covered && !entry.result.stalled && !entry.result.recovery);
|
||||
const attentionBlockerCount = classified.length - coveredBlockerCount - stalledBlockerCount;
|
||||
const hardAttentionEntry = classified.find((entry) => !entry.result.covered && !entry.result.stalled);
|
||||
const stalledEntry = classified.find((entry) => entry.result.stalled);
|
||||
const waitingEntry = classified.find((entry) => entry.result.coveredReason === "explicit_waiting" && entry.result.waitingPath);
|
||||
const sampleEntry = hardAttentionEntry ?? recoveryEntry ?? stalledEntry ?? classified[0] ?? null;
|
||||
const sampleEntry = hardAttentionEntry ?? stalledEntry ?? classified[0] ?? null;
|
||||
const sampleNode = sampleEntry ? nodesById.get(sampleEntry.edge.blockerIssueId) : null;
|
||||
const sampleStalledFromChain = classified
|
||||
.map((entry) => entry.result.sampleStalledBlockerIdentifier)
|
||||
.find((value) => value);
|
||||
const recoveryLeafIdentifier = recoveryEntry?.result.recoveryLeafIdentifier ?? null;
|
||||
const recoveryClassification = recoveryEntry?.result.recovery ?? null;
|
||||
|
||||
let state: IssueBlockerAttentionState;
|
||||
let reason: IssueBlockerAttentionReason;
|
||||
let nextActionOwner: IssueBlockerNextActionOwner | null = null;
|
||||
let nextActionHint: IssueBlockerNextActionHint = null;
|
||||
let sampleBlockerIdentifier =
|
||||
sampleEntry?.result.sampleBlockerIdentifier ?? blockerSampleIdentifier(sampleNode);
|
||||
let state: IssueBlockerAttention["state"];
|
||||
let reason: IssueBlockerAttention["reason"];
|
||||
if (attentionBlockerCount > 0) {
|
||||
state = "needs_attention";
|
||||
reason = "attention_required";
|
||||
} else if (recoveryClassification) {
|
||||
state = "recovery_needed";
|
||||
reason = recoveryClassification.reason;
|
||||
nextActionOwner = recoveryClassification.owner;
|
||||
nextActionHint = recoveryClassification.hint;
|
||||
sampleBlockerIdentifier = recoveryLeafIdentifier ?? sampleBlockerIdentifier;
|
||||
} else if (stalledBlockerCount > 0) {
|
||||
state = "stalled";
|
||||
reason = "stalled_review";
|
||||
} else {
|
||||
state = "covered";
|
||||
reason = waitingEntry
|
||||
? "explicit_waiting"
|
||||
: topLevelEdges.every((edge) => nodesById.get(edge.blockerIssueId)?.parentId === root.id)
|
||||
? "active_child"
|
||||
: "active_dependency";
|
||||
nextActionOwner = waitingEntry?.result.waitingPath?.owner ?? null;
|
||||
nextActionHint = waitingEntry?.result.waitingPath?.hint ?? null;
|
||||
sampleBlockerIdentifier = waitingEntry?.result.sampleBlockerIdentifier ?? sampleBlockerIdentifier;
|
||||
reason = topLevelEdges.every((edge) => nodesById.get(edge.blockerIssueId)?.parentId === root.id)
|
||||
? "active_child"
|
||||
: "active_dependency";
|
||||
}
|
||||
|
||||
attentionMap.set(root.id, createIssueBlockerAttention({
|
||||
@@ -1741,37 +1347,9 @@ async function listIssueBlockerAttentionMap(
|
||||
coveredBlockerCount,
|
||||
stalledBlockerCount,
|
||||
attentionBlockerCount,
|
||||
sampleBlockerIdentifier,
|
||||
sampleBlockerIdentifier: sampleEntry?.result.sampleBlockerIdentifier ?? blockerSampleIdentifier(sampleNode),
|
||||
sampleStalledBlockerIdentifier:
|
||||
stalledEntry?.result.sampleStalledBlockerIdentifier ?? sampleStalledFromChain ?? null,
|
||||
nextActionOwner,
|
||||
nextActionHint,
|
||||
}));
|
||||
}
|
||||
|
||||
// Non-blocked recovery seeds: a leaf that is itself the invalid endpoint
|
||||
// gets its own recovery_needed surface (e.g. opening the leaf directly from
|
||||
// an inbox wake).
|
||||
for (const seed of recoverySeedRows) {
|
||||
const waitingPath = explicitWaitingByIssueId.get(seed.id) ?? null;
|
||||
if (waitingPath) {
|
||||
attentionMap.set(seed.id, createIssueBlockerAttention({
|
||||
state: "covered",
|
||||
reason: "explicit_waiting",
|
||||
sampleBlockerIdentifier: blockerSampleIdentifier(nodesById.get(seed.id)) ?? seed.identifier ?? seed.id,
|
||||
nextActionOwner: waitingPath.owner,
|
||||
nextActionHint: waitingPath.hint,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
const recovery = leafRecoveryByNodeId.get(seed.id);
|
||||
if (!recovery) continue;
|
||||
attentionMap.set(seed.id, createIssueBlockerAttention({
|
||||
state: "recovery_needed",
|
||||
reason: recovery.reason,
|
||||
sampleBlockerIdentifier: blockerSampleIdentifier(nodesById.get(seed.id)) ?? seed.identifier ?? seed.id,
|
||||
nextActionOwner: recovery.owner,
|
||||
nextActionHint: recovery.hint,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
const EXPLICIT_WAITING_PATH_FRESHNESS_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
type MaybeDate = Date | string | null | undefined;
|
||||
|
||||
export type ExplicitWaitingIssueInput = {
|
||||
companyId: string;
|
||||
id: string;
|
||||
assigneeUserId?: string | null;
|
||||
executionState?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type ExplicitWaitingInteractionInput = {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
status?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
createdAt?: MaybeDate;
|
||||
};
|
||||
|
||||
export type ExplicitWaitingApprovalInput = {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
status?: string | null;
|
||||
requestedByUserId?: string | null;
|
||||
linkedByUserId?: string | null;
|
||||
createdAt?: MaybeDate;
|
||||
updatedAt?: MaybeDate;
|
||||
linkedAt?: MaybeDate;
|
||||
};
|
||||
|
||||
function parseDate(value: MaybeDate) {
|
||||
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;
|
||||
}
|
||||
|
||||
function latestDate(...values: MaybeDate[]) {
|
||||
return values
|
||||
.map(parseDate)
|
||||
.filter((value): value is Date => Boolean(value))
|
||||
.sort((left, right) => right.getTime() - left.getTime())[0] ?? null;
|
||||
}
|
||||
|
||||
function currentParticipantIsUser(executionState: Record<string, unknown> | null | undefined) {
|
||||
const participant = executionState?.currentParticipant;
|
||||
if (!participant || typeof participant !== "object") return false;
|
||||
const value = participant as Record<string, unknown>;
|
||||
return value.type === "user" && typeof value.userId === "string" && value.userId.trim().length > 0;
|
||||
}
|
||||
|
||||
function issueHasHumanOwner(issue: ExplicitWaitingIssueInput) {
|
||||
return Boolean(issue.assigneeUserId) || currentParticipantIsUser(issue.executionState);
|
||||
}
|
||||
|
||||
function isFreshWaitingPath(createdAt: MaybeDate, now: Date) {
|
||||
const created = parseDate(createdAt);
|
||||
if (!created) return false;
|
||||
return now.getTime() - created.getTime() <= EXPLICIT_WAITING_PATH_FRESHNESS_MS;
|
||||
}
|
||||
|
||||
export function isLiveExplicitInteractionWaitingPath(
|
||||
issue: ExplicitWaitingIssueInput,
|
||||
interaction: ExplicitWaitingInteractionInput,
|
||||
now = new Date(),
|
||||
) {
|
||||
if (interaction.companyId !== issue.companyId || interaction.issueId !== issue.id) return false;
|
||||
if (interaction.status && interaction.status !== "pending") return false;
|
||||
if (issueHasHumanOwner(issue)) return true;
|
||||
if (interaction.createdByUserId) return true;
|
||||
return isFreshWaitingPath(interaction.createdAt, now);
|
||||
}
|
||||
|
||||
export function isLiveExplicitApprovalWaitingPath(
|
||||
issue: ExplicitWaitingIssueInput,
|
||||
approval: ExplicitWaitingApprovalInput,
|
||||
now = new Date(),
|
||||
) {
|
||||
if (approval.companyId !== issue.companyId || approval.issueId !== issue.id) return false;
|
||||
if (approval.status && approval.status !== "pending" && approval.status !== "revision_requested") return false;
|
||||
if (issueHasHumanOwner(issue)) return true;
|
||||
if (approval.requestedByUserId || approval.linkedByUserId) return true;
|
||||
return isFreshWaitingPath(latestDate(approval.updatedAt, approval.linkedAt, approval.createdAt), now);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { buildIssueGraphLivenessIncidentKey } from "./origins.js";
|
||||
import {
|
||||
isLiveExplicitApprovalWaitingPath,
|
||||
isLiveExplicitInteractionWaitingPath,
|
||||
} from "./explicit-waiting-paths.js";
|
||||
|
||||
export type IssueLivenessSeverity = "warning" | "critical";
|
||||
|
||||
@@ -56,12 +52,6 @@ export interface IssueLivenessWaitingPathInput {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
status: string;
|
||||
createdByUserId?: string | null;
|
||||
requestedByUserId?: string | null;
|
||||
linkedByUserId?: string | null;
|
||||
createdAt?: Date | string | null;
|
||||
updatedAt?: Date | string | null;
|
||||
linkedAt?: Date | string | null;
|
||||
}
|
||||
|
||||
export interface IssueLivenessDependencyPathEntry {
|
||||
@@ -143,16 +133,11 @@ function hasActiveExecutionPath(
|
||||
}
|
||||
|
||||
function hasWaitingPath(
|
||||
issue: IssueLivenessIssueInput,
|
||||
companyId: string,
|
||||
issueId: string,
|
||||
waitingPaths: IssueLivenessWaitingPathInput[],
|
||||
kind: "interaction" | "approval",
|
||||
now: Date,
|
||||
) {
|
||||
return waitingPaths.some((entry) =>
|
||||
kind === "interaction"
|
||||
? isLiveExplicitInteractionWaitingPath(issue, entry, now)
|
||||
: isLiveExplicitApprovalWaitingPath(issue, entry, now)
|
||||
);
|
||||
return waitingPaths.some((entry) => entry.companyId === companyId && entry.issueId === issueId);
|
||||
}
|
||||
|
||||
function readPrincipalAgentId(principal: unknown): string | null {
|
||||
@@ -323,7 +308,6 @@ function finding(input: {
|
||||
}
|
||||
|
||||
export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): IssueLivenessFinding[] {
|
||||
const now = new Date();
|
||||
const issuesById = new Map(input.issues.map((issue) => [issue.id, issue]));
|
||||
const agentsById = new Map(input.agents.map((agent) => [agent.id, agent]));
|
||||
const blockersByBlockedIssueId = new Map<string, IssueLivenessRelationInput[]>();
|
||||
@@ -368,9 +352,9 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
|
||||
function hasExplicitWaitingPath(issue: IssueLivenessIssueInput) {
|
||||
return Boolean(issue.assigneeUserId) ||
|
||||
hasActiveExecutionPath(issue.companyId, issue.id, activeRuns, queuedWakeRequests) ||
|
||||
hasWaitingPath(issue, pendingInteractions, "interaction", now) ||
|
||||
hasWaitingPath(issue, pendingApprovals, "approval", now) ||
|
||||
openRecoveryIssues.some((entry) => entry.companyId === issue.companyId && entry.issueId === issue.id);
|
||||
hasWaitingPath(issue.companyId, issue.id, pendingInteractions) ||
|
||||
hasWaitingPath(issue.companyId, issue.id, pendingApprovals) ||
|
||||
hasWaitingPath(issue.companyId, issue.id, openRecoveryIssues);
|
||||
}
|
||||
|
||||
function reviewFinding(
|
||||
|
||||
@@ -8,14 +8,13 @@ export const RUN_LIVENESS_CONTINUATION_REASON = RECOVERY_REASON_KINDS.runLivenes
|
||||
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">;
|
||||
type HeartbeatRunRow = typeof heartbeatRuns.$inferSelect;
|
||||
type IssueRow = Pick<
|
||||
typeof issues.$inferSelect,
|
||||
"id" | "companyId" | "identifier" | "title" | "status" | "assigneeAgentId" | "executionState" | "projectId"
|
||||
@@ -105,15 +104,7 @@ export function decideRunLivenessContinuation(input: {
|
||||
} = input;
|
||||
const maxAttempts = input.maxAttempts ?? DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS;
|
||||
|
||||
if (!livenessState) {
|
||||
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) {
|
||||
if (!livenessState || !ACTIONABLE_LIVENESS_STATES.has(livenessState)) {
|
||||
return { kind: "skip", reason: "liveness state is not actionable for continuation" };
|
||||
}
|
||||
if (!issue) return { kind: "skip", reason: "issue not found" };
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
|
||||
type IssueGraphLivenessAutoRecoveryPreview,
|
||||
type IssueGraphLivenessAutoRecoveryPreviewItem,
|
||||
type RunLivenessState,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
agents,
|
||||
@@ -32,7 +31,6 @@ import { budgetService } from "../budgets.js";
|
||||
import { instanceSettingsService } from "../instance-settings.js";
|
||||
import { issueTreeControlService } from "../issue-tree-control.js";
|
||||
import { issueService } from "../issues.js";
|
||||
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
|
||||
import { getRunLogStore } from "../run-log-store.js";
|
||||
import {
|
||||
RECOVERY_ORIGIN_KINDS,
|
||||
@@ -44,18 +42,7 @@ import {
|
||||
classifyIssueGraphLiveness,
|
||||
type IssueLivenessFinding,
|
||||
} from "./issue-graph-liveness.js";
|
||||
import {
|
||||
isLiveExplicitApprovalWaitingPath,
|
||||
isLiveExplicitInteractionWaitingPath,
|
||||
} from "./explicit-waiting-paths.js";
|
||||
import { isAutomaticRecoverySuppressedByPauseHold } from "./pause-hold-guard.js";
|
||||
import {
|
||||
RUN_LIVENESS_CONTINUATION_REASON,
|
||||
buildRunLivenessContinuationIdempotencyKey,
|
||||
decideRunLivenessContinuation,
|
||||
findExistingRunLivenessContinuationWake,
|
||||
readContinuationAttempt,
|
||||
} from "./run-liveness-continuations.js";
|
||||
|
||||
const EXECUTION_PATH_HEARTBEAT_RUN_STATUSES = ["queued", "running", "scheduled_retry"] as const;
|
||||
const UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES = ["failed", "cancelled", "timed_out"] as const;
|
||||
@@ -66,7 +53,6 @@ const ACTIVE_RUN_OUTPUT_EVIDENCE_TAIL_BYTES = 8 * 1024;
|
||||
const STRANDED_ISSUE_RECOVERY_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.strandedIssueRecovery;
|
||||
const STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.staleActiveRunEvaluation;
|
||||
const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
|
||||
const EXPLICIT_WAITING_INTERACTION_KINDS = ["request_confirmation", "ask_user_questions"];
|
||||
|
||||
type RecoveryWakeupOptions = {
|
||||
source?: "timer" | "assignment" | "on_demand" | "automation";
|
||||
@@ -86,21 +72,9 @@ type RecoveryWakeup = (
|
||||
|
||||
type LatestIssueRun = Pick<
|
||||
typeof heartbeatRuns.$inferSelect,
|
||||
| "id"
|
||||
| "companyId"
|
||||
| "agentId"
|
||||
| "status"
|
||||
| "error"
|
||||
| "errorCode"
|
||||
| "contextSnapshot"
|
||||
| "livenessState"
|
||||
| "livenessReason"
|
||||
| "nextAction"
|
||||
| "continuationAttempt"
|
||||
"id" | "agentId" | "status" | "error" | "errorCode" | "contextSnapshot" | "livenessState"
|
||||
> | null;
|
||||
|
||||
type PostRunIssueDisposition = "terminal" | "explicitly_waiting" | "explicitly_live" | "invalid";
|
||||
|
||||
type WatchdogDecisionActor =
|
||||
| { type: "board"; userId?: string | null; runId?: string | null }
|
||||
| { type: "agent"; agentId?: string | null; runId?: string | null }
|
||||
@@ -214,14 +188,16 @@ function isUnsuccessfulTerminalIssueRun(latestRun: LatestIssueRun) {
|
||||
);
|
||||
}
|
||||
|
||||
function isProductiveProgressRun(latestRun: LatestIssueRun) {
|
||||
function isSuccessfulInProgressContinuationRun(latestRun: LatestIssueRun) {
|
||||
return latestRun?.status === "succeeded";
|
||||
}
|
||||
|
||||
function isProductiveContinuationRun(latestRun: LatestIssueRun) {
|
||||
return latestRun?.status === "succeeded" &&
|
||||
(
|
||||
latestRun.livenessState === "advanced" ||
|
||||
(latestRun.livenessState === "advanced" ||
|
||||
latestRun.livenessState === "completed" ||
|
||||
latestRun.livenessState === "blocked" ||
|
||||
latestRun.livenessState === "needs_followup"
|
||||
);
|
||||
latestRun.livenessState === "needs_followup");
|
||||
}
|
||||
|
||||
function parseLivenessIncidentKey(incidentKey: string | null | undefined) {
|
||||
@@ -330,16 +306,12 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
return db
|
||||
.select({
|
||||
id: heartbeatRuns.id,
|
||||
companyId: heartbeatRuns.companyId,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
status: heartbeatRuns.status,
|
||||
error: heartbeatRuns.error,
|
||||
errorCode: heartbeatRuns.errorCode,
|
||||
contextSnapshot: heartbeatRuns.contextSnapshot,
|
||||
livenessState: heartbeatRuns.livenessState,
|
||||
livenessReason: heartbeatRuns.livenessReason,
|
||||
nextAction: heartbeatRuns.nextAction,
|
||||
continuationAttempt: heartbeatRuns.continuationAttempt,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
@@ -399,103 +371,6 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
.then((rows) => Boolean(rows[0]));
|
||||
}
|
||||
|
||||
async function hasExplicitWaitingPath(
|
||||
issue: Pick<
|
||||
typeof issues.$inferSelect,
|
||||
"companyId" | "id" | "status" | "assigneeUserId" | "executionState"
|
||||
>,
|
||||
) {
|
||||
if (issue.status === "done" || issue.status === "cancelled") return true;
|
||||
if (issue.status === "blocked" || issue.status === "in_review") return true;
|
||||
if (issue.executionState) return true;
|
||||
await issueThreadInteractionService(db).expireInvalidPendingRequestConfirmationsForIssue(
|
||||
{ id: issue.id, companyId: issue.companyId },
|
||||
{ agentId: null, userId: null },
|
||||
);
|
||||
|
||||
const [interactionRows, approvalRows, recoveryIssue] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
issueId: issueThreadInteractions.issueId,
|
||||
companyId: issueThreadInteractions.companyId,
|
||||
status: issueThreadInteractions.status,
|
||||
createdByUserId: issueThreadInteractions.createdByUserId,
|
||||
createdAt: issueThreadInteractions.createdAt,
|
||||
})
|
||||
.from(issueThreadInteractions)
|
||||
.where(
|
||||
and(
|
||||
eq(issueThreadInteractions.companyId, issue.companyId),
|
||||
eq(issueThreadInteractions.issueId, issue.id),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
inArray(issueThreadInteractions.kind, EXPLICIT_WAITING_INTERACTION_KINDS),
|
||||
),
|
||||
)
|
||||
.limit(5),
|
||||
db
|
||||
.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", "revision_requested"]),
|
||||
),
|
||||
)
|
||||
.limit(5),
|
||||
db
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, issue.companyId),
|
||||
eq(issues.originKind, STRANDED_ISSUE_RECOVERY_ORIGIN_KIND),
|
||||
eq(issues.originId, issue.id),
|
||||
notInArray(issues.status, ["done", "cancelled"]),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null),
|
||||
]);
|
||||
|
||||
return Boolean(
|
||||
interactionRows.some((row) => isLiveExplicitInteractionWaitingPath(issue, row)) ||
|
||||
approvalRows.some((row) => isLiveExplicitApprovalWaitingPath(issue, row)) ||
|
||||
recoveryIssue,
|
||||
);
|
||||
}
|
||||
|
||||
async function classifyPostRunIssueDisposition(
|
||||
issue: typeof issues.$inferSelect,
|
||||
latestRun: LatestIssueRun,
|
||||
): Promise<PostRunIssueDisposition> {
|
||||
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";
|
||||
}
|
||||
|
||||
function didAutomaticContinuationFinishWithoutLivePath(latestRun: LatestIssueRun) {
|
||||
if (latestRun?.status !== "succeeded") return false;
|
||||
const latestContext = parseObject(latestRun.contextSnapshot);
|
||||
return readNonEmptyString(latestContext.retryReason) === "issue_continuation_needed";
|
||||
}
|
||||
|
||||
async function enqueueStrandedIssueRecovery(input: {
|
||||
issueId: string;
|
||||
agentId: string;
|
||||
@@ -559,89 +434,6 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
});
|
||||
}
|
||||
|
||||
async function continueOrEscalateSuccessfulStrandedRun(input: {
|
||||
issue: typeof issues.$inferSelect;
|
||||
agent: typeof agents.$inferSelect;
|
||||
latestRun: NonNullable<LatestIssueRun>;
|
||||
}) {
|
||||
const { issue, agent, latestRun } = input;
|
||||
const livenessState = latestRun.livenessState as RunLivenessState | null;
|
||||
const nextAttempt = readContinuationAttempt(latestRun.continuationAttempt) + 1;
|
||||
const idempotencyKey = buildRunLivenessContinuationIdempotencyKey({
|
||||
issueId: issue.id,
|
||||
sourceRunId: latestRun.id,
|
||||
livenessState: livenessState ?? "failed",
|
||||
nextAttempt,
|
||||
});
|
||||
const existingWake = await findExistingRunLivenessContinuationWake(db, {
|
||||
companyId: issue.companyId,
|
||||
idempotencyKey,
|
||||
});
|
||||
const budgetBlocked = await isInvocationBudgetBlocked(issue, agent.id);
|
||||
if (budgetBlocked) return "skipped" as const;
|
||||
|
||||
const decision = decideRunLivenessContinuation({
|
||||
run: latestRun,
|
||||
issue,
|
||||
agent,
|
||||
livenessState,
|
||||
livenessReason: latestRun.livenessReason,
|
||||
nextAction: latestRun.nextAction,
|
||||
budgetBlocked,
|
||||
idempotentWakeExists: Boolean(existingWake),
|
||||
});
|
||||
|
||||
if (decision.kind === "enqueue") {
|
||||
const queued = await deps.enqueueWakeup(agent.id, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: RUN_LIVENESS_CONTINUATION_REASON,
|
||||
payload: decision.payload,
|
||||
contextSnapshot: decision.contextSnapshot,
|
||||
idempotencyKey: decision.idempotencyKey,
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: "recovery.reconcile_stranded_assigned_issue",
|
||||
});
|
||||
if (!queued) return "skipped" as const;
|
||||
|
||||
await db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
continuationAttempt: decision.nextAttempt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, latestRun.id));
|
||||
return "queued" as const;
|
||||
}
|
||||
|
||||
if (decision.kind === "skip" && decision.reason === "continuation wake already exists for this source run and attempt") {
|
||||
return "skipped" as const;
|
||||
}
|
||||
|
||||
const continuationDetail = decision.kind === "exhausted"
|
||||
? `\n\n${decision.comment}`
|
||||
: [
|
||||
"",
|
||||
"",
|
||||
"Paperclip did not queue an automatic continuation because the latest successful run did not leave a safe runnable action path.",
|
||||
"",
|
||||
`- Liveness state: \`${livenessState ?? "unknown"}\``,
|
||||
`- Reason: ${decision.reason}`,
|
||||
].join("\n");
|
||||
|
||||
const updated = await escalateStrandedAssignedIssue({
|
||||
issue,
|
||||
previousStatus: "in_progress",
|
||||
latestRun,
|
||||
comment:
|
||||
"Paperclip observed a successful terminal run for this assigned `in_progress` issue, " +
|
||||
"but no live execution, queued wake, waiting interaction, approval, or recovery owner now holds the next action." +
|
||||
continuationDetail +
|
||||
"\n\nMoving it to `blocked` so it is visible for intervention.",
|
||||
});
|
||||
return updated ? "escalated" as const : "skipped" as const;
|
||||
}
|
||||
|
||||
async function isInvocationBudgetBlocked(issue: typeof issues.$inferSelect, agentId: string) {
|
||||
const budgetBlock = await budgets.getInvocationBlock(issue.companyId, agentId, {
|
||||
issueId: issue.id,
|
||||
@@ -1824,11 +1616,6 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await hasExplicitWaitingPath(issue)) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const latestRun = await getLatestIssueRun(issue.companyId, issue.id);
|
||||
if (isStrandedIssueRecoveryIssue(issue) && isUnsuccessfulTerminalIssueRun(latestRun)) {
|
||||
const updated = await escalateStrandedRecoveryIssueInPlace({
|
||||
@@ -1918,57 +1705,15 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const postRunDisposition = await classifyPostRunIssueDisposition(issue, latestRun);
|
||||
if (postRunDisposition !== "invalid") {
|
||||
if (postRunDisposition === "explicitly_live" && latestRun?.status === "succeeded") {
|
||||
if (isProductiveProgressRun(latestRun)) {
|
||||
result.productiveContinuationObserved += 1;
|
||||
} else {
|
||||
result.successfulContinuationObserved += 1;
|
||||
}
|
||||
if (isSuccessfulInProgressContinuationRun(latestRun)) {
|
||||
if (isProductiveContinuationRun(latestRun)) {
|
||||
result.productiveContinuationObserved += 1;
|
||||
} else {
|
||||
result.successfulContinuationObserved += 1;
|
||||
}
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (didAutomaticContinuationFinishWithoutLivePath(latestRun)) {
|
||||
const updated = await escalateStrandedAssignedIssue({
|
||||
issue,
|
||||
previousStatus: "in_progress",
|
||||
latestRun,
|
||||
comment:
|
||||
"Paperclip observed a successful automatic continuation for this assigned `in_progress` issue, " +
|
||||
"but the run has finished and no live execution, queued wake, waiting interaction, approval, or recovery owner now holds the next action. " +
|
||||
"Moving it to `blocked` so it is visible for intervention.",
|
||||
});
|
||||
if (updated) {
|
||||
result.escalated += 1;
|
||||
result.issueIds.push(issue.id);
|
||||
} else {
|
||||
result.skipped += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (latestRun?.status === "succeeded") {
|
||||
const successfulRunRecovery = await continueOrEscalateSuccessfulStrandedRun({
|
||||
issue,
|
||||
agent,
|
||||
latestRun,
|
||||
});
|
||||
if (successfulRunRecovery === "queued") {
|
||||
result.continuationRequeued += 1;
|
||||
result.issueIds.push(issue.id);
|
||||
} else if (successfulRunRecovery === "escalated") {
|
||||
result.escalated += 1;
|
||||
result.issueIds.push(issue.id);
|
||||
} else {
|
||||
result.skipped += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (didAutomaticRecoveryFail(latestRun, "issue_continuation_needed")) {
|
||||
const failureSummary = summarizeRunFailureForIssueComment(latestRun);
|
||||
const updated = await escalateStrandedAssignedIssue({
|
||||
@@ -2111,26 +1856,14 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
companyId: issueThreadInteractions.companyId,
|
||||
issueId: issueThreadInteractions.issueId,
|
||||
status: issueThreadInteractions.status,
|
||||
createdByUserId: issueThreadInteractions.createdByUserId,
|
||||
createdAt: issueThreadInteractions.createdAt,
|
||||
})
|
||||
.from(issueThreadInteractions)
|
||||
.where(
|
||||
and(
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
inArray(issueThreadInteractions.kind, EXPLICIT_WAITING_INTERACTION_KINDS),
|
||||
),
|
||||
),
|
||||
.where(eq(issueThreadInteractions.status, "pending")),
|
||||
db
|
||||
.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))
|
||||
|
||||
@@ -317,10 +317,6 @@ export function classifyRunLiveness(input: RunLivenessClassificationInput): RunL
|
||||
return output("completed", `Issue is ${issueStatus}`);
|
||||
}
|
||||
|
||||
if (issueStatus === "in_review") {
|
||||
return output("needs_followup", "Issue is in review and waiting on an explicit review path", nextAction);
|
||||
}
|
||||
|
||||
if (declaredBlocker(input)) {
|
||||
return output("blocked", issueStatus === "blocked" ? "Issue status is blocked" : "Run output declared a concrete blocker", nextAction);
|
||||
}
|
||||
@@ -329,20 +325,12 @@ export function classifyRunLiveness(input: RunLivenessClassificationInput): RunL
|
||||
return output("empty_response", "Run succeeded without useful output or concrete action evidence");
|
||||
}
|
||||
|
||||
if (planExempt && usefulOutput) {
|
||||
return output("advanced", "Planning/document task produced useful output and is exempt from plan-only classification");
|
||||
if (concreteEvidence) {
|
||||
return output("advanced", `Run produced concrete action evidence: ${evidenceReason(evidence)}`);
|
||||
}
|
||||
|
||||
if (concreteEvidence) {
|
||||
const reason = `Run produced concrete action evidence: ${evidenceReason(evidence)}`;
|
||||
if (nextAction && actionability === "runnable") {
|
||||
return output("advanced", `${reason}; runnable next action remains`, nextAction);
|
||||
}
|
||||
if (nextAction && (actionability === "manager_review" || actionability === "unknown")) {
|
||||
const disposition = actionability === "manager_review" ? "manager review" : "ambiguous review";
|
||||
return output("needs_followup", `${reason}; next action requires ${disposition}`, nextAction);
|
||||
}
|
||||
return output("advanced", reason);
|
||||
if (planExempt && usefulOutput) {
|
||||
return output("advanced", "Planning/document task produced useful output and is exempt from plan-only classification");
|
||||
}
|
||||
|
||||
if (looksLikePlanningOnly(input) || nextAction) {
|
||||
|
||||
@@ -218,55 +218,6 @@ describe("CommentThread", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows pause and resume timeline rows with the actor and time", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<CommentThread
|
||||
comments={[]}
|
||||
timelineEvents={[
|
||||
{
|
||||
id: "event-pause",
|
||||
actorType: "user",
|
||||
actorId: "local-board",
|
||||
createdAt: new Date("2026-03-11T10:00:00.000Z"),
|
||||
treeHoldChange: {
|
||||
action: "paused",
|
||||
mode: "pause",
|
||||
reason: "manual_issue_pause",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "event-resume",
|
||||
actorType: "user",
|
||||
actorId: "local-board",
|
||||
createdAt: new Date("2026-03-11T11:00:00.000Z"),
|
||||
treeHoldChange: {
|
||||
action: "resumed",
|
||||
mode: "pause",
|
||||
reason: "operator_resume",
|
||||
},
|
||||
},
|
||||
]}
|
||||
onAdd={async () => {}}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Board");
|
||||
expect(container.textContent).toContain("paused work");
|
||||
expect(container.textContent).toContain("resumed work");
|
||||
expect(container.textContent).toContain("2h ago");
|
||||
expect(container.textContent).toContain("1h ago");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the reopen control and infers reopen for closed agent-assigned issues", async () => {
|
||||
const root = createRoot(container);
|
||||
const onAdd = vi.fn(async () => {});
|
||||
|
||||
@@ -20,11 +20,7 @@ import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
|
||||
import { ApprovalCard } from "./ApprovalCard";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import {
|
||||
formatIssueTimelineTreeHoldAction,
|
||||
type IssueTimelineAssignee,
|
||||
type IssueTimelineEvent,
|
||||
} from "../lib/issue-timeline-events";
|
||||
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { cn, formatDateTime } from "../lib/utils";
|
||||
import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft";
|
||||
@@ -490,8 +486,7 @@ function TimelineEventCard({
|
||||
currentUserId?: string | null;
|
||||
}) {
|
||||
const actorName = formatTimelineActorName(event.actorType, event.actorId, agentMap, currentUserId);
|
||||
const actionLabel = formatIssueTimelineTreeHoldAction(event)
|
||||
?? (event.followUpRequested ? "requested follow-up" : "updated this task");
|
||||
const actionLabel = event.followUpRequested ? "requested follow-up" : "updated this task";
|
||||
|
||||
return (
|
||||
<div id={`activity-${event.id}`} className="flex items-start gap-2.5 py-1.5">
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { IssueBlockedNotice } from "./IssueBlockedNotice";
|
||||
|
||||
function renderNotice(node: React.ReactNode) {
|
||||
const queryClient = new QueryClient();
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{node}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("IssueBlockedNotice", () => {
|
||||
it("renders the rose recovery variant with parent-perspective copy and a wake-to-continue verb", () => {
|
||||
const html = renderNotice(
|
||||
<>
|
||||
<IssueBlockedNotice
|
||||
issueStatus="blocked"
|
||||
blockers={[
|
||||
{
|
||||
id: "issue-recovery-parent",
|
||||
identifier: "PAP-2089",
|
||||
title: "Liveness root",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
terminalBlockers: [
|
||||
{
|
||||
id: "issue-recovery-leaf",
|
||||
identifier: "PAP-2642",
|
||||
title: "Implementation phase",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
blockerAttention={{
|
||||
state: "recovery_needed",
|
||||
reason: "productive_run_stopped",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 0,
|
||||
stalledBlockerCount: 0,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-2642",
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
nextActionOwner: { type: "agent", agentId: "agent-1", userId: null },
|
||||
nextActionHint: "wake_to_continue",
|
||||
}}
|
||||
ownerAgentName="CodexCoder"
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-blocker-attention-state="recovery_needed"');
|
||||
expect(html).toContain('data-blocker-attention-reason="productive_run_stopped"');
|
||||
expect(html).toContain("paused at a liveness break");
|
||||
expect(html).toContain("Liveness break at");
|
||||
expect(html).toContain("Wake to continue");
|
||||
expect(html).toContain("PAP-2642");
|
||||
expect(html).toContain("border-rose-300/70");
|
||||
});
|
||||
|
||||
it("renders the leaf-perspective copy without blocker chips when the issue itself is the invalid leaf", () => {
|
||||
const html = renderNotice(
|
||||
<>
|
||||
<IssueBlockedNotice
|
||||
issueStatus="in_progress"
|
||||
blockers={[]}
|
||||
blockerAttention={{
|
||||
state: "recovery_needed",
|
||||
reason: "productive_run_stopped",
|
||||
unresolvedBlockerCount: 0,
|
||||
coveredBlockerCount: 0,
|
||||
stalledBlockerCount: 0,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-2642",
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
nextActionOwner: { type: "agent", agentId: "agent-1", userId: null },
|
||||
nextActionHint: "wake_to_continue",
|
||||
}}
|
||||
ownerAgentName="CodexCoder"
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(html).toContain("productive run that exited without queueing a continuation");
|
||||
expect(html).not.toContain("Liveness break at");
|
||||
expect(html).toContain("Wake to continue");
|
||||
});
|
||||
|
||||
it("falls back to amber stalled treatment when state is stalled rather than recovery_needed", () => {
|
||||
const html = renderNotice(
|
||||
<>
|
||||
<IssueBlockedNotice
|
||||
issueStatus="blocked"
|
||||
blockers={[
|
||||
{
|
||||
id: "issue-stalled",
|
||||
identifier: "PAP-2279",
|
||||
title: "Stage gate review",
|
||||
status: "in_review",
|
||||
priority: "medium",
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
},
|
||||
]}
|
||||
blockerAttention={{
|
||||
state: "stalled",
|
||||
reason: "stalled_review",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 0,
|
||||
stalledBlockerCount: 1,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-2279",
|
||||
sampleStalledBlockerIdentifier: "PAP-2279",
|
||||
nextActionOwner: null,
|
||||
nextActionHint: null,
|
||||
}}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Stalled in review");
|
||||
expect(html).not.toContain("Liveness break");
|
||||
expect(html).toContain("border-amber-300/70");
|
||||
});
|
||||
});
|
||||
@@ -2,43 +2,17 @@ import type { IssueBlockerAttention, IssueRelationIssueSummary } from "@papercli
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
||||
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
|
||||
import { Identity } from "./Identity";
|
||||
|
||||
type RecoveryReason =
|
||||
| "productive_run_stopped"
|
||||
| "continuation_exhausted"
|
||||
| "continuation_suppressed";
|
||||
|
||||
const RECOVERY_REASONS: ReadonlySet<string> = new Set<RecoveryReason>([
|
||||
"productive_run_stopped",
|
||||
"continuation_exhausted",
|
||||
"continuation_suppressed",
|
||||
]);
|
||||
|
||||
const NEXT_ACTION_VERB: Record<NonNullable<IssueBlockerAttention["nextActionHint"]>, string> = {
|
||||
wake_to_continue: "Wake to continue",
|
||||
needs_human_review: "Needs human review",
|
||||
create_recovery_issue: "Create recovery issue",
|
||||
reassign: "Reassign",
|
||||
};
|
||||
|
||||
export function IssueBlockedNotice({
|
||||
issueStatus,
|
||||
blockers,
|
||||
blockerAttention,
|
||||
ownerAgentName,
|
||||
ownerUserName,
|
||||
}: {
|
||||
issueStatus?: string;
|
||||
blockers: IssueRelationIssueSummary[];
|
||||
blockerAttention?: IssueBlockerAttention | null;
|
||||
ownerAgentName?: string | null;
|
||||
ownerUserName?: string | null;
|
||||
}) {
|
||||
const isRecoveryNeeded = blockerAttention?.state === "recovery_needed"
|
||||
&& blockerAttention.reason !== null
|
||||
&& RECOVERY_REASONS.has(blockerAttention.reason);
|
||||
if (blockers.length === 0 && issueStatus !== "blocked" && !isRecoveryNeeded) return null;
|
||||
if (blockers.length === 0 && issueStatus !== "blocked") return null;
|
||||
|
||||
const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues";
|
||||
const terminalBlockers = blockers
|
||||
@@ -67,171 +41,60 @@ export function IssueBlockedNotice({
|
||||
})();
|
||||
const showStalledRow = isStalled && stalledLeafBlockers.length > 0;
|
||||
|
||||
const recoveryLeafIdentifier = isRecoveryNeeded ? blockerAttention?.sampleBlockerIdentifier ?? null : null;
|
||||
const recoveryLeafBlockers = (() => {
|
||||
if (!isRecoveryNeeded) 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 (recoveryLeafIdentifier) {
|
||||
const preferred = candidates.find(
|
||||
(blocker) => (blocker.identifier ?? blocker.id) === recoveryLeafIdentifier,
|
||||
);
|
||||
if (preferred) {
|
||||
return [preferred, ...candidates.filter((blocker) => blocker.id !== preferred.id)];
|
||||
}
|
||||
}
|
||||
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";
|
||||
|
||||
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 renderBlockerChip = (blocker: IssueRelationIssueSummary, variant: "amber" | "rose" = "amber") => {
|
||||
const renderBlockerChip = (blocker: IssueRelationIssueSummary) => {
|
||||
const issuePathId = blocker.identifier ?? blocker.id;
|
||||
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={chipClass}
|
||||
className="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"
|
||||
>
|
||||
<span>{blocker.identifier ?? blocker.id.slice(0, 8)}</span>
|
||||
<span className={labelClass}>{blocker.title}</span>
|
||||
<span className="max-w-[18rem] truncate font-sans text-[11px] text-amber-800 dark:text-amber-200">
|
||||
{blocker.title}
|
||||
</span>
|
||||
</IssueLinkQuicklook>
|
||||
);
|
||||
};
|
||||
|
||||
const recoveryParentCopy = (reason: RecoveryReason, leafIdent: string) => {
|
||||
const leafChip = <span className="font-mono text-xs">[{leafIdent}]</span>;
|
||||
if (reason === "productive_run_stopped") {
|
||||
return (
|
||||
<>Work on this issue is <strong>paused at a liveness break in {leafChip}</strong>. The last run was productive but exited without queueing the next step, so nothing will resume on its own.</>
|
||||
);
|
||||
}
|
||||
if (reason === "continuation_exhausted") {
|
||||
return (
|
||||
<>Work on this issue is <strong>stuck at {leafChip}</strong>. Automatic continuation has been used up, so a human or recovery issue is needed before it moves again.</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>Work on this issue is <strong>stuck at {leafChip}</strong> because automatic continuation was held back. It needs review before resuming.</>
|
||||
);
|
||||
};
|
||||
|
||||
const recoveryLeafCopy = (reason: RecoveryReason) => {
|
||||
if (reason === "productive_run_stopped") {
|
||||
return (
|
||||
<>This issue had a <strong>productive run that exited without queueing a continuation</strong>. It is non-terminal with no live action path.</>
|
||||
);
|
||||
}
|
||||
if (reason === "continuation_exhausted") {
|
||||
return (
|
||||
<>This issue's <strong>automatic continuation has been exhausted</strong>. It is non-terminal with no live action path.</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>This issue's <strong>automatic continuation was suppressed</strong> and it is non-terminal with no live action path.</>
|
||||
);
|
||||
};
|
||||
|
||||
const ownerPill = (() => {
|
||||
if (!isRecoveryNeeded) return null;
|
||||
const owner = blockerAttention?.nextActionOwner ?? null;
|
||||
const hint = blockerAttention?.nextActionHint ?? null;
|
||||
const verb = hint ? NEXT_ACTION_VERB[hint] : null;
|
||||
let ownerNode: React.ReactNode = "No one assigned";
|
||||
if (owner?.type === "agent" && owner.agentId) {
|
||||
ownerNode = (
|
||||
<Identity name={ownerAgentName ?? "Agent"} size="xs" />
|
||||
);
|
||||
} else if (owner?.type === "user" && owner.userId) {
|
||||
ownerNode = (
|
||||
<Identity name={ownerUserName ?? "User"} size="xs" />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5 text-xs text-rose-900 dark:text-rose-100">
|
||||
{verb ? (
|
||||
<>
|
||||
<span>Next action:</span>
|
||||
<span className="font-medium">{verb}</span>
|
||||
<span aria-hidden>·</span>
|
||||
</>
|
||||
) : null}
|
||||
<span>Owner:</span>
|
||||
<span className="font-medium">{ownerNode}</span>
|
||||
</div>
|
||||
);
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-blocker-attention-state={blockerAttention?.state}
|
||||
data-blocker-attention-reason={blockerAttention?.reason ?? undefined}
|
||||
className={containerClass}
|
||||
className="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"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className={iconClass} />
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<p className="leading-5">
|
||||
{isRecoveryNeeded
|
||||
? isLeafSurface
|
||||
? recoveryLeafCopy(blockerAttention!.reason as RecoveryReason)
|
||||
: recoveryParentCopy(
|
||||
blockerAttention!.reason as RecoveryReason,
|
||||
recoveryLeafIdentifier ?? "linked issue",
|
||||
)
|
||||
: blockers.length > 0
|
||||
? isStalled
|
||||
? stalledLeafBlockers.length > 1
|
||||
? <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled reviews below or remove them as blockers.</>
|
||||
: <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled review below or remove it as a blocker.</>
|
||||
: <>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.</>}
|
||||
{blockers.length > 0
|
||||
? isStalled
|
||||
? stalledLeafBlockers.length > 1
|
||||
? <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled reviews below or remove them as blockers.</>
|
||||
: <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled review below or remove it as a blocker.</>
|
||||
: <>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>
|
||||
{!isRecoveryNeeded && blockers.length > 0 ? (
|
||||
{blockers.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{blockers.map((blocker) => renderBlockerChip(blocker, "amber"))}
|
||||
{blockers.map(renderBlockerChip)}
|
||||
</div>
|
||||
) : null}
|
||||
{isRecoveryNeeded && !isLeafSurface && recoveryLeafBlockers.length > 0 ? (
|
||||
{showStalledRow ? (
|
||||
<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"))}
|
||||
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
|
||||
Stalled in review
|
||||
</span>
|
||||
{stalledLeafBlockers.map(renderBlockerChip)}
|
||||
</div>
|
||||
) : terminalBlockers.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
|
||||
Ultimately waiting on
|
||||
</span>
|
||||
{terminalBlockers.map(renderBlockerChip)}
|
||||
</div>
|
||||
) : null}
|
||||
{!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>
|
||||
) : !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"))}
|
||||
</div>
|
||||
) : null}
|
||||
{ownerPill}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1920,7 +1920,6 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
|
||||
const actorName = typeof custom.actorName === "string" ? custom.actorName : null;
|
||||
const actorType = typeof custom.actorType === "string" ? custom.actorType : null;
|
||||
const actorId = typeof custom.actorId === "string" ? custom.actorId : null;
|
||||
const treeHoldActionLabel = typeof custom.treeHoldActionLabel === "string" ? custom.treeHoldActionLabel : null;
|
||||
const statusChange = typeof custom.statusChange === "object" && custom.statusChange
|
||||
? custom.statusChange as { from: string | null; to: string | null }
|
||||
: null;
|
||||
@@ -1973,7 +1972,7 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
|
||||
<div className={cn("flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5 text-xs", isCurrentUser && "justify-end")}>
|
||||
<span className="font-medium text-foreground">{actorName}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{treeHoldActionLabel ?? (custom.followUpRequested === true ? "requested follow-up" : "updated this task")}
|
||||
{custom.followUpRequested === true ? "requested follow-up" : "updated this task"}
|
||||
</span>
|
||||
<a
|
||||
href={anchorId ? `#${anchorId}` : undefined}
|
||||
|
||||
@@ -407,8 +407,6 @@ describe("IssueProperties", () => {
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-2",
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
nextActionOwner: null,
|
||||
nextActionHint: null,
|
||||
},
|
||||
}),
|
||||
childIssues: [],
|
||||
|
||||
@@ -65,13 +65,6 @@ export function IssueRow({
|
||||
const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined;
|
||||
const detailState = withIssueDetailHeaderSeed(issueLinkState, issue);
|
||||
const productivityReview = issue.productivityReview ?? null;
|
||||
const recoveryLeafIdentifier =
|
||||
issue.blockerAttention?.state === "recovery_needed"
|
||||
? issue.blockerAttention.sampleBlockerIdentifier
|
||||
: null;
|
||||
const recoveryLeafLabel = recoveryLeafIdentifier && recoveryLeafIdentifier !== identifier
|
||||
? `liveness break at ${recoveryLeafIdentifier}`
|
||||
: null;
|
||||
const productivityReviewIndicator = productivityReview ? (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -137,14 +130,6 @@ export function IssueRow({
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{recoveryLeafLabel ? (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{recoveryLeafLabel}</span>
|
||||
</>
|
||||
) : null}
|
||||
{mobileMeta ? (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground sm:hidden" aria-hidden="true">
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { ThemeProvider } from "../context/ThemeContext";
|
||||
import {
|
||||
RUN_CANCEL_ISSUE_PAUSE_WARNING,
|
||||
RunCancelIssuePauseWarning,
|
||||
RunInvocationCard,
|
||||
} from "../pages/AgentDetail";
|
||||
import { RunInvocationCard } from "../pages/AgentDetail";
|
||||
|
||||
describe("RunInvocationCard", () => {
|
||||
it("keeps verbose invocation details collapsed by default", () => {
|
||||
@@ -39,12 +35,4 @@ describe("RunInvocationCard", () => {
|
||||
expect(html).not.toContain("ANTHROPIC_API_KEY");
|
||||
expect(html).not.toContain("triggeredBy");
|
||||
});
|
||||
|
||||
it("renders the raw run cancellation warning copy", () => {
|
||||
const html = renderToStaticMarkup(<RunCancelIssuePauseWarning />);
|
||||
|
||||
expect(html).toContain(RUN_CANCEL_ISSUE_PAUSE_WARNING);
|
||||
expect(html).toContain("does not pause the issue");
|
||||
expect(html).toContain("Pause work");
|
||||
});
|
||||
});
|
||||
@@ -18,8 +18,6 @@ describe("StatusIcon", () => {
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-2",
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
nextActionOwner: null,
|
||||
nextActionHint: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
@@ -46,8 +44,6 @@ describe("StatusIcon", () => {
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: null,
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
nextActionOwner: null,
|
||||
nextActionHint: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
@@ -70,8 +66,6 @@ describe("StatusIcon", () => {
|
||||
attentionBlockerCount: 1,
|
||||
sampleBlockerIdentifier: "PAP-2",
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
nextActionOwner: null,
|
||||
nextActionHint: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
@@ -82,53 +76,6 @@ describe("StatusIcon", () => {
|
||||
expect(html).not.toContain("border-dashed");
|
||||
});
|
||||
|
||||
it("renders recovery_needed leaves with the alert overlay and liveness-break tooltip", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<StatusIcon
|
||||
status="blocked"
|
||||
blockerAttention={{
|
||||
state: "recovery_needed",
|
||||
reason: "productive_run_stopped",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 0,
|
||||
stalledBlockerCount: 0,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-2642",
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
nextActionOwner: { type: "agent", agentId: "agent-1", userId: null },
|
||||
nextActionHint: "wake_to_continue",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-blocker-attention-state="recovery_needed"');
|
||||
expect(html).toContain('aria-label="Blocked · liveness break at PAP-2642 · productive run stopped without continuation"');
|
||||
expect(html).toContain("border-red-600");
|
||||
expect(html).toContain("text-rose-500");
|
||||
});
|
||||
|
||||
it("uses continuation_exhausted copy when the chain ran out of automatic continuations", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<StatusIcon
|
||||
status="blocked"
|
||||
blockerAttention={{
|
||||
state: "recovery_needed",
|
||||
reason: "continuation_exhausted",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 0,
|
||||
stalledBlockerCount: 0,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-2642",
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
nextActionOwner: { type: "agent", agentId: "agent-1", userId: null },
|
||||
nextActionHint: "create_recovery_issue",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('aria-label="Blocked · liveness break at PAP-2642 · automatic continuation exhausted"');
|
||||
});
|
||||
|
||||
it("renders stalled review chains with amber visual and stalled-leaf copy", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<StatusIcon
|
||||
@@ -142,8 +89,6 @@ describe("StatusIcon", () => {
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-2279",
|
||||
sampleStalledBlockerIdentifier: "PAP-2279",
|
||||
nextActionOwner: null,
|
||||
nextActionHint: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import type { IssueBlockerAttention } from "@paperclipai/shared";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { issueStatusIcon, issueStatusIconDefault } from "../lib/status-colors";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
@@ -49,26 +48,6 @@ function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null |
|
||||
return `Blocked · ${count} reviews stalled with no clear next step`;
|
||||
}
|
||||
|
||||
if (blockerAttention.state === "recovery_needed") {
|
||||
const leaf = blockerAttention.sampleBlockerIdentifier;
|
||||
if (blockerAttention.reason === "productive_run_stopped") {
|
||||
return leaf
|
||||
? `Blocked · liveness break at ${leaf} · productive run stopped without continuation`
|
||||
: "Blocked · liveness break · productive run stopped without continuation";
|
||||
}
|
||||
if (blockerAttention.reason === "continuation_exhausted") {
|
||||
return leaf
|
||||
? `Blocked · liveness break at ${leaf} · automatic continuation exhausted`
|
||||
: "Blocked · liveness break · automatic continuation exhausted";
|
||||
}
|
||||
if (blockerAttention.reason === "continuation_suppressed") {
|
||||
return leaf
|
||||
? `Blocked · liveness break at ${leaf} · automatic continuation suppressed`
|
||||
: "Blocked · liveness break · automatic continuation suppressed";
|
||||
}
|
||||
return "Blocked · liveness break needs recovery";
|
||||
}
|
||||
|
||||
if (blockerAttention.reason === "attention_required") {
|
||||
const count = blockerAttention.unresolvedBlockerCount;
|
||||
return `Blocked · ${count} unresolved ${count === 1 ? "blocker needs" : "blockers need"} attention`;
|
||||
@@ -81,25 +60,18 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
|
||||
const [open, setOpen] = useState(false);
|
||||
const isCoveredBlocked = status === "blocked" && blockerAttention?.state === "covered";
|
||||
const isStalledBlocked = status === "blocked" && blockerAttention?.state === "stalled";
|
||||
const isRecoveryNeeded = blockerAttention?.state === "recovery_needed";
|
||||
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;
|
||||
: issueStatusIcon[status] ?? issueStatusIconDefault;
|
||||
const isDone = status === "done";
|
||||
const recoveryAriaLabel = isRecoveryNeeded ? blockedAttentionLabel(blockerAttention) : null;
|
||||
const ariaLabel = recoveryAriaLabel
|
||||
?? (status === "blocked" ? blockedAttentionLabel(blockerAttention) : statusLabel(status));
|
||||
const ariaLabel = status === "blocked" ? blockedAttentionLabel(blockerAttention) : statusLabel(status);
|
||||
const blockerAttentionState = isCoveredBlocked
|
||||
? "covered"
|
||||
: isStalledBlocked
|
||||
? "stalled"
|
||||
: isRecoveryNeeded
|
||||
? "recovery_needed"
|
||||
: undefined;
|
||||
: undefined;
|
||||
|
||||
const circle = (
|
||||
<span
|
||||
@@ -122,14 +94,6 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
|
||||
{isStalledBlocked && (
|
||||
<span className="absolute inset-0 m-auto h-1.5 w-1.5 rounded-full bg-current" />
|
||||
)}
|
||||
{isRecoveryNeeded && (
|
||||
<AlertTriangle
|
||||
aria-hidden
|
||||
className="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full bg-background text-rose-500 dark:text-rose-300"
|
||||
strokeWidth={3}
|
||||
fill="currentColor"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
buildIssueThreadInteractionSummary,
|
||||
type IssueThreadInteraction,
|
||||
} from "./issue-thread-interactions";
|
||||
import { formatIssueTimelineTreeHoldAction, type IssueTimelineEvent } from "./issue-timeline-events";
|
||||
import type { IssueTimelineEvent } from "./issue-timeline-events";
|
||||
import {
|
||||
summarizeNotice,
|
||||
} from "./transcriptPresentation";
|
||||
@@ -359,12 +359,9 @@ function createTimelineEventMessage(args: {
|
||||
: event.actorType === "system"
|
||||
? "System"
|
||||
: (formatAssigneeUserLabel(event.actorId, currentUserId, userLabelMap) ?? "Board");
|
||||
const treeHoldActionLabel = formatIssueTimelineTreeHoldAction(event);
|
||||
|
||||
const lines: string[] = [
|
||||
treeHoldActionLabel
|
||||
? `${actorName} ${treeHoldActionLabel}`
|
||||
: event.followUpRequested ? `${actorName} requested follow-up` : `${actorName} updated this issue`,
|
||||
event.followUpRequested ? `${actorName} requested follow-up` : `${actorName} updated this issue`,
|
||||
];
|
||||
if (event.statusChange) {
|
||||
lines.push(
|
||||
@@ -394,7 +391,6 @@ function createTimelineEventMessage(args: {
|
||||
actorName,
|
||||
actorType: event.actorType,
|
||||
actorId: event.actorId,
|
||||
treeHoldActionLabel,
|
||||
statusChange: event.statusChange ?? null,
|
||||
assigneeChange: event.assigneeChange ?? null,
|
||||
followUpRequested: event.followUpRequested === true,
|
||||
|
||||
@@ -200,70 +200,6 @@ describe("extractIssueTimelineEvents", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("extracts pause and resume tree hold activity with actor attribution", () => {
|
||||
const events = extractIssueTimelineEvents([
|
||||
{
|
||||
id: "evt-pause",
|
||||
companyId: "company-1",
|
||||
actorType: "user",
|
||||
actorId: "board-user-1",
|
||||
action: "issue.tree_hold_created",
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
createdAt: new Date("2026-03-31T12:01:00.000Z"),
|
||||
details: {
|
||||
holdId: "hold-1",
|
||||
mode: "pause",
|
||||
reason: "manual_issue_pause",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "evt-resume",
|
||||
companyId: "company-1",
|
||||
actorType: "user",
|
||||
actorId: "board-user-1",
|
||||
action: "issue.tree_hold_released",
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
createdAt: new Date("2026-03-31T12:02:00.000Z"),
|
||||
details: {
|
||||
holdId: "hold-1",
|
||||
mode: "pause",
|
||||
reason: "resume_after_review",
|
||||
},
|
||||
},
|
||||
] satisfies ActivityEvent[]);
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
id: "evt-pause",
|
||||
createdAt: new Date("2026-03-31T12:01:00.000Z"),
|
||||
actorType: "user",
|
||||
actorId: "board-user-1",
|
||||
treeHoldChange: {
|
||||
action: "paused",
|
||||
mode: "pause",
|
||||
reason: "manual_issue_pause",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "evt-resume",
|
||||
createdAt: new Date("2026-03-31T12:02:00.000Z"),
|
||||
actorType: "user",
|
||||
actorId: "board-user-1",
|
||||
treeHoldChange: {
|
||||
action: "resumed",
|
||||
mode: "pause",
|
||||
reason: "resume_after_review",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores issue updates without visible status or assignee transitions", () => {
|
||||
const events = extractIssueTimelineEvents([
|
||||
{
|
||||
|
||||
@@ -5,18 +5,11 @@ export interface IssueTimelineAssignee {
|
||||
userId: string | null;
|
||||
}
|
||||
|
||||
type IssueTreeHoldTimelineAction = "paused" | "resumed" | "cancelled" | "restored";
|
||||
|
||||
export interface IssueTimelineEvent {
|
||||
id: string;
|
||||
createdAt: Date | string;
|
||||
actorType: ActivityEvent["actorType"];
|
||||
actorId: string;
|
||||
treeHoldChange?: {
|
||||
action: IssueTreeHoldTimelineAction;
|
||||
mode: string | null;
|
||||
reason: string | null;
|
||||
};
|
||||
statusChange?: {
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
@@ -42,12 +35,6 @@ function nullableString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function treeHoldAction(eventAction: string, mode: string | null): IssueTreeHoldTimelineAction | null {
|
||||
if (eventAction === "issue.tree_hold_created") return mode === "cancel" ? "cancelled" : "paused";
|
||||
if (eventAction === "issue.tree_hold_released") return mode === "cancel" ? "restored" : "resumed";
|
||||
return null;
|
||||
}
|
||||
|
||||
function toTimestamp(value: Date | string) {
|
||||
return new Date(value).getTime();
|
||||
}
|
||||
@@ -56,15 +43,6 @@ function sameAssignee(left: IssueTimelineAssignee, right: IssueTimelineAssignee)
|
||||
return left.agentId === right.agentId && left.userId === right.userId;
|
||||
}
|
||||
|
||||
export function formatIssueTimelineTreeHoldAction(event: IssueTimelineEvent): string | null {
|
||||
const action = event.treeHoldChange?.action;
|
||||
if (!action) return null;
|
||||
if (action === "paused") return "paused work";
|
||||
if (action === "resumed") return "resumed work";
|
||||
if (action === "cancelled") return "cancelled this subtree";
|
||||
return "restored this subtree";
|
||||
}
|
||||
|
||||
function sortTimelineEvents<T extends { createdAt: Date | string; id: string }>(events: T[]) {
|
||||
return [...events].sort((a, b) => {
|
||||
const createdAtDiff = toTimestamp(a.createdAt) - toTimestamp(b.createdAt);
|
||||
@@ -95,24 +73,6 @@ export function extractIssueTimelineEvents(activity: ActivityEvent[] | null | un
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.action === "issue.tree_hold_created" || event.action === "issue.tree_hold_released") {
|
||||
const mode = nullableString(details.mode);
|
||||
const action = treeHoldAction(event.action, mode);
|
||||
if (!action) continue;
|
||||
events.push({
|
||||
id: event.id,
|
||||
createdAt: event.createdAt,
|
||||
actorType: event.actorType,
|
||||
actorId: event.actorId,
|
||||
treeHoldChange: {
|
||||
action,
|
||||
mode,
|
||||
reason: nullableString(details.reason),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.action !== "issue.updated") continue;
|
||||
|
||||
const previous = asRecord(details._previous);
|
||||
|
||||
@@ -111,8 +111,6 @@ const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string
|
||||
};
|
||||
|
||||
const RUN_LOG_PAGE_BYTES = 256_000;
|
||||
export const RUN_CANCEL_ISSUE_PAUSE_WARNING =
|
||||
"Cancelling this process does not pause the issue. Use Pause work on the issue page if you want to stop further runs.";
|
||||
|
||||
const REDACTED_ENV_VALUE = "***REDACTED***";
|
||||
const SECRET_ENV_KEY_RE =
|
||||
@@ -179,14 +177,6 @@ const sourceLabels: Record<string, string> = {
|
||||
const LIVE_SCROLL_BOTTOM_TOLERANCE_PX = 32;
|
||||
type ScrollContainer = Window | HTMLElement;
|
||||
|
||||
export function RunCancelIssuePauseWarning() {
|
||||
return (
|
||||
<div className="max-w-2xl rounded-md border border-amber-500/30 bg-amber-500/10 px-2.5 py-2 text-xs text-amber-900 dark:text-amber-100">
|
||||
{RUN_CANCEL_ISSUE_PAUSE_WARNING}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isWindowContainer(container: ScrollContainer): container is Window {
|
||||
return container === window;
|
||||
}
|
||||
@@ -3215,9 +3205,6 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{(run.status === "running" || run.status === "queued") && (
|
||||
<RunCancelIssuePauseWarning />
|
||||
)}
|
||||
{/* Adapter type · provider · model */}
|
||||
{(() => {
|
||||
const displayProvider = metrics.provider
|
||||
|
||||
@@ -849,8 +849,6 @@ describe("IssueDetail", () => {
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-2",
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
nextActionOwner: null,
|
||||
nextActionHint: null,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -61,8 +61,6 @@ function attention(
|
||||
attentionBlockerCount: partial.attentionBlockerCount ?? 0,
|
||||
sampleBlockerIdentifier: partial.sampleBlockerIdentifier ?? null,
|
||||
sampleStalledBlockerIdentifier: partial.sampleStalledBlockerIdentifier ?? null,
|
||||
nextActionOwner: partial.nextActionOwner ?? null,
|
||||
nextActionHint: partial.nextActionHint ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -190,64 +188,6 @@ const coveredBlockedMatrix: CoveredBlockedCell[] = [
|
||||
expectedVisual: "yellow ring",
|
||||
expectedCopy: "In Progress",
|
||||
},
|
||||
{
|
||||
label: "Recovery needed (productive run stopped)",
|
||||
status: "blocked",
|
||||
blockerAttention: attention({
|
||||
state: "recovery_needed",
|
||||
reason: "productive_run_stopped",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-2642",
|
||||
nextActionOwner: { type: "agent", agentId: "agent-1", userId: null },
|
||||
nextActionHint: "wake_to_continue",
|
||||
}),
|
||||
expectedVisual: "red ring + alert overlay",
|
||||
expectedCopy: "Blocked · liveness break at PAP-2642 · productive run stopped without continuation",
|
||||
},
|
||||
{
|
||||
label: "Recovery needed (continuation exhausted)",
|
||||
status: "blocked",
|
||||
blockerAttention: attention({
|
||||
state: "recovery_needed",
|
||||
reason: "continuation_exhausted",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-2642",
|
||||
nextActionOwner: { type: "agent", agentId: "agent-1", userId: null },
|
||||
nextActionHint: "create_recovery_issue",
|
||||
}),
|
||||
expectedVisual: "red ring + alert overlay",
|
||||
expectedCopy: "Blocked · liveness break at PAP-2642 · automatic continuation exhausted",
|
||||
},
|
||||
{
|
||||
label: "Recovery needed (continuation suppressed)",
|
||||
status: "blocked",
|
||||
blockerAttention: attention({
|
||||
state: "recovery_needed",
|
||||
reason: "continuation_suppressed",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-2642",
|
||||
nextActionOwner: { type: "agent", agentId: "agent-1", userId: null },
|
||||
nextActionHint: "needs_human_review",
|
||||
}),
|
||||
expectedVisual: "red ring + alert overlay",
|
||||
expectedCopy: "Blocked · liveness break at PAP-2642 · automatic continuation suppressed",
|
||||
},
|
||||
{
|
||||
label: "Recovery needed (leaf surface, productive run stopped)",
|
||||
status: "in_progress",
|
||||
blockerAttention: attention({
|
||||
state: "recovery_needed",
|
||||
reason: "productive_run_stopped",
|
||||
sampleBlockerIdentifier: "PAP-2642",
|
||||
nextActionOwner: { type: "agent", agentId: "agent-1", userId: null },
|
||||
nextActionHint: "wake_to_continue",
|
||||
}),
|
||||
expectedVisual: "yellow ring + alert overlay",
|
||||
expectedCopy: "Blocked · liveness break at PAP-2642 · productive run stopped without continuation",
|
||||
},
|
||||
];
|
||||
|
||||
const coveredBlockedIssue = createIssue({
|
||||
@@ -280,17 +220,13 @@ function summaryBlocker(
|
||||
type BlockedNoticeStateLabel =
|
||||
| "Default covered"
|
||||
| "Stalled (single leaf)"
|
||||
| "Stalled (multiple leaves)"
|
||||
| "Recovery needed (parent perspective)"
|
||||
| "Recovery needed (leaf perspective)";
|
||||
| "Stalled (multiple leaves)";
|
||||
|
||||
type BlockedNoticeFixture = {
|
||||
label: BlockedNoticeStateLabel;
|
||||
caption: string;
|
||||
issueStatus: string;
|
||||
blockers: IssueRelationIssueSummary[];
|
||||
blockerAttention: IssueBlockerAttention;
|
||||
ownerAgentName?: string;
|
||||
};
|
||||
|
||||
const stalledLeafSingle = summaryBlocker({
|
||||
@@ -314,18 +250,10 @@ const stalledLeafMultiSecondary = summaryBlocker({
|
||||
status: "in_review",
|
||||
});
|
||||
|
||||
const recoveryLeafSummary = summaryBlocker({
|
||||
id: "issue-recovery-leaf",
|
||||
identifier: "PAP-2642",
|
||||
title: "Implementation phase that exited without continuation",
|
||||
status: "in_progress",
|
||||
});
|
||||
|
||||
const blockedNoticeFixtures: BlockedNoticeFixture[] = [
|
||||
{
|
||||
label: "Default covered",
|
||||
caption: "Active sub-issue covers the chain — informational only.",
|
||||
issueStatus: "blocked",
|
||||
blockers: [
|
||||
summaryBlocker({
|
||||
id: "issue-active-child",
|
||||
@@ -345,7 +273,6 @@ const blockedNoticeFixtures: BlockedNoticeFixture[] = [
|
||||
{
|
||||
label: "Stalled (single leaf)",
|
||||
caption: "Chain stalled on one leaf review — copy names the leaf and shows the chip strip.",
|
||||
issueStatus: "blocked",
|
||||
blockers: [
|
||||
summaryBlocker({
|
||||
id: "issue-stalled-parent-single",
|
||||
@@ -367,7 +294,6 @@ const blockedNoticeFixtures: BlockedNoticeFixture[] = [
|
||||
{
|
||||
label: "Stalled (multiple leaves)",
|
||||
caption: "Multiple stalled reviews — body uses plural agreement (\"reviews\"/\"them\") to match the chip strip.",
|
||||
issueStatus: "blocked",
|
||||
blockers: [
|
||||
summaryBlocker({
|
||||
id: "issue-stalled-parent-multi-a",
|
||||
@@ -392,43 +318,6 @@ const blockedNoticeFixtures: BlockedNoticeFixture[] = [
|
||||
sampleStalledBlockerIdentifier: "PAP-2284",
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Recovery needed (parent perspective)",
|
||||
caption: "Blocked parent whose dependency leaf had a productive run that exited without queueing a continuation.",
|
||||
issueStatus: "blocked",
|
||||
blockers: [
|
||||
summaryBlocker({
|
||||
id: "issue-recovery-parent",
|
||||
identifier: "PAP-2089",
|
||||
title: "Liveness forensics root",
|
||||
status: "blocked",
|
||||
terminalBlockers: [recoveryLeafSummary],
|
||||
}),
|
||||
],
|
||||
blockerAttention: attention({
|
||||
state: "recovery_needed",
|
||||
reason: "productive_run_stopped",
|
||||
unresolvedBlockerCount: 1,
|
||||
sampleBlockerIdentifier: "PAP-2642",
|
||||
nextActionOwner: { type: "agent", agentId: "agent-codex", userId: null },
|
||||
nextActionHint: "wake_to_continue",
|
||||
}),
|
||||
ownerAgentName: "CodexSparkCoder",
|
||||
},
|
||||
{
|
||||
label: "Recovery needed (leaf perspective)",
|
||||
caption: "Same component on the invalid leaf itself — leaf-perspective copy, no chip strip, owner pill is the leaf assignee.",
|
||||
issueStatus: "in_progress",
|
||||
blockers: [],
|
||||
blockerAttention: attention({
|
||||
state: "recovery_needed",
|
||||
reason: "productive_run_stopped",
|
||||
sampleBlockerIdentifier: "PAP-2642",
|
||||
nextActionOwner: { type: "agent", agentId: "agent-codex", userId: null },
|
||||
nextActionHint: "wake_to_continue",
|
||||
}),
|
||||
ownerAgentName: "CodexSparkCoder",
|
||||
},
|
||||
];
|
||||
|
||||
function BlockedNoticeSurface({
|
||||
@@ -453,10 +342,9 @@ function BlockedNoticeSurface({
|
||||
</div>
|
||||
<div className={isMobile ? "max-w-[358px] px-3 py-3" : "min-w-[620px] px-4 py-3"}>
|
||||
<IssueBlockedNotice
|
||||
issueStatus={fixture.issueStatus}
|
||||
issueStatus="blocked"
|
||||
blockers={fixture.blockers}
|
||||
blockerAttention={fixture.blockerAttention}
|
||||
ownerAgentName={fixture.ownerAgentName}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">{fixture.caption}</p>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user