[codex] Add source-scoped recovery actions (#5599)

## Thinking Path

> - Paperclip is a control plane for autonomous AI companies, where work
must end with a clear disposition rather than ambiguous agent liveness.
> - Recovery currently detects stalled or missing-next-step issues, but
source issue recovery can become split across child recovery issues,
blockers, and comments.
> - That makes it harder for operators and agents to see who owns
recovery and what exact action is needed on the original issue.
> - Source-scoped recovery actions give the original issue a first-class
active recovery state with owner, evidence, wake policy, and resolution
outcome.
> - This pull request adds the recovery-action data model, backend
reconciliation and resolution APIs, and board UI indicators/actions.
> - The benefit is clearer stalled-work recovery without losing source
issue context or relying on comments as the liveness path.

## What Changed

- Added the `issue_recovery_actions` schema, shared
types/constants/validators, and an idempotent
`0084_issue_recovery_actions` migration ordered after current `master`
migrations.
- Updated stranded/missing-disposition recovery to create source-scoped
recovery actions, wake the recovery owner on the source issue, and avoid
locking the source issue for recovery-action wakes.
- Added API support for reading active recovery actions on issue
detail/list surfaces and resolving them with restored, blocked,
cancelled, or false-positive outcomes.
- Require blocked recovery resolutions to have an unresolved first-class
blocker, and removed the UI shortcut that could mark recovery blocked
without a blocker selection path.
- Surfaced recovery indicators/actions in the issue UI, blocker notices,
active run panels, issue rows, and Storybook coverage.
- Updated docs and focused tests for recovery semantics, ownership,
races, stale comments, and UI behavior.

## Verification

- `pnpm exec vitest run
server/src/__tests__/issue-recovery-actions.test.ts
server/src/__tests__/heartbeat-process-recovery.test.ts
ui/src/components/IssueRecoveryActionCard.test.tsx
ui/src/components/IssueBlockedNotice.test.tsx ui/src/api/issues.test.ts`
— 5 files, 72 tests passed.
- `pnpm --filter @paperclipai/shared typecheck` — passed.
- `pnpm --filter @paperclipai/db typecheck` — passed, including
migration numbering check.
- `pnpm --filter @paperclipai/server typecheck` — passed.
- `pnpm --filter @paperclipai/ui typecheck` — passed.
- Follow-up verification after blocker-resolution guard: `pnpm exec
vitest run server/src/__tests__/issue-recovery-actions.test.ts
ui/src/components/IssueRecoveryActionCard.test.tsx
ui/src/api/issues.test.ts` — 3 files, 27 tests passed.
- Follow-up `pnpm --filter @paperclipai/server typecheck` — passed.
- Follow-up `pnpm --filter @paperclipai/ui typecheck` — passed.
- UI states are available in
`ui/storybook/stories/source-issue-recovery.stories.tsx`; screenshot
capture helper is `scripts/screenshot-recovery-card.cjs`.

## Risks

- Medium: recovery behavior changes from child recovery issue ownership
toward source-scoped actions, so operators may see stalled-work state in
new places.
- Migration risk is mitigated by using the next migration slot after
`master` and making the table/constraints/index creation idempotent for
anyone who previously applied the old branch-local
`0082_dizzy_master_mold` migration.
- Existing child recovery issue paths are still guarded for
already-created recovery issues, but new source-scoped flows should be
watched in CI and Greptile review.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool use enabled for shell, Git,
GitHub, and local test execution. Context window not exposed by the
runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-05-12 09:37:15 -05:00
committed by GitHub
parent c445e59256
commit 0808b388ee
57 changed files with 3947 additions and 224 deletions
+3 -2
View File
@@ -37,7 +37,7 @@ These decisions close open questions from `SPEC.md` for V1.
| Visibility | Full visibility to board and all agents in same company | | Visibility | Full visibility to board and all agents in same company |
| Communication | Tasks + comments only (no separate chat system) | | Communication | Tasks + comments only (no separate chat system) |
| Task ownership | Single assignee; atomic checkout required for `in_progress` transition | | Task ownership | Single assignee; atomic checkout required for `in_progress` transition |
| Recovery | Liveness/watchdog recovery preserves explicit ownership: retry lost execution continuity where safe, otherwise create visible recovery issues or require human escalation (see `doc/execution-semantics.md`) | | Recovery | Liveness/watchdog recovery preserves explicit ownership: retry lost execution continuity where safe, otherwise open visible source-scoped recovery actions by default, use issue-backed recovery only for independent repair work, or require human escalation (see `doc/execution-semantics.md`) |
| Agent adapters | Built-in `process`, `http`, local CLI/session adapters, and OpenClaw gateway support; external adapters can also be loaded through the adapter plugin flow | | Agent adapters | Built-in `process`, `http`, local CLI/session adapters, and OpenClaw gateway support; external adapters can also be loaded through the adapter plugin flow |
| Plugin framework | Local/self-hosted early plugin runtime is in scope; cloud marketplace and packaged public distribution remain out of scope | | Plugin framework | Local/self-hosted early plugin runtime is in scope; cloud marketplace and packaged public distribution remain out of scope |
| Auth | Mode-dependent human auth (`local_trusted` implicit board in current code; authenticated mode uses sessions), API keys for agents | | Auth | Mode-dependent human auth (`local_trusted` implicit board in current code; authenticated mode uses sessions), API keys for agents |
@@ -434,9 +434,10 @@ Side effects:
V1 non-terminal liveness rule: 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 - 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
- `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 - `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 action owns the next action
- a blocked chain is covered only when each unresolved leaf issue is live or explicitly waiting - 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 - when Paperclip cannot safely infer the next action, it surfaces the problem through visible blocked/recovery work instead of silently completing or reassigning work
- explicit recovery actions are the liveness primitive; source-scoped actions are the default form, issue-backed recovery is a fallback for independent repair work or safety boundaries, and comments alone are evidence rather than a healthy liveness path
Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and non-terminal liveness semantics are documented in `doc/execution-semantics.md`. Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and non-terminal liveness semantics are documented in `doc/execution-semantics.md`.
+47 -20
View File
@@ -156,7 +156,7 @@ If a parent is truly waiting on a child, model that with blockers. Do not rely o
For agent-owned, non-terminal issues, Paperclip should never leave work in a state where nobody is responsible for the next move and nothing will wake or surface it. For agent-owned, non-terminal issues, Paperclip should never leave work in a state where nobody is responsible for the next move and nothing will wake or surface it.
This is a visibility contract, not an auto-completion contract. If Paperclip cannot safely infer the next action, it should surface the ambiguity with a blocked state, a visible comment, or an explicit recovery issue. It must not silently mark work done from prose comments or guess that a dependency is complete. This is a visibility contract, not an auto-completion contract. If Paperclip cannot safely infer the next action, it should surface the ambiguity with a blocked state, a visible notice, or an explicit recovery action. It must not silently mark work done from prose comments or guess that a dependency is complete.
An issue is healthy when the product can answer "what moves this forward next?" without requiring a human to reconstruct intent from the whole thread. An issue is stalled when it is non-terminal but has no live execution path, no explicit waiting path, and no recovery path. An issue is healthy when the product can answer "what moves this forward next?" without requiring a human to reconstruct intent from the whole thread. An issue is stalled when it is non-terminal but has no live execution path, no explicit waiting path, and no recovery path.
@@ -169,7 +169,32 @@ The valid action-path primitives are:
- a one-shot issue monitor (`executionPolicy.monitor.nextCheckAt`) that will wake the assignee for a future check - a one-shot issue monitor (`executionPolicy.monitor.nextCheckAt`) that will wake the assignee for a future check
- a human owner via `assigneeUserId` - a human owner via `assigneeUserId`
- a first-class blocker chain whose unresolved leaf issues are themselves healthy - 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 - an open explicit recovery action that names the owner and action needed to restore liveness
### Explicit recovery actions
An explicit recovery action is a typed liveness repair path for a source issue. It is the recovery primitive; the action can be rendered directly on the source issue or backed by a separate recovery issue when the repair needs its own work item.
A valid recovery action must name:
- the source issue and company
- the recovery kind and idempotency fingerprint
- the recovery owner, plus previous or return owner when ownership may temporarily shift
- the cause, bounded evidence, and next action
- the wake, monitor, timeout, retry, or escalation policy that will move the action forward
- the resolution outcome when closed, such as restored, delegated, false positive, blocked, escalated, or cancelled
A source-scoped recovery action is the default form. Use it when the next safe move is to repair the source issue's liveness directly: restore a wake path, clarify disposition, re-establish a monitor, record a false positive, or delegate real follow-up work from the source issue.
Use an issue-backed recovery action only when the recovery is genuinely independent work or when source-scoped handling would be unsafe or unclear. Examples include:
- long or cross-agent repair work with its own assignee, subtasks, or blockers
- real delegated follow-up that should block the source issue as a first-class dependency
- active-run watchdog work that must observe a still-running source process without interfering with it
- recovery that needs separate review, approval, security handling, or escalation ownership
- cases where source issue ownership cannot be changed or restored safely
A comment or system notice can be evidence for a recovery action, but it is not a recovery action by itself. Comment-only recovery is not a healthy liveness path because it does not define a typed owner, wake or monitor policy, retry bound, timeout, escalation path, or resolution outcome.
### Agent-assigned `todo` ### Agent-assigned `todo`
@@ -191,7 +216,7 @@ Assigning an issue normally implies executable intent. When create APIs receive
An explicit assigned `backlog` issue remains valid when the creator is deliberately parking the work. It must not wake the assignee just because it has an assignee. Paperclip should make that choice visible in activity and UI so operators can distinguish intentional parking from a missed handoff. An explicit assigned `backlog` issue remains valid when the creator is deliberately parking the work. It must not wake the assignee just because it has an assignee. Paperclip should make that choice visible in activity and UI so operators can distinguish intentional parking from a missed handoff.
An assigned `backlog` issue becomes a liveness problem when another issue is blocked on it and there is no explicit waiting path such as a human owner, active run, queued wake, pending interaction or approval, monitor, or open recovery issue. In that case the blocked parent should surface "blocked by parked work" rather than treating the dependency chain as healthy. An assigned `backlog` issue becomes a liveness problem when another issue is blocked on it and there is no explicit waiting path such as a human owner, active run, queued wake, pending interaction or approval, monitor, or open recovery action. In that case the blocked parent should surface "blocked by parked work" rather than treating the dependency chain as healthy.
### Agent-assigned `in_progress` ### Agent-assigned `in_progress`
@@ -202,7 +227,7 @@ A healthy active-work state means at least one of these is true:
- there is an active run for the issue - there is an active run for the issue
- there is already a queued continuation wake - there is already a queued continuation wake
- there is an active one-shot monitor that will wake the assignee for a future check - there is an active one-shot monitor that will wake the assignee for a future check
- there is an open explicit recovery issue for the lost execution path - there is an open explicit recovery action for the lost execution path
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. 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.
@@ -217,11 +242,11 @@ A healthy `in_review` issue has at least one valid action path:
- a human owner via `assigneeUserId` - a human owner via `assigneeUserId`
- an active run or queued wake that is expected to process the review state - an active run or queued wake that is expected to process the review state
- an active one-shot monitor for an external service or async review loop that the assignee owns - an active one-shot monitor for an external service or async review loop that the assignee owns
- an open explicit recovery issue for an ambiguous review handoff - an open explicit recovery action for an ambiguous review handoff
Agent-assigned `in_review` with no typed participant is only healthy when one of the other paths exists. Assignment to the same agent that produced the handoff is not, by itself, a review path. Agent-assigned `in_review` with no typed participant is only healthy when one of the other paths exists. Assignment to the same agent that produced the handoff is not, by itself, a review path.
An `in_review` issue is stalled when it has no typed participant, no pending interaction or approval, no user owner, no active monitor, no active run, no queued wake, and no explicit recovery issue. Paperclip should surface that state as recovery work rather than silently completing the issue or leaving blocker chains parked indefinitely. An `in_review` issue is stalled when it has no typed participant, no pending interaction or approval, no user owner, no active monitor, no active run, no queued wake, and no explicit recovery action. Paperclip should surface that state as recovery work rather than silently completing the issue or leaving blocker chains parked indefinitely.
### Issue monitors ### Issue monitors
@@ -241,7 +266,7 @@ Monitors are not recurring intervals. When a monitor fires, Paperclip clears the
Because `serviceName` and `notes` remain visible in issue activity and wake context, operators should keep them short and non-secret. Put enough context for the assignee to know what to inspect, but do not include signed URLs, bearer tokens, customer secrets, tenant-private identifiers, or provider links with embedded credentials. Because `serviceName` and `notes` remain visible in issue activity and wake context, operators should keep them short and non-secret. Put enough context for the assignee to know what to inspect, but do not include signed URLs, bearer tokens, customer secrets, tenant-private identifiers, or provider links with embedded credentials.
Monitor bounds are enforced. Paperclip rejects attempts to re-arm a monitor whose `timeoutAt` or `maxAttempts` is already exhausted. When a scheduled monitor reaches an exhausted bound at trigger time, Paperclip clears it and follows `recoveryPolicy`: `wake_owner` queues a bounded recovery wake for the assignee, `create_recovery_issue` opens visible recovery work, and `escalate_to_board` records a board-visible escalation comment/activity. Monitor bounds are enforced. Paperclip rejects attempts to re-arm a monitor whose `timeoutAt` or `maxAttempts` is already exhausted. When a scheduled monitor reaches an exhausted bound at trigger time, Paperclip clears it and follows `recoveryPolicy`: `wake_owner` queues a bounded recovery wake for the assignee, `create_recovery_issue` opens visible issue-backed recovery work, and `escalate_to_board` records a board-visible escalation comment/activity.
Use `blocked` instead of a monitor when no Paperclip assignee owns a responsible polling path. In that case, name the external owner/action or create first-class recovery/blocker work. Use `blocked` instead of a monitor when no Paperclip assignee owns a responsible polling path. In that case, name the external owner/action or create first-class recovery/blocker work.
@@ -252,12 +277,12 @@ This is explicit waiting state.
A healthy `blocked` issue has an explicit waiting path: 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 - 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 has an explicit recovery action that itself has a live or waiting path
- the issue is waiting on a 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 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, 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 action. In that case the parent should show the first stalled leaf instead of presenting the dependency as calmly covered.
## 8. Crash and Restart Recovery ## 8. Crash and Restart Recovery
@@ -277,7 +302,7 @@ Example:
Recovery rule: Recovery rule:
- if the latest issue-linked run failed/timed out/cancelled and no live execution path remains, Paperclip queues one automatic assignment recovery 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 - if that recovery wake also finishes and the issue is still stranded, Paperclip moves the issue to `blocked` and opens or updates an explicit recovery action when a bounded owner/action is known; the visible comment is evidence, not the recovery path by itself
This is a dispatch recovery, not a continuation recovery. This is a dispatch recovery, not a continuation recovery.
@@ -293,7 +318,7 @@ Example:
Recovery rule: Recovery rule:
- Paperclip queues one automatic continuation wake - 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 - if that continuation wake also finishes and the issue is still stranded, Paperclip moves the issue to `blocked` and opens or updates an explicit recovery action when a bounded owner/action is known; the visible comment is evidence, not the recovery path by itself
This is an active-work continuity recovery. This is an active-work continuity recovery.
@@ -306,7 +331,7 @@ On startup and on the periodic recovery loop, Paperclip now does four things in
1. reap orphaned `running` runs 1. reap orphaned `running` runs
2. resume persisted `queued` runs 2. resume persisted `queued` runs
3. reconcile stranded assigned work 3. reconcile stranded assigned work
4. scan silent active runs and create or update explicit watchdog review issues 4. scan silent active runs and create or update explicit watchdog recovery actions
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. 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.
@@ -319,11 +344,11 @@ The recovery service owns this contract:
- classify active-run output silence as `ok`, `suspicious`, `critical`, `snoozed`, or `not_applicable` - classify active-run output silence as `ok`, `suspicious`, `critical`, `snoozed`, or `not_applicable`
- collect bounded evidence from run logs, recent run events, child issues, and blockers - collect bounded evidence from run logs, recent run events, child issues, and blockers
- preserve redaction and truncation before evidence is written to issue descriptions - preserve redaction and truncation before evidence is written to issue descriptions
- create at most one open `stale_active_run_evaluation` issue per run - create at most one open watchdog recovery action per run; issue-backed implementations use `stale_active_run_evaluation` issues
- honor active snooze decisions before creating more review work - honor active snooze decisions before creating more review work
- build the `outputSilence` summary shown by live-run and active-run API responses - build the `outputSilence` summary shown by live-run and active-run API responses
Suspicious silence creates a medium-priority review issue for the selected recovery owner. Critical silence raises that review issue to high priority and blocks the source issue on the explicit evaluation task without cancelling the active process. Suspicious silence creates a medium-priority watchdog recovery action for the selected recovery owner. Critical silence raises that recovery action to high priority and, when issue-backed evaluation is needed for correctness, blocks the source issue on the explicit evaluation task without cancelling the active process.
Watchdog decisions are explicit operator/recovery-owner decisions: Watchdog decisions are explicit operator/recovery-owner decisions:
@@ -333,7 +358,7 @@ Watchdog decisions are explicit operator/recovery-owner decisions:
Operators should prefer `snooze` for known time-bounded quiet periods. `continue` is only a short acknowledgement of the current evidence; if the run remains silent after the re-arm window, the periodic watchdog scan can create or update review work again. Operators should prefer `snooze` for known time-bounded quiet periods. `continue` is only a short acknowledgement of the current evidence; if the run remains silent after the re-arm window, the periodic watchdog scan can create or update review work again.
The board can record watchdog decisions. The assigned owner of the watchdog evaluation issue can also record them. Other agents cannot. The board can record watchdog decisions. The assigned owner of an issue-backed watchdog evaluation can also record them. Other agents cannot.
## 11. Auto-Recover vs Explicit Recovery vs Human Escalation ## 11. Auto-Recover vs Explicit Recovery vs Human Escalation
@@ -351,9 +376,9 @@ Examples:
Auto-recovery preserves the existing owner. It does not choose a replacement agent. Auto-recovery preserves the existing owner. It does not choose a replacement agent.
### Explicit Recovery Issue ### Explicit Recovery Action
Paperclip creates an explicit recovery issue when the system can identify a problem but cannot safely complete the work itself. Paperclip opens an explicit recovery action when the system can identify a problem but cannot safely complete the work itself.
Examples: Examples:
@@ -361,9 +386,11 @@ Examples:
- a dependency graph has an invalid/uninvokable owner, unassigned blocker, or invalid review participant - a dependency graph has an invalid/uninvokable owner, unassigned blocker, or invalid review participant
- an active run is silent past the watchdog threshold - an active run is silent past the watchdog threshold
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. The recovery action stays source-scoped by default. The source issue should show the recovery owner, cause, evidence, next action, and wake or monitor policy in its own thread/detail surface.
Instance-level issue-graph liveness auto-recovery is disabled by default. When enabled, its lookback window means "dependency paths updated within the last N hours"; older findings remain advisory and are counted as outside the configured lookback instead of creating recovery issues automatically. This is an operator noise control, not the older staleness delay for determining whether a chain is old enough to surface. Create an issue-backed recovery action only when a separate issue is the right execution object. In that fallback form, the source issue remains visible and is blocked on the recovery issue when blocking is necessary for correctness. The recovery owner must restore a live path, resolve the source issue manually, delegate real follow-up work, or record the reason the signal is a false positive.
Instance-level issue-graph liveness auto-recovery is disabled by default. When enabled, its lookback window means "dependency paths updated within the last N hours"; older findings remain advisory and are counted as outside the configured lookback instead of creating recovery actions automatically. This is an operator noise control, not the older staleness delay for determining whether a chain is old enough to surface.
### Human Escalation ### Human Escalation
@@ -391,7 +418,7 @@ The recovery model is intentionally conservative:
- preserve ownership - preserve ownership
- retry once when the control plane lost execution continuity - retry once when the control plane lost execution continuity
- create explicit recovery work when the system can identify a bounded recovery owner/action - open an explicit recovery action when the system can identify a bounded recovery owner/action
- escalate visibly when the system cannot safely keep going - escalate visibly when the system cannot safely keep going
## 13. Practical Interpretation ## 13. Practical Interpretation
@@ -0,0 +1,64 @@
CREATE TABLE IF NOT EXISTS "issue_recovery_actions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"source_issue_id" uuid NOT NULL,
"recovery_issue_id" uuid,
"kind" text NOT NULL,
"status" text DEFAULT 'active' NOT NULL,
"owner_type" text DEFAULT 'agent' NOT NULL,
"owner_agent_id" uuid,
"owner_user_id" text,
"previous_owner_agent_id" uuid,
"return_owner_agent_id" uuid,
"cause" text NOT NULL,
"fingerprint" text NOT NULL,
"evidence" jsonb DEFAULT '{}'::jsonb NOT NULL,
"next_action" text NOT NULL,
"wake_policy" jsonb,
"monitor_policy" jsonb,
"attempt_count" integer DEFAULT 0 NOT NULL,
"max_attempts" integer,
"timeout_at" timestamp with time zone,
"last_attempt_at" timestamp with time zone,
"outcome" text,
"resolution_note" text,
"resolved_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_recovery_actions_company_id_companies_id_fk') THEN
ALTER TABLE "issue_recovery_actions" ADD CONSTRAINT "issue_recovery_actions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_recovery_actions_source_issue_id_issues_id_fk') THEN
ALTER TABLE "issue_recovery_actions" ADD CONSTRAINT "issue_recovery_actions_source_issue_id_issues_id_fk" FOREIGN KEY ("source_issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_recovery_actions_recovery_issue_id_issues_id_fk') THEN
ALTER TABLE "issue_recovery_actions" ADD CONSTRAINT "issue_recovery_actions_recovery_issue_id_issues_id_fk" FOREIGN KEY ("recovery_issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_recovery_actions_owner_agent_id_agents_id_fk') THEN
ALTER TABLE "issue_recovery_actions" ADD CONSTRAINT "issue_recovery_actions_owner_agent_id_agents_id_fk" FOREIGN KEY ("owner_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_recovery_actions_previous_owner_agent_id_agents_id_fk') THEN
ALTER TABLE "issue_recovery_actions" ADD CONSTRAINT "issue_recovery_actions_previous_owner_agent_id_agents_id_fk" FOREIGN KEY ("previous_owner_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_recovery_actions_return_owner_agent_id_agents_id_fk') THEN
ALTER TABLE "issue_recovery_actions" ADD CONSTRAINT "issue_recovery_actions_return_owner_agent_id_agents_id_fk" FOREIGN KEY ("return_owner_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "issue_recovery_actions_company_source_status_idx" ON "issue_recovery_actions" USING btree ("company_id","source_issue_id","status");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "issue_recovery_actions_company_owner_status_idx" ON "issue_recovery_actions" USING btree ("company_id","owner_agent_id","status");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "issue_recovery_actions_company_recovery_issue_idx" ON "issue_recovery_actions" USING btree ("company_id","recovery_issue_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "issue_recovery_actions_active_source_uq" ON "issue_recovery_actions" USING btree ("company_id","source_issue_id") WHERE "issue_recovery_actions"."status" in ('active', 'escalated');--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "issue_recovery_actions_active_fingerprint_uq" ON "issue_recovery_actions" USING btree ("company_id","source_issue_id","cause","fingerprint") WHERE "issue_recovery_actions"."status" in ('active', 'escalated');
@@ -589,6 +589,13 @@
"when": 1778074536410, "when": 1778074536410,
"tag": "0083_company_secret_provider_configs", "tag": "0083_company_secret_provider_configs",
"breakpoints": true "breakpoints": true
},
{
"idx": 84,
"version": "7",
"when": 1778355326070,
"tag": "0084_issue_recovery_actions",
"breakpoints": true
} }
] ]
} }
+1
View File
@@ -29,6 +29,7 @@ export { workspaceRuntimeServices } from "./workspace_runtime_services.js";
export { projectGoals } from "./project_goals.js"; export { projectGoals } from "./project_goals.js";
export { goals } from "./goals.js"; export { goals } from "./goals.js";
export { issues } from "./issues.js"; export { issues } from "./issues.js";
export { issueRecoveryActions } from "./issue_recovery_actions.js";
export { issueReferenceMentions } from "./issue_reference_mentions.js"; export { issueReferenceMentions } from "./issue_reference_mentions.js";
export { issueRelations } from "./issue_relations.js"; export { issueRelations } from "./issue_relations.js";
export { routines, routineRevisions, routineTriggers, routineRuns } from "./routines.js"; export { routines, routineRevisions, routineTriggers, routineRuns } from "./routines.js";
@@ -0,0 +1,68 @@
import { sql } from "drizzle-orm";
import {
index,
integer,
jsonb,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
import { issues } from "./issues.js";
export const issueRecoveryActions = pgTable(
"issue_recovery_actions",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
sourceIssueId: uuid("source_issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
recoveryIssueId: uuid("recovery_issue_id").references(() => issues.id, { onDelete: "set null" }),
kind: text("kind").notNull(),
status: text("status").notNull().default("active"),
ownerType: text("owner_type").notNull().default("agent"),
ownerAgentId: uuid("owner_agent_id").references(() => agents.id, { onDelete: "set null" }),
ownerUserId: text("owner_user_id"),
previousOwnerAgentId: uuid("previous_owner_agent_id").references(() => agents.id, { onDelete: "set null" }),
returnOwnerAgentId: uuid("return_owner_agent_id").references(() => agents.id, { onDelete: "set null" }),
cause: text("cause").notNull(),
fingerprint: text("fingerprint").notNull(),
evidence: jsonb("evidence").$type<Record<string, unknown>>().notNull().default({}),
nextAction: text("next_action").notNull(),
wakePolicy: jsonb("wake_policy").$type<Record<string, unknown>>(),
monitorPolicy: jsonb("monitor_policy").$type<Record<string, unknown>>(),
attemptCount: integer("attempt_count").notNull().default(0),
maxAttempts: integer("max_attempts"),
timeoutAt: timestamp("timeout_at", { withTimezone: true }),
lastAttemptAt: timestamp("last_attempt_at", { withTimezone: true }),
outcome: text("outcome"),
resolutionNote: text("resolution_note"),
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companySourceStatusIdx: index("issue_recovery_actions_company_source_status_idx").on(
table.companyId,
table.sourceIssueId,
table.status,
),
companyOwnerStatusIdx: index("issue_recovery_actions_company_owner_status_idx").on(
table.companyId,
table.ownerAgentId,
table.status,
),
companyRecoveryIssueIdx: index("issue_recovery_actions_company_recovery_issue_idx").on(
table.companyId,
table.recoveryIssueId,
),
activeSourceIdx: uniqueIndex("issue_recovery_actions_active_source_uq")
.on(table.companyId, table.sourceIssueId)
.where(sql`${table.status} in ('active', 'escalated')`),
activeFingerprintIdx: uniqueIndex("issue_recovery_actions_active_fingerprint_uq")
.on(table.companyId, table.sourceIssueId, table.cause, table.fingerprint)
.where(sql`${table.status} in ('active', 'escalated')`),
}),
);
+19 -10
View File
@@ -111,11 +111,15 @@ function formatEmbeddedPostgresError(error: unknown): string {
} }
async function probeEmbeddedPostgresSupport(): Promise<EmbeddedPostgresTestSupport> { async function probeEmbeddedPostgresSupport(): Promise<EmbeddedPostgresTestSupport> {
const { dataDir, instance } = await createEmbeddedPostgresTestInstance( let dataDir: string | null = null;
"paperclip-embedded-postgres-probe-", let instance: EmbeddedPostgresInstance | null = null;
);
try { try {
const created = await createEmbeddedPostgresTestInstance(
"paperclip-embedded-postgres-probe-",
);
dataDir = created.dataDir;
instance = created.instance;
await instance.initialise(); await instance.initialise();
await instance.start(); await instance.start();
return { supported: true }; return { supported: true };
@@ -125,8 +129,8 @@ async function probeEmbeddedPostgresSupport(): Promise<EmbeddedPostgresTestSuppo
reason: formatEmbeddedPostgresError(error), reason: formatEmbeddedPostgresError(error),
}; };
} finally { } finally {
await instance.stop().catch(() => {}); await instance?.stop().catch(() => {});
cleanupEmbeddedPostgresTestDirs(dataDir); if (dataDir) cleanupEmbeddedPostgresTestDirs(dataDir);
} }
} }
@@ -140,9 +144,14 @@ export async function getEmbeddedPostgresTestSupport(): Promise<EmbeddedPostgres
export async function startEmbeddedPostgresTestDatabase( export async function startEmbeddedPostgresTestDatabase(
tempDirPrefix: string, tempDirPrefix: string,
): Promise<EmbeddedPostgresTestDatabase> { ): Promise<EmbeddedPostgresTestDatabase> {
const { dataDir, port, instance } = await createEmbeddedPostgresTestInstance(tempDirPrefix); let dataDir: string | null = null;
let instance: EmbeddedPostgresInstance | null = null;
try { try {
const created = await createEmbeddedPostgresTestInstance(tempDirPrefix);
dataDir = created.dataDir;
instance = created.instance;
const { port } = created;
await instance.initialise(); await instance.initialise();
await instance.start(); await instance.start();
@@ -154,13 +163,13 @@ export async function startEmbeddedPostgresTestDatabase(
return { return {
connectionString, connectionString,
cleanup: async () => { cleanup: async () => {
await instance.stop().catch(() => {}); await instance?.stop().catch(() => {});
cleanupEmbeddedPostgresTestDirs(dataDir); if (dataDir) cleanupEmbeddedPostgresTestDirs(dataDir);
}, },
}; };
} catch (error) { } catch (error) {
await instance.stop().catch(() => {}); await instance?.stop().catch(() => {});
cleanupEmbeddedPostgresTestDirs(dataDir); if (dataDir) cleanupEmbeddedPostgresTestDirs(dataDir);
throw new Error( throw new Error(
`Failed to start embedded PostgreSQL test database: ${formatEmbeddedPostgresError(error)}`, `Failed to start embedded PostgreSQL test database: ${formatEmbeddedPostgresError(error)}`,
); );
+34
View File
@@ -215,6 +215,40 @@ export type IssueOriginKind = BuiltInIssueOriginKind | PluginIssueOriginKind;
export const ISSUE_SURFACE_VISIBILITIES = ["default", "plugin_operation"] as const; export const ISSUE_SURFACE_VISIBILITIES = ["default", "plugin_operation"] as const;
export type IssueSurfaceVisibility = (typeof ISSUE_SURFACE_VISIBILITIES)[number]; export type IssueSurfaceVisibility = (typeof ISSUE_SURFACE_VISIBILITIES)[number];
export const ISSUE_RECOVERY_ACTION_KINDS = [
"missing_disposition",
"stranded_assigned_issue",
"active_run_watchdog",
"issue_graph_liveness",
] as const;
export type IssueRecoveryActionKind = (typeof ISSUE_RECOVERY_ACTION_KINDS)[number];
export const ISSUE_RECOVERY_ACTION_STATUSES = [
"active",
"escalated",
"resolved",
"cancelled",
] as const;
export type IssueRecoveryActionStatus = (typeof ISSUE_RECOVERY_ACTION_STATUSES)[number];
export const ISSUE_RECOVERY_ACTION_OWNER_TYPES = [
"agent",
"user",
"board",
"system",
] as const;
export type IssueRecoveryActionOwnerType = (typeof ISSUE_RECOVERY_ACTION_OWNER_TYPES)[number];
export const ISSUE_RECOVERY_ACTION_OUTCOMES = [
"restored",
"delegated",
"false_positive",
"blocked",
"escalated",
"cancelled",
] as const;
export type IssueRecoveryActionOutcome = (typeof ISSUE_RECOVERY_ACTION_OUTCOMES)[number];
export function pluginOperationIssueOriginKind(pluginKey: string): PluginIssueOriginKind { export function pluginOperationIssueOriginKind(pluginKey: string): PluginIssueOriginKind {
return `plugin:${pluginKey}:operation`; return `plugin:${pluginKey}:operation`;
} }
+11
View File
@@ -31,6 +31,10 @@ export {
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES, ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
ISSUE_ORIGIN_KINDS, ISSUE_ORIGIN_KINDS,
ISSUE_SURFACE_VISIBILITIES, ISSUE_SURFACE_VISIBILITIES,
ISSUE_RECOVERY_ACTION_KINDS,
ISSUE_RECOVERY_ACTION_STATUSES,
ISSUE_RECOVERY_ACTION_OWNER_TYPES,
ISSUE_RECOVERY_ACTION_OUTCOMES,
pluginOperationIssueOriginKind, pluginOperationIssueOriginKind,
isPluginOperationIssueOriginKind, isPluginOperationIssueOriginKind,
ISSUE_RELATION_TYPES, ISSUE_RELATION_TYPES,
@@ -149,6 +153,10 @@ export {
type PluginIssueOriginKind, type PluginIssueOriginKind,
type IssueOriginKind, type IssueOriginKind,
type IssueSurfaceVisibility, type IssueSurfaceVisibility,
type IssueRecoveryActionKind,
type IssueRecoveryActionStatus,
type IssueRecoveryActionOwnerType,
type IssueRecoveryActionOutcome,
type IssueRelationType, type IssueRelationType,
type IssueTreeControlMode, type IssueTreeControlMode,
type IssueTreeHoldReleasePolicyStrategy, type IssueTreeHoldReleasePolicyStrategy,
@@ -373,6 +381,7 @@ export type {
IssueBlockerAttentionState, IssueBlockerAttentionState,
IssueProductivityReview, IssueProductivityReview,
IssueProductivityReviewTrigger, IssueProductivityReviewTrigger,
IssueRecoveryAction,
SuccessfulRunHandoffState, SuccessfulRunHandoffState,
SuccessfulRunHandoffStateKind, SuccessfulRunHandoffStateKind,
IssueScheduledRetry, IssueScheduledRetry,
@@ -755,6 +764,7 @@ export {
updateIssueSchema, updateIssueSchema,
issueExecutionPolicySchema, issueExecutionPolicySchema,
issueExecutionStateSchema, issueExecutionStateSchema,
resolveIssueRecoveryActionSchema,
issueReviewRequestSchema, issueReviewRequestSchema,
issueExecutionWorkspaceSettingsSchema, issueExecutionWorkspaceSettingsSchema,
checkoutIssueSchema, checkoutIssueSchema,
@@ -814,6 +824,7 @@ export {
type CreateChildIssue, type CreateChildIssue,
type CreateIssueLabel, type CreateIssueLabel,
type UpdateIssue, type UpdateIssue,
type ResolveIssueRecoveryAction,
type CheckoutIssue, type CheckoutIssue,
type AddIssueComment, type AddIssueComment,
type CreateIssueThreadInteraction, type CreateIssueThreadInteraction,
+1
View File
@@ -151,6 +151,7 @@ export type {
IssueBlockerAttentionState, IssueBlockerAttentionState,
IssueProductivityReview, IssueProductivityReview,
IssueProductivityReviewTrigger, IssueProductivityReviewTrigger,
IssueRecoveryAction,
SuccessfulRunHandoffState, SuccessfulRunHandoffState,
SuccessfulRunHandoffStateKind, SuccessfulRunHandoffStateKind,
IssueScheduledRetry, IssueScheduledRetry,
+35
View File
@@ -15,6 +15,10 @@ import type {
IssueExecutionStateStatus, IssueExecutionStateStatus,
IssueOriginKind, IssueOriginKind,
IssuePriority, IssuePriority,
IssueRecoveryActionKind,
IssueRecoveryActionOutcome,
IssueRecoveryActionOwnerType,
IssueRecoveryActionStatus,
IssueWorkMode, IssueWorkMode,
ModelProfileKey, ModelProfileKey,
IssueThreadInteractionContinuationPolicy, IssueThreadInteractionContinuationPolicy,
@@ -131,6 +135,7 @@ export interface IssueRelationIssueSummary {
assigneeAgentId: string | null; assigneeAgentId: string | null;
assigneeUserId: string | null; assigneeUserId: string | null;
terminalBlockers?: IssueRelationIssueSummary[]; terminalBlockers?: IssueRelationIssueSummary[];
activeRecoveryAction?: IssueRecoveryAction | null;
} }
export type IssueBlockerAttentionState = "none" | "covered" | "stalled" | "needs_attention"; export type IssueBlockerAttentionState = "none" | "covered" | "stalled" | "needs_attention";
@@ -169,6 +174,35 @@ export interface IssueProductivityReview {
updatedAt: Date; updatedAt: Date;
} }
export interface IssueRecoveryAction {
id: string;
companyId: string;
sourceIssueId: string;
recoveryIssueId: string | null;
kind: IssueRecoveryActionKind;
status: IssueRecoveryActionStatus;
ownerType: IssueRecoveryActionOwnerType;
ownerAgentId: string | null;
ownerUserId: string | null;
previousOwnerAgentId: string | null;
returnOwnerAgentId: string | null;
cause: string;
fingerprint: string;
evidence: Record<string, unknown>;
nextAction: string;
wakePolicy: Record<string, unknown> | null;
monitorPolicy: Record<string, unknown> | null;
attemptCount: number;
maxAttempts: number | null;
timeoutAt: Date | string | null;
lastAttemptAt: Date | string | null;
outcome: IssueRecoveryActionOutcome | null;
resolutionNote: string | null;
resolvedAt: Date | string | null;
createdAt: Date | string;
updatedAt: Date | string;
}
export type SuccessfulRunHandoffStateKind = "required" | "resolved" | "escalated"; export type SuccessfulRunHandoffStateKind = "required" | "resolved" | "escalated";
export interface SuccessfulRunHandoffState { export interface SuccessfulRunHandoffState {
@@ -372,6 +406,7 @@ export interface Issue {
blocks?: IssueRelationIssueSummary[]; blocks?: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention; blockerAttention?: IssueBlockerAttention;
productivityReview?: IssueProductivityReview | null; productivityReview?: IssueProductivityReview | null;
activeRecoveryAction?: IssueRecoveryAction | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null; successfulRunHandoff?: SuccessfulRunHandoffState | null;
scheduledRetry?: IssueScheduledRetry | null; scheduledRetry?: IssueScheduledRetry | null;
relatedWork?: IssueRelatedWorkSummary; relatedWork?: IssueRelatedWorkSummary;
+4
View File
@@ -156,6 +156,8 @@ export {
updateIssueSchema, updateIssueSchema,
issueExecutionPolicySchema, issueExecutionPolicySchema,
issueExecutionStateSchema, issueExecutionStateSchema,
issueRecoveryActionReadModelSchema,
resolveIssueRecoveryActionSchema,
issueReviewRequestSchema, issueReviewRequestSchema,
issueExecutionWorkspaceSettingsSchema, issueExecutionWorkspaceSettingsSchema,
checkoutIssueSchema, checkoutIssueSchema,
@@ -198,6 +200,8 @@ export {
type CreateIssueLabel, type CreateIssueLabel,
type UpdateIssue, type UpdateIssue,
type IssueExecutionWorkspaceSettings, type IssueExecutionWorkspaceSettings,
type IssueRecoveryActionReadModel,
type ResolveIssueRecoveryAction,
type CheckoutIssue, type CheckoutIssue,
type AddIssueComment, type AddIssueComment,
type CreateIssueThreadInteraction, type CreateIssueThreadInteraction,
@@ -3,6 +3,7 @@ import { MAX_ISSUE_REQUEST_DEPTH } from "../index.js";
import { import {
addIssueCommentSchema, addIssueCommentSchema,
createIssueSchema, createIssueSchema,
resolveIssueRecoveryActionSchema,
respondIssueThreadInteractionSchema, respondIssueThreadInteractionSchema,
suggestedTaskDraftSchema, suggestedTaskDraftSchema,
updateIssueSchema, updateIssueSchema,
@@ -46,6 +47,70 @@ describe("issue validators", () => {
expect(parsed.comment).toBe("Done\n\n- Verified the route"); expect(parsed.comment).toBe("Done\n\n- Verified the route");
}); });
it("allows false-positive recovery resolutions to atomically restore the source issue status", () => {
expect(
resolveIssueRecoveryActionSchema.parse({
outcome: "false_positive",
sourceIssueStatus: "in_review",
}),
).toMatchObject({
outcome: "false_positive",
sourceIssueStatus: "in_review",
});
expect(
resolveIssueRecoveryActionSchema.safeParse({
outcome: "false_positive",
sourceIssueStatus: "blocked",
}).success,
).toBe(false);
expect(
resolveIssueRecoveryActionSchema.safeParse({
outcome: "false_positive",
}).success,
).toBe(false);
});
it("allows cancelled recovery resolutions to atomically restore the source issue status", () => {
expect(
resolveIssueRecoveryActionSchema.parse({
outcome: "cancelled",
sourceIssueStatus: "in_review",
}),
).toMatchObject({
outcome: "cancelled",
sourceIssueStatus: "in_review",
});
expect(
resolveIssueRecoveryActionSchema.safeParse({
outcome: "cancelled",
sourceIssueStatus: "blocked",
}).success,
).toBe(false);
expect(
resolveIssueRecoveryActionSchema.safeParse({
outcome: "cancelled",
}).success,
).toBe(false);
});
it("rejects recovery outcomes that are not supported by the source-scoped resolution endpoint", () => {
expect(
resolveIssueRecoveryActionSchema.safeParse({
outcome: "delegated",
}).success,
).toBe(false);
expect(
resolveIssueRecoveryActionSchema.safeParse({
outcome: "escalated",
}).success,
).toBe(false);
});
it("normalizes escaped line breaks in issue comment bodies", () => { it("normalizes escaped line breaks in issue comment bodies", () => {
const parsed = addIssueCommentSchema.parse({ const parsed = addIssueCommentSchema.parse({
body: "Progress update\\r\\n\\r\\nNext action.", body: "Progress update\\r\\n\\r\\nNext action.",
+87
View File
@@ -14,6 +14,10 @@ import {
ISSUE_COMMENT_PRESENTATION_TONES, ISSUE_COMMENT_PRESENTATION_TONES,
ISSUE_MONITOR_SCHEDULED_BY, ISSUE_MONITOR_SCHEDULED_BY,
ISSUE_PRIORITIES, ISSUE_PRIORITIES,
ISSUE_RECOVERY_ACTION_KINDS,
ISSUE_RECOVERY_ACTION_OUTCOMES,
ISSUE_RECOVERY_ACTION_OWNER_TYPES,
ISSUE_RECOVERY_ACTION_STATUSES,
ISSUE_WORK_MODES, ISSUE_WORK_MODES,
clampIssueRequestDepth, clampIssueRequestDepth,
ISSUE_STATUSES, ISSUE_STATUSES,
@@ -167,6 +171,89 @@ export const issueExecutionStateSchema = z.object({
monitor: issueExecutionMonitorStateSchema.optional().nullable(), monitor: issueExecutionMonitorStateSchema.optional().nullable(),
}); });
export const issueRecoveryActionReadModelSchema = z.object({
id: z.string().uuid(),
companyId: z.string().uuid(),
sourceIssueId: z.string().uuid(),
recoveryIssueId: z.string().uuid().nullable(),
kind: z.enum(ISSUE_RECOVERY_ACTION_KINDS),
status: z.enum(ISSUE_RECOVERY_ACTION_STATUSES),
ownerType: z.enum(ISSUE_RECOVERY_ACTION_OWNER_TYPES),
ownerAgentId: z.string().uuid().nullable(),
ownerUserId: z.string().nullable(),
previousOwnerAgentId: z.string().uuid().nullable(),
returnOwnerAgentId: z.string().uuid().nullable(),
cause: z.string().min(1),
fingerprint: z.string().min(1),
evidence: z.record(z.unknown()),
nextAction: z.string().min(1),
wakePolicy: z.record(z.unknown()).nullable(),
monitorPolicy: z.record(z.unknown()).nullable(),
attemptCount: z.number().int().nonnegative(),
maxAttempts: z.number().int().positive().nullable(),
timeoutAt: z.union([z.date(), z.string().datetime()]).nullable(),
lastAttemptAt: z.union([z.date(), z.string().datetime()]).nullable(),
outcome: z.enum(ISSUE_RECOVERY_ACTION_OUTCOMES).nullable(),
resolutionNote: z.string().nullable(),
resolvedAt: z.union([z.date(), z.string().datetime()]).nullable(),
createdAt: z.union([z.date(), z.string().datetime()]),
updatedAt: z.union([z.date(), z.string().datetime()]),
});
export type IssueRecoveryActionReadModel = z.infer<typeof issueRecoveryActionReadModelSchema>;
const RESOLVE_ISSUE_RECOVERY_ACTION_OUTCOMES = [
"restored",
"false_positive",
"blocked",
"cancelled",
] as const;
export const resolveIssueRecoveryActionSchema = z.object({
actionId: z.string().uuid().optional(),
outcome: z.enum(RESOLVE_ISSUE_RECOVERY_ACTION_OUTCOMES),
sourceIssueStatus: z.enum(["done", "in_review", "blocked"]),
resolutionNote: multilineTextSchema.optional().nullable(),
}).strict().superRefine((value, ctx) => {
if (value.outcome === "restored") {
if (value.sourceIssueStatus !== "done" && value.sourceIssueStatus !== "in_review") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Restored recovery actions must move the source issue to done or in_review",
path: ["sourceIssueStatus"],
});
}
return;
}
if (value.outcome === "blocked") {
if (value.sourceIssueStatus !== "blocked") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Blocked recovery actions must move the source issue to blocked",
path: ["sourceIssueStatus"],
});
}
return;
}
if (value.outcome === "false_positive" || value.outcome === "cancelled") {
if (
value.sourceIssueStatus !== "done" &&
value.sourceIssueStatus !== "in_review"
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "This recovery outcome requires sourceIssueStatus to be done or in_review",
path: ["sourceIssueStatus"],
});
}
return;
}
});
export type ResolveIssueRecoveryAction = z.infer<typeof resolveIssueRecoveryActionSchema>;
const issueRequestDepthInputSchema = z const issueRequestDepthInputSchema = z
.number() .number()
.int() .int()
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env node
/* eslint-disable no-console */
const path = require("path");
const fs = require("fs");
const repoRoot = path.resolve(__dirname, "..");
const playwright = require(path.join(
repoRoot,
"node_modules/.pnpm/playwright@1.58.2/node_modules/playwright/index.js",
));
const STORYBOOK_BASE = process.env.STORYBOOK_URL ?? "http://localhost:6006";
const OUT_DIR = path.resolve(repoRoot, "tmp/pap-9134-recovery-screens");
const VIEWPORTS = [
{ name: "desktop", width: 1440, height: 900 },
{ name: "mobile", width: 390, height: 844 },
];
const STORIES = [
{
id: "paperclip-source-issue-recovery--recovery-action-card-states",
label: "card-states",
},
{
id: "paperclip-source-issue-recovery--inbox-row-chips",
label: "inbox-rows",
},
{
id: "paperclip-source-issue-recovery--blocker-notice-recovery-indicators",
label: "blocker-notice",
},
{
id: "paperclip-source-issue-recovery--active-run-panel-recovery-chips",
label: "active-run-panel",
},
];
const THEMES = [
{ name: "light", apply: () => document.documentElement.classList.remove("dark") },
{ name: "dark", apply: () => document.documentElement.classList.add("dark") },
];
(async () => {
fs.mkdirSync(OUT_DIR, { recursive: true });
const browser = await playwright.chromium.launch();
try {
for (const viewport of VIEWPORTS) {
const context = await browser.newContext({
viewport: { width: viewport.width, height: viewport.height },
deviceScaleFactor: 2,
});
for (const story of STORIES) {
for (const theme of THEMES) {
const page = await context.newPage();
const url = `${STORYBOOK_BASE}/iframe.html?id=${story.id}&viewMode=story`;
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
await page.waitForLoadState("networkidle", { timeout: 30_000 }).catch(() => {});
await page.evaluate((darkTheme) => {
const html = document.documentElement;
if (darkTheme) {
html.classList.add("dark");
} else {
html.classList.remove("dark");
}
}, theme.name === "dark");
await page.waitForTimeout(400);
const outPath = path.join(
OUT_DIR,
`${story.label}_${viewport.name}_${theme.name}.png`,
);
await page.screenshot({ path: outPath, fullPage: true });
console.log(`Saved ${outPath}`);
await page.close();
}
}
await context.close();
}
} finally {
await browser.close();
}
})().catch((err) => {
console.error(err);
process.exit(1);
});
@@ -80,6 +80,10 @@ vi.mock("../services/index.js", () => ({
listApprovalsForIssue: vi.fn(), listApprovalsForIssue: vi.fn(),
unlink: vi.fn(), unlink: vi.fn(),
}), }),
issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
}),
documentService: () => ({}), documentService: () => ({}),
routineService: () => ({}), routineService: () => ({}),
workProductService: () => ({}), workProductService: () => ({}),
@@ -20,6 +20,7 @@ import {
heartbeatRuns, heartbeatRuns,
issueComments, issueComments,
issueDocuments, issueDocuments,
issueRecoveryActions,
issueRelations, issueRelations,
issueTreeHoldMembers, issueTreeHoldMembers,
issueTreeHolds, issueTreeHolds,
@@ -328,6 +329,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
await db.delete(documentRevisions); await db.delete(documentRevisions);
await db.delete(documents); await db.delete(documents);
await db.delete(issueRelations); await db.delete(issueRelations);
await db.delete(issueRecoveryActions);
await db.delete(issueTreeHoldMembers); await db.delete(issueTreeHoldMembers);
await db.delete(issueTreeHolds); await db.delete(issueTreeHolds);
for (let attempt = 0; attempt < 5; attempt += 1) { for (let attempt = 0; attempt < 5; attempt += 1) {
@@ -692,67 +694,76 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
return { companyId, agentId, issueId }; return { companyId, agentId, issueId };
} }
async function expectStrandedRecoveryArtifacts(input: { async function expectSourceScopedStrandedRecoveryAction(input: {
companyId: string; companyId: string;
agentId: string; agentId: string;
issueId: string; issueId: string;
runId: string; runId: string;
previousStatus: "todo" | "in_progress"; previousStatus: "todo" | "in_progress";
retryReason: "assignment_recovery" | "issue_continuation_needed"; retryReason?: "assignment_recovery" | "issue_continuation_needed" | null;
cause?: string;
kind?: string;
}) { }) {
const recovery = await waitForValue(async () => const action = await waitForValue(async () =>
db.select().from(issues).where( db.select().from(issueRecoveryActions).where(
and( and(
eq(issues.companyId, input.companyId), eq(issueRecoveryActions.companyId, input.companyId),
eq(issues.originKind, "stranded_issue_recovery"), eq(issueRecoveryActions.sourceIssueId, input.issueId),
eq(issues.originId, input.issueId),
), ),
).then((rows) => rows[0] ?? null), ).then((rows) => rows[0] ?? null),
); );
if (!recovery) throw new Error("Expected stranded issue recovery issue to be created"); if (!action) throw new Error("Expected source-scoped stranded recovery action to be created");
expect(recovery).toMatchObject({ expect(action).toMatchObject({
companyId: input.companyId, companyId: input.companyId,
parentId: input.issueId, sourceIssueId: input.issueId,
assigneeAgentId: input.agentId, recoveryIssueId: null,
originKind: "stranded_issue_recovery", kind: input.kind ?? "stranded_assigned_issue",
originId: input.issueId, status: "active",
originRunId: input.runId, ownerType: "agent",
priority: "medium", ownerAgentId: input.agentId,
assigneeAdapterOverrides: { modelProfile: "cheap" }, previousOwnerAgentId: input.agentId,
returnOwnerAgentId: input.agentId,
cause: input.cause ?? "stranded_assigned_issue",
attemptCount: 1,
maxAttempts: null,
}); });
expect(recovery.title).toContain("Recover stalled issue"); expect(action.evidence).toMatchObject({
expect(recovery.description).toContain(`Previous source status: \`${input.previousStatus}\``); sourceIssueId: input.issueId,
expect(recovery.description).toContain(`Retry reason: \`${input.retryReason}\``); previousStatus: input.previousStatus,
expect(recovery.description).toContain("Fix the runtime/adapter problem"); latestRunId: input.runId,
retryReason: input.retryReason ?? null,
});
expect(action.nextAction).toContain(
input.kind === "missing_disposition" ? "valid issue disposition" : "Restore a live execution path",
);
const relation = await db const recoveryIssues = await db
.select() .select()
.from(issueRelations) .from(issues)
.where( .where(and(
and( eq(issues.companyId, input.companyId),
eq(issueRelations.companyId, input.companyId), eq(issues.originKind, "stranded_issue_recovery"),
eq(issueRelations.issueId, recovery.id), eq(issues.originId, input.issueId),
eq(issueRelations.relatedIssueId, input.issueId), ));
eq(issueRelations.type, "blocks"), expect(recoveryIssues).toHaveLength(0);
),
)
.then((rows) => rows[0] ?? null);
expect(relation).toBeTruthy();
const wakeups = await db const recoveryWakeup = await waitForValue(async () => {
.select() const wakeups = await db
.from(agentWakeupRequests) .select()
.where(eq(agentWakeupRequests.agentId, input.agentId)); .from(agentWakeupRequests)
const recoveryWakeup = wakeups.find((wakeup) => { .where(eq(agentWakeupRequests.agentId, input.agentId));
const payload = wakeup.payload as Record<string, unknown> | null; return wakeups.find((wakeup) => {
return payload?.issueId === recovery.id && const payload = wakeup.payload as Record<string, unknown> | null;
payload?.sourceIssueId === input.issueId && return payload?.issueId === input.issueId &&
payload?.strandedRunId === input.runId; payload?.sourceIssueId === input.issueId &&
payload?.recoveryActionId === action.id &&
payload?.strandedRunId === input.runId;
}) ?? null;
}); });
expect(recoveryWakeup).toMatchObject({ expect(recoveryWakeup).toMatchObject({
companyId: input.companyId, companyId: input.companyId,
reason: "issue_assigned", reason: "source_scoped_recovery_action",
source: "assignment", source: "assignment",
payload: expect.objectContaining({ modelProfile: "cheap" }), payload: expect.objectContaining({ modelProfile: "cheap" }),
}); });
@@ -765,15 +776,23 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
.then((rows) => rows[0] ?? null) .then((rows) => rows[0] ?? null)
: null; : null;
expect(recoveryRun?.contextSnapshot).toMatchObject({ expect(recoveryRun?.contextSnapshot).toMatchObject({
issueId: recovery.id, issueId: input.issueId,
taskId: recovery.id, taskId: input.issueId,
source: "stranded_issue_recovery", source: "issue_recovery_action",
recoveryActionId: action.id,
sourceIssueId: input.issueId, sourceIssueId: input.issueId,
strandedRunId: input.runId, strandedRunId: input.runId,
modelProfile: "cheap", modelProfile: "cheap",
}); });
await waitForHeartbeatIdle(db);
const sourceIssue = await db
.select()
.from(issues)
.where(eq(issues.id, input.issueId))
.then((rows) => rows[0] ?? null);
expect(sourceIssue?.status).toBe("blocked");
return recovery; return action;
} }
async function sourceBlockerIssueIds(companyId: string, sourceIssueId: string) { async function sourceBlockerIssueIds(companyId: string, sourceIssueId: string) {
@@ -1056,7 +1075,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(blockedIssue?.checkoutRunId).toBeNull(); expect(blockedIssue?.checkoutRunId).toBeNull();
if (!continuationRun?.id) throw new Error("Expected continuation recovery run to exist"); if (!continuationRun?.id) throw new Error("Expected continuation recovery run to exist");
const recovery = await expectStrandedRecoveryArtifacts({ const recoveryAction = await expectSourceScopedStrandedRecoveryAction({
companyId, companyId,
agentId, agentId,
issueId, issueId,
@@ -1065,17 +1084,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
retryReason: "issue_continuation_needed", retryReason: "issue_continuation_needed",
}); });
const blockerRelations = await db await expect(sourceBlockerIssueIds(companyId, issueId)).resolves.toEqual([]);
.select()
.from(issueRelations)
.where(
and(
eq(issueRelations.companyId, companyId),
eq(issueRelations.relatedIssueId, issueId),
eq(issueRelations.type, "blocks"),
),
);
expect(blockerRelations.map((relation) => relation.issueId)).toEqual([recovery.id]);
const comments = await waitForValue(async () => { const comments = await waitForValue(async () => {
const rows = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); const rows = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
@@ -1083,7 +1092,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
}); });
expect(comments).toHaveLength(1); expect(comments).toHaveLength(1);
expect(comments[0]?.body).toContain("retried continuation"); expect(comments[0]?.body).toContain("retried continuation");
expect(comments[0]?.body).toContain(`Recovery issue: [${recovery.identifier}]`); expect(comments[0]?.body).toContain(`Recovery action: \`${recoveryAction.id}\``);
expect(comments[0]?.body).toContain("Recovery owner: [CodexCoder]");
}); });
it("blocks failed recovery work in place during immediate terminal-run cleanup", async () => { it("blocks failed recovery work in place during immediate terminal-run cleanup", async () => {
@@ -1600,27 +1610,28 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(result.successfulRunHandoffEscalated).toBe(1); expect(result.successfulRunHandoffEscalated).toBe(1);
expect(result.issueIds).toEqual([issueId]); expect(result.issueIds).toEqual([issueId]);
const recovery = await waitForValue(async () => const recoveryAction = await expectSourceScopedStrandedRecoveryAction({
db.select().from(issues).where( companyId,
and( agentId,
eq(issues.companyId, companyId), issueId,
eq(issues.originKind, "stranded_issue_recovery"), runId,
eq(issues.originId, issueId), previousStatus: "in_progress",
), retryReason: null,
).then((rows) => rows[0] ?? null), cause: SUCCESSFUL_RUN_MISSING_STATE_REASON,
); kind: "missing_disposition",
expect(recovery?.assigneeAgentId).toBe(agentId); });
expect(recovery?.title).toContain("Recover missing next step"); expect(recoveryAction.evidence).toMatchObject({
expect(recovery?.description).toContain("Normalized cause: `successful_run_missing_state`"); sourceRunId,
expect(recovery?.description).toContain("not a runtime/adapter crash report"); missingDisposition: "clear_next_step",
expect(recovery?.description).toContain(`Source run: [\`${sourceRunId}\`]`); latestRunStatus: "failed",
expect(recovery?.description).toContain("Missing disposition: `clear_next_step`"); latestRunErrorCode: "adapter_failed",
expect(recovery?.description).toContain("Source assignee: [CodexCoder]"); recoveryCause: SUCCESSFUL_RUN_MISSING_STATE_REASON,
expect(recovery?.description).not.toContain("sk-test-successful-handoff-secret"); });
expect(JSON.stringify(recoveryAction.evidence)).not.toContain("sk-test-successful-handoff-secret");
const sourceIssue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null); const sourceIssue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null);
expect(sourceIssue?.status).toBe("blocked"); expect(sourceIssue?.status).toBe("blocked");
await expect(sourceBlockerIssueIds(companyId, issueId)).resolves.toEqual([recovery?.id]); await expect(sourceBlockerIssueIds(companyId, issueId)).resolves.toEqual([]);
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments[0]?.body).toBe(SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY); expect(comments[0]?.body).toBe(SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY);
@@ -1636,7 +1647,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect.objectContaining({ expect.objectContaining({
title: "Recovery owner", title: "Recovery owner",
rows: expect.arrayContaining([ rows: expect.arrayContaining([
expect.objectContaining({ type: "issue_link", identifier: recovery?.identifier }), expect.objectContaining({ type: "key_value", label: "Recovery action", value: recoveryAction.id }),
expect.objectContaining({ type: "agent_link", label: "Recovery owner", name: "CodexCoder" }), expect.objectContaining({ type: "agent_link", label: "Recovery owner", name: "CodexCoder" }),
]), ]),
}), }),
@@ -1657,7 +1668,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
}); });
it("escalates an exhausted successful handoff run that still leaves no disposition", async () => { it("escalates an exhausted successful handoff run that still leaves no disposition", async () => {
const { companyId, runId, issueId } = await seedStrandedIssueFixture({ const { companyId, agentId, runId, issueId } = await seedStrandedIssueFixture({
status: "in_progress", status: "in_progress",
runStatus: "succeeded", runStatus: "succeeded",
livenessState: "advanced", livenessState: "advanced",
@@ -1687,17 +1698,21 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(result.successfulContinuationObserved).toBe(0); expect(result.successfulContinuationObserved).toBe(0);
expect(result.successfulRunHandoffEscalated).toBe(1); expect(result.successfulRunHandoffEscalated).toBe(1);
const recovery = await waitForValue(async () => const recoveryAction = await expectSourceScopedStrandedRecoveryAction({
db.select().from(issues).where( companyId,
and( agentId,
eq(issues.companyId, companyId), issueId,
eq(issues.originKind, "stranded_issue_recovery"), runId,
eq(issues.originId, issueId), previousStatus: "in_progress",
), retryReason: null,
).then((rows) => rows[0] ?? null), cause: SUCCESSFUL_RUN_MISSING_STATE_REASON,
); kind: "missing_disposition",
expect(recovery?.description).toContain("Latest handoff run status: `succeeded`"); });
expect(recovery?.description).toContain("Suggested"); expect(recoveryAction.evidence).toMatchObject({
sourceRunId,
latestRunStatus: "succeeded",
missingDisposition: "clear_next_step",
});
}); });
it("clears the detached warning when the run reports activity again", async () => { it("clears the detached warning when the run reports activity again", async () => {
@@ -2063,7 +2078,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null); 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("blocked");
const recovery = await expectStrandedRecoveryArtifacts({ const recoveryAction = await expectSourceScopedStrandedRecoveryAction({
companyId, companyId,
agentId, agentId,
issueId, issueId,
@@ -2071,13 +2086,14 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
previousStatus: "todo", previousStatus: "todo",
retryReason: "assignment_recovery", retryReason: "assignment_recovery",
}); });
expect(recovery.description ?? "").not.toContain("sk-test-recovery-secret"); expect(JSON.stringify(recoveryAction.evidence)).not.toContain("sk-test-recovery-secret");
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(1); expect(comments).toHaveLength(1);
expect(comments[0]?.body).toContain("retried dispatch"); expect(comments[0]?.body).toContain("retried dispatch");
expect(comments[0]?.body).toContain("Latest retry failure details were withheld from the issue thread"); expect(comments[0]?.body).toContain("Latest retry failure details were withheld from the issue thread");
expect(comments[0]?.body).toContain(`Recovery issue: [${recovery.identifier}]`); expect(comments[0]?.body).toContain(`Recovery action: \`${recoveryAction.id}\``);
expect(comments[0]?.body).toContain("Recovery owner: [CodexCoder]");
}); });
it("blocks an already stranded recovery issue without creating a recovery child", async () => { it("blocks an already stranded recovery issue without creating a recovery child", async () => {
@@ -2457,7 +2473,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null); 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("blocked");
const recovery = await expectStrandedRecoveryArtifacts({ const recoveryAction = await expectSourceScopedStrandedRecoveryAction({
companyId, companyId,
agentId, agentId,
issueId, issueId,
@@ -2470,7 +2486,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(comments).toHaveLength(1); expect(comments).toHaveLength(1);
expect(comments[0]?.body).toContain("retried continuation"); expect(comments[0]?.body).toContain("retried continuation");
expect(comments[0]?.body).toContain("Latest retry failure details were withheld from the issue thread"); expect(comments[0]?.body).toContain("Latest retry failure details were withheld from the issue thread");
expect(comments[0]?.body).toContain(`Recovery issue: [${recovery.identifier}]`); expect(comments[0]?.body).toContain(`Recovery action: \`${recoveryAction.id}\``);
expect(comments[0]?.body).toContain("Recovery owner: [CodexCoder]");
}); });
it("redacts error-code-only stranded recovery failures in issue copy", async () => { it("redacts error-code-only stranded recovery failures in issue copy", async () => {
@@ -2486,7 +2503,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const result = await heartbeat.reconcileStrandedAssignedIssues(); const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.escalated).toBe(1); expect(result.escalated).toBe(1);
const recovery = await expectStrandedRecoveryArtifacts({ const recoveryAction = await expectSourceScopedStrandedRecoveryAction({
companyId, companyId,
agentId, agentId,
issueId, issueId,
@@ -2494,8 +2511,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
previousStatus: "in_progress", previousStatus: "in_progress",
retryReason: "issue_continuation_needed", retryReason: "issue_continuation_needed",
}); });
expect(recovery.description).toContain("Latest retry failure details were withheld from the issue thread"); expect(recoveryAction.evidence).toMatchObject({
expect(recovery.description).not.toContain("- Failure: none recorded"); latestRunErrorCode: "adapter_exit_code",
});
expect(JSON.stringify(recoveryAction.evidence)).not.toContain("- Failure: none recorded");
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(1); expect(comments).toHaveLength(1);
@@ -2516,6 +2535,15 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
); );
expect(results.every((result) => result.status === "fulfilled")).toBe(true); expect(results.every((result) => result.status === "fulfilled")).toBe(true);
const actions = await db
.select()
.from(issueRecoveryActions)
.where(and(
eq(issueRecoveryActions.companyId, companyId),
eq(issueRecoveryActions.sourceIssueId, issueId),
));
expect(actions).toHaveLength(1);
expect(actions[0]?.attemptCount).toBeGreaterThanOrEqual(1);
const recoveries = await db const recoveries = await db
.select() .select()
.from(issues) .from(issues)
@@ -2524,8 +2552,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
eq(issues.originKind, "stranded_issue_recovery"), eq(issues.originKind, "stranded_issue_recovery"),
eq(issues.originId, issueId), eq(issues.originId, issueId),
)); ));
expect(recoveries).toHaveLength(1); expect(recoveries).toHaveLength(0);
await expect(sourceBlockerIssueIds(companyId, issueId)).resolves.toEqual([recoveries[0]?.id]); await expect(sourceBlockerIssueIds(companyId, issueId)).resolves.toEqual([]);
}); });
it("blocks stranded recovery issues in place instead of creating nested recovery issues", async () => { it("blocks stranded recovery issues in place instead of creating nested recovery issues", async () => {
@@ -2783,7 +2811,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null); 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("blocked");
const recovery = await expectStrandedRecoveryArtifacts({ const recoveryAction = await expectSourceScopedStrandedRecoveryAction({
companyId, companyId,
agentId, agentId,
issueId, issueId,
@@ -2796,7 +2824,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(comments).toHaveLength(1); expect(comments).toHaveLength(1);
expect(comments[0]?.body).toContain("automatically retried continuation"); expect(comments[0]?.body).toContain("automatically retried continuation");
expect(comments[0]?.body).toContain("still has no live execution path"); expect(comments[0]?.body).toContain("still has no live execution path");
expect(comments[0]?.body).toContain(`Recovery issue: [${recovery.identifier}]`); expect(comments[0]?.body).toContain(`Recovery action: \`${recoveryAction.id}\``);
expect(comments[0]?.body).toContain("Recovery owner: [CodexCoder]");
}); });
it("allows one productive-terminal recovery after regular continuation recovery made progress", async () => { it("allows one productive-terminal recovery after regular continuation recovery made progress", async () => {
@@ -89,6 +89,10 @@ function registerModuleMocks() {
heartbeatService: () => mockHeartbeatService, heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService, instanceSettingsService: () => mockInstanceSettingsService,
issueApprovalService: () => ({}), issueApprovalService: () => ({}),
issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
}),
issueReferenceService: () => ({ issueReferenceService: () => ({
deleteDocumentSource: async () => undefined, deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({ diffIssueReferenceSummary: () => ({
@@ -60,6 +60,9 @@ const mockIssueThreadInteractionService = vi.hoisted(() => ({
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
})); }));
const mockIssueRecoveryActionService = vi.hoisted(() => ({
getActiveForIssue: vi.fn(async () => null),
}));
function registerRouteMocks() { function registerRouteMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({ vi.doMock("@paperclipai/shared/telemetry", () => ({
@@ -124,6 +127,7 @@ function registerRouteMocks() {
listCompanyIds: vi.fn(async () => [companyId]), listCompanyIds: vi.fn(async () => [companyId]),
}), }),
issueApprovalService: () => ({}), issueApprovalService: () => ({}),
issueRecoveryActionService: () => mockIssueRecoveryActionService,
issueReferenceService: () => ({ issueReferenceService: () => ({
deleteDocumentSource: async () => undefined, deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({ diffIssueReferenceSummary: () => ({
@@ -259,6 +263,8 @@ describe("agent issue mutation checkout ownership", () => {
mockIssueService.getWakeableParentAfterChildCompletion.mockReset(); mockIssueService.getWakeableParentAfterChildCompletion.mockReset();
mockIssueService.listAttachments.mockReset(); mockIssueService.listAttachments.mockReset();
mockIssueService.listWakeableBlockedDependents.mockReset(); mockIssueService.listWakeableBlockedDependents.mockReset();
mockIssueRecoveryActionService.getActiveForIssue.mockReset();
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue(null);
mockIssueService.remove.mockReset(); mockIssueService.remove.mockReset();
mockIssueService.removeAttachment.mockReset(); mockIssueService.removeAttachment.mockReset();
mockIssueService.update.mockReset(); mockIssueService.update.mockReset();
@@ -59,6 +59,10 @@ vi.mock("../services/index.js", () => ({
listCompanyIds: vi.fn(async () => ["company-1"]), listCompanyIds: vi.fn(async () => ["company-1"]),
}), }),
issueApprovalService: () => ({}), issueApprovalService: () => ({}),
issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
}),
issueReferenceService: () => ({ issueReferenceService: () => ({
deleteDocumentSource: async () => undefined, deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({ diffIssueReferenceSummary: () => ({
@@ -81,6 +81,10 @@ function registerRouteMocks() {
syncDocument: async () => undefined, syncDocument: async () => undefined,
syncIssue: async () => undefined, syncIssue: async () => undefined,
}), }),
issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
}),
issueService: () => mockIssueService, issueService: () => mockIssueService,
logActivity: mockLogActivity, logActivity: mockLogActivity,
projectService: () => ({}), projectService: () => ({}),
@@ -116,6 +116,10 @@ function registerServiceMocks() {
syncDocument: async () => undefined, syncDocument: async () => undefined,
syncIssue: async () => undefined, syncIssue: async () => undefined,
}), }),
issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
}),
issueService: () => mockIssueService, issueService: () => mockIssueService,
logActivity: mockLogActivity, logActivity: mockLogActivity,
projectService: () => mockProjectService, projectService: () => mockProjectService,
@@ -86,6 +86,10 @@ function registerModuleMocks() {
heartbeatService: () => mockHeartbeatService, heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService, instanceSettingsService: () => mockInstanceSettingsService,
issueApprovalService: () => ({}), issueApprovalService: () => ({}),
issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
}),
issueReferenceService: () => ({ issueReferenceService: () => ({
deleteDocumentSource: async () => undefined, deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({ diffIssueReferenceSummary: () => ({
@@ -67,6 +67,9 @@ const mockIssueThreadInteractionService = vi.hoisted(() => ({
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []), expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
})); }));
const mockIssueRecoveryActionService = vi.hoisted(() => ({
getActiveForIssue: vi.fn(async () => null),
}));
const mockIssueTreeControlService = vi.hoisted(() => ({ const mockIssueTreeControlService = vi.hoisted(() => ({
getActivePauseHoldGate: vi.fn(async () => null), getActivePauseHoldGate: vi.fn(async () => null),
})); }));
@@ -125,6 +128,7 @@ vi.mock("../services/index.js", () => ({
heartbeatService: () => mockHeartbeatService, heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService, instanceSettingsService: () => mockInstanceSettingsService,
issueApprovalService: () => ({}), issueApprovalService: () => ({}),
issueRecoveryActionService: () => mockIssueRecoveryActionService,
issueReferenceService: () => ({ issueReferenceService: () => ({
deleteDocumentSource: async () => undefined, deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({ diffIssueReferenceSummary: () => ({
@@ -238,6 +242,7 @@ describe.sequential("issue comment reopen routes", () => {
mockInstanceSettingsService.get.mockReset(); mockInstanceSettingsService.get.mockReset();
mockInstanceSettingsService.listCompanyIds.mockReset(); mockInstanceSettingsService.listCompanyIds.mockReset();
mockRoutineService.syncRunStatusForIssue.mockReset(); mockRoutineService.syncRunStatusForIssue.mockReset();
mockIssueRecoveryActionService.getActiveForIssue.mockReset();
mockIssueTreeControlService.getActivePauseHoldGate.mockReset(); mockIssueTreeControlService.getActivePauseHoldGate.mockReset();
mockTxInsertValues.mockReset(); mockTxInsertValues.mockReset();
mockTxInsert.mockReset(); mockTxInsert.mockReset();
@@ -274,6 +279,7 @@ describe.sequential("issue comment reopen routes", () => {
}); });
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]); mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined); mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined);
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue(null);
mockIssueTreeControlService.getActivePauseHoldGate.mockResolvedValue(null); mockIssueTreeControlService.getActivePauseHoldGate.mockResolvedValue(null);
mockIssueService.addComment.mockResolvedValue({ mockIssueService.addComment.mockResolvedValue({
id: "comment-1", id: "comment-1",
@@ -61,6 +61,10 @@ vi.mock("../services/index.js", () => ({
syncDocument: async () => undefined, syncDocument: async () => undefined,
syncIssue: async () => undefined, syncIssue: async () => undefined,
}), }),
issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
}),
issueService: () => mockIssueService, issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined), logActivity: vi.fn(async () => undefined),
projectService: () => ({ projectService: () => ({
@@ -133,6 +133,10 @@ function registerModuleMocks() {
syncDocument: async () => undefined, syncDocument: async () => undefined,
syncIssue: async () => undefined, syncIssue: async () => undefined,
}), }),
issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
}),
issueService: () => mockIssueService, issueService: () => mockIssueService,
issueThreadInteractionService: () => mockIssueThreadInteractionService, issueThreadInteractionService: () => mockIssueThreadInteractionService,
logActivity: mockLogActivity, logActivity: mockLogActivity,
@@ -82,6 +82,10 @@ function registerModuleMocks() {
syncDocument: async () => undefined, syncDocument: async () => undefined,
syncIssue: async () => undefined, syncIssue: async () => undefined,
}), }),
issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
}),
issueService: () => mockIssueService, issueService: () => mockIssueService,
issueThreadInteractionService: () => mockIssueThreadInteractionService, issueThreadInteractionService: () => mockIssueThreadInteractionService,
logActivity: mockLogActivity, logActivity: mockLogActivity,
@@ -93,6 +93,10 @@ function registerModuleMocks() {
heartbeatService: () => mockHeartbeatService, heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => ({}), issueApprovalService: () => ({}),
issueReferenceService: () => mockIssueReferenceService, issueReferenceService: () => mockIssueReferenceService,
issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
}),
issueService: () => mockIssueService, issueService: () => mockIssueService,
issueThreadInteractionService: () => mockIssueThreadInteractionService, issueThreadInteractionService: () => mockIssueThreadInteractionService,
logActivity: mockLogActivity, logActivity: mockLogActivity,
@@ -0,0 +1,767 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { and, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
agents,
activityLog,
companies,
createDb,
issueComments,
issueRecoveryActions,
issueRelations,
issues,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
import { issueRecoveryActionService } from "../services/issue-recovery-actions.js";
import { recoveryService } from "../services/recovery/service.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
function makeRecoveryActionRow(overrides: Record<string, unknown> = {}) {
const now = new Date("2026-05-09T19:30:00.000Z");
return {
id: randomUUID(),
companyId: "company-1",
sourceIssueId: "source-1",
recoveryIssueId: null,
kind: "missing_disposition",
status: "active",
ownerType: "agent",
ownerAgentId: "agent-1",
ownerUserId: null,
previousOwnerAgentId: null,
returnOwnerAgentId: null,
cause: "successful_run_missing_issue_disposition",
fingerprint: "missing-disposition:fingerprint",
evidence: {},
nextAction: "Choose a valid issue disposition.",
wakePolicy: null,
monitorPolicy: null,
attemptCount: 1,
maxAttempts: null,
timeoutAt: null,
lastAttemptAt: now,
outcome: null,
resolutionNote: null,
resolvedAt: null,
createdAt: now,
updatedAt: now,
...overrides,
};
}
describe("issueRecoveryActionService", () => {
it("does not reactivate an action resolved between the active read and update", async () => {
const existingRow = makeRecoveryActionRow({ id: "existing-action", attemptCount: 1 });
const createdRow = makeRecoveryActionRow({ id: "new-action", attemptCount: 1 });
const selectResults = [[existingRow], []];
const makeSelectQuery = (rows: unknown[]) => ({
from() {
return this;
},
where() {
return this;
},
orderBy() {
return this;
},
limit() {
return Promise.resolve(rows);
},
});
const fakeDb = {
select: vi.fn(() => makeSelectQuery(selectResults.shift() ?? [])),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(() => ({
returning: vi.fn(async () => []),
})),
})),
})),
insert: vi.fn(() => ({
values: vi.fn(() => ({
returning: vi.fn(async () => [createdRow]),
})),
})),
};
const result = await issueRecoveryActionService(fakeDb as never).upsertSourceScoped({
companyId: "company-1",
sourceIssueId: "source-1",
kind: "missing_disposition",
ownerType: "agent",
ownerAgentId: "agent-1",
cause: "successful_run_missing_issue_disposition",
fingerprint: "missing-disposition:fingerprint",
nextAction: "Choose a valid issue disposition.",
});
expect(result).toMatchObject({ id: "new-action", status: "active" });
expect(fakeDb.update).toHaveBeenCalledTimes(1);
expect(fakeDb.insert).toHaveBeenCalledTimes(1);
});
});
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres issue recovery action tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("issue recovery actions", () => {
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-recovery-actions-");
db = createDb(tempDb.connectionString);
}, 30_000);
afterEach(async () => {
await db.delete(issueRecoveryActions);
await db.delete(issueComments);
await db.delete(activityLog);
await db.delete(issues);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedCompany() {
const companyId = randomUUID();
const managerId = randomUUID();
const coderId = randomUUID();
const sourceIssueId = randomUUID();
const prefix = `RA${companyId.replaceAll("-", "").slice(0, 6).toUpperCase()}`;
await db.insert(companies).values({
id: companyId,
name: "Recovery Co",
issuePrefix: prefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values([
{
id: managerId,
companyId,
name: "CTO",
role: "cto",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: coderId,
companyId,
name: "Coder",
role: "engineer",
status: "idle",
reportsTo: managerId,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
await db.insert(issues).values({
id: sourceIssueId,
companyId,
title: "Implement backend recovery",
status: "in_progress",
priority: "medium",
assigneeAgentId: coderId,
issueNumber: 1,
identifier: `${prefix}-1`,
});
const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
return { companyId, managerId, coderId, sourceIssueId, prefix, sourceIssue: sourceIssue! };
}
function createApp(actor: any = { type: "board", source: "local_implicit" }) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", issueRoutes(db, {} as any));
app.use(errorHandler);
return app;
}
it("upserts one active source-scoped action per issue and keeps company scoping explicit", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
const svc = issueRecoveryActionService(db);
const first = await svc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "stranded_assigned_issue",
ownerType: "agent",
ownerAgentId: managerId,
cause: "stranded_assigned_issue",
fingerprint: "recovery:fingerprint",
evidence: { latestRunId: "run-1" },
nextAction: "Restore a live execution path.",
wakePolicy: { type: "wake_owner" },
});
const second = await svc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "stranded_assigned_issue",
ownerType: "agent",
ownerAgentId: managerId,
cause: "stranded_assigned_issue",
fingerprint: "recovery:fingerprint",
evidence: { latestRunId: "run-2" },
nextAction: "Restore a live execution path.",
wakePolicy: { type: "wake_owner" },
});
expect(second.id).toBe(first.id);
expect(second.attemptCount).toBe(2);
expect(second.evidence).toMatchObject({ latestRunId: "run-2" });
expect(await svc.getActiveForIssue(companyId, sourceIssueId)).toMatchObject({ id: first.id });
expect(await svc.getActiveForIssue(randomUUID(), sourceIssueId)).toBeNull();
});
it("escalates stranded assigned work into a source action instead of a recovery issue", async () => {
const { companyId, managerId, coderId, sourceIssue } = await seedCompany();
const enqueueWakeup = vi.fn(async () => null);
const recovery = recoveryService(db, { enqueueWakeup });
const latestRun = {
id: randomUUID(),
agentId: coderId,
status: "failed",
error: "adapter failed",
errorCode: "adapter_failed",
contextSnapshot: { retryReason: "issue_continuation_needed" },
livenessState: "needs_followup",
} as const;
await recovery.escalateStrandedAssignedIssue({
issue: sourceIssue,
previousStatus: "in_progress",
latestRun,
comment: "Automatic continuation recovery failed.",
});
await recovery.escalateStrandedAssignedIssue({
issue: sourceIssue,
previousStatus: "in_progress",
latestRun,
comment: "Automatic continuation recovery failed.",
});
const actionRows = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.sourceIssueId, sourceIssue.id));
expect(actionRows).toHaveLength(1);
expect(actionRows[0]).toMatchObject({
companyId,
kind: "stranded_assigned_issue",
status: "active",
previousOwnerAgentId: coderId,
returnOwnerAgentId: coderId,
cause: "stranded_assigned_issue",
attemptCount: 2,
});
const [updatedIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssue.id));
expect(updatedIssue).toMatchObject({
status: "blocked",
});
const recoveryIssues = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stranded_issue_recovery")));
expect(recoveryIssues).toHaveLength(0);
expect(enqueueWakeup).toHaveBeenCalledTimes(2);
expect(enqueueWakeup.mock.calls[0]?.[1]?.payload).toMatchObject({
issueId: sourceIssue.id,
sourceIssueId: sourceIssue.id,
recoveryCause: "stranded_assigned_issue",
});
});
it("reuses the same source-scoped action when latest run IDs change while the cause stays the same", async () => {
const { companyId, managerId, coderId, sourceIssue } = await seedCompany();
const enqueueWakeup = vi.fn(async () => null);
const recovery = recoveryService(db, { enqueueWakeup });
const firstLatestRun = {
id: randomUUID(),
agentId: coderId,
status: "failed",
error: "adapter failed",
errorCode: "adapter_failed",
contextSnapshot: { retryReason: "issue_continuation_needed" },
livenessState: "needs_followup",
} as const;
const secondLatestRun = {
...firstLatestRun,
id: randomUUID(),
};
await recovery.escalateStrandedAssignedIssue({
issue: sourceIssue,
previousStatus: "in_progress",
latestRun: firstLatestRun,
comment: "Automatic continuation recovery failed.",
});
await recovery.escalateStrandedAssignedIssue({
issue: sourceIssue,
previousStatus: "in_progress",
latestRun: secondLatestRun,
comment: "Automatic continuation recovery failed.",
});
const actionRows = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.sourceIssueId, sourceIssue.id));
expect(actionRows).toHaveLength(1);
expect(actionRows[0]).toMatchObject({
companyId,
kind: "stranded_assigned_issue",
status: "active",
previousOwnerAgentId: coderId,
returnOwnerAgentId: coderId,
cause: "stranded_assigned_issue",
attemptCount: 2,
});
expect(actionRows[0]?.evidence).toMatchObject({ latestRunId: secondLatestRun.id });
expect(enqueueWakeup).toHaveBeenCalledTimes(2);
expect(enqueueWakeup.mock.calls[1]?.[1]?.payload).toMatchObject({
issueId: sourceIssue.id,
sourceIssueId: sourceIssue.id,
strandedRunId: secondLatestRun.id,
recoveryCause: "stranded_assigned_issue",
});
});
it("keeps the source issue blocked when source-scoped wakeup is claimed synchronously", async () => {
const { companyId, managerId, coderId, sourceIssue } = await seedCompany();
await db.update(agents).set({ status: "paused" }).where(eq(agents.id, managerId));
const enqueueWakeup = vi.fn(async () => {
await db
.update(issues)
.set({ status: "in_progress" })
.where(eq(issues.id, sourceIssue.id));
return null;
});
const recovery = recoveryService(db, { enqueueWakeup });
const firstLatestRun = {
id: randomUUID(),
agentId: coderId,
status: "failed",
error: "adapter failed",
errorCode: "adapter_failed",
contextSnapshot: { retryReason: "issue_continuation_needed" },
livenessState: "needs_followup",
} as const;
await recovery.escalateStrandedAssignedIssue({
issue: sourceIssue,
previousStatus: "in_progress",
latestRun: firstLatestRun,
comment: "Automatic continuation recovery failed.",
});
const [afterFirst] = await db.select().from(issues).where(eq(issues.id, sourceIssue.id));
expect(afterFirst?.status).toBe("blocked");
expect(afterFirst?.assigneeAgentId).toBe(coderId);
const secondLatestRun = {
...firstLatestRun,
id: randomUUID(),
};
await recovery.escalateStrandedAssignedIssue({
issue: sourceIssue,
previousStatus: "in_progress",
latestRun: secondLatestRun,
comment: "Automatic continuation recovery failed.",
});
const actionRows = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.sourceIssueId, sourceIssue.id));
expect(actionRows).toHaveLength(1);
expect(actionRows[0]).toMatchObject({
companyId,
kind: "stranded_assigned_issue",
status: "active",
previousOwnerAgentId: coderId,
returnOwnerAgentId: coderId,
cause: "stranded_assigned_issue",
attemptCount: 2,
});
const [afterSecond] = await db.select().from(issues).where(eq(issues.id, sourceIssue.id));
expect(afterSecond?.status).toBe("blocked");
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, sourceIssue.id));
expect(comments).toHaveLength(1);
expect(comments[0]?.body).toContain("Recovery action:");
});
it("does not create nested recovery artifacts when issue-backed fallback work itself fails", async () => {
const { companyId, managerId, sourceIssueId, prefix } = await seedCompany();
const recoveryIssueId = randomUUID();
await db.insert(issues).values({
id: recoveryIssueId,
companyId,
title: "Recover stalled issue",
status: "in_progress",
priority: "medium",
assigneeAgentId: managerId,
parentId: sourceIssueId,
issueNumber: 2,
identifier: `${prefix}-2`,
originKind: "stranded_issue_recovery",
originId: sourceIssueId,
originFingerprint: `stranded_issue_recovery:${sourceIssueId}`,
});
const [recoveryIssue] = await db.select().from(issues).where(eq(issues.id, recoveryIssueId));
const recovery = recoveryService(db, { enqueueWakeup: vi.fn(async () => null) });
await recovery.escalateStrandedAssignedIssue({
issue: recoveryIssue!,
previousStatus: "in_progress",
latestRun: {
id: randomUUID(),
agentId: managerId,
status: "failed",
error: "adapter failed",
errorCode: "adapter_failed",
contextSnapshot: { retryReason: "issue_continuation_needed" },
livenessState: "needs_followup",
},
});
const actionRows = await db.select().from(issueRecoveryActions);
expect(actionRows).toHaveLength(0);
const recoveryIssues = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stranded_issue_recovery")));
expect(recoveryIssues).toHaveLength(1);
expect(recoveryIssues[0]?.status).toBe("blocked");
});
it("exposes active recovery actions on the issue read API", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "missing_disposition",
ownerType: "agent",
ownerAgentId: managerId,
cause: "successful_run_missing_issue_disposition",
fingerprint: "missing-disposition:fingerprint",
evidence: { sourceRunId: "run-1" },
nextAction: "Choose a valid issue disposition.",
wakePolicy: { type: "wake_owner" },
});
const app = createApp();
const detail = await request(app).get(`/api/issues/${sourceIssueId}`).expect(200);
expect(detail.body.activeRecoveryAction).toMatchObject({
id: action.id,
sourceIssueId,
kind: "missing_disposition",
ownerAgentId: managerId,
});
const list = await request(app).get(`/api/issues/${sourceIssueId}/recovery-actions`).expect(200);
expect(list.body.active).toMatchObject({ id: action.id });
expect(list.body.actions).toHaveLength(1);
});
it("resolves an active recovery action and removes it from active projections", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "missing_disposition",
ownerType: "agent",
ownerAgentId: managerId,
cause: "successful_run_missing_issue_disposition",
fingerprint: "missing-disposition:fingerprint",
evidence: { sourceRunId: "run-1" },
nextAction: "Choose a valid issue disposition.",
wakePolicy: { type: "wake_owner" },
});
const app = createApp();
const resolved = await request(app)
.post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`)
.send({
actionId: action.id,
outcome: "restored",
sourceIssueStatus: "done",
resolutionNote: "Operator confirmed the source issue is complete.",
})
.expect(200);
expect(resolved.body.issue).toMatchObject({
id: sourceIssueId,
status: "done",
activeRecoveryAction: null,
});
expect(resolved.body.recoveryAction).toMatchObject({
id: action.id,
status: "resolved",
outcome: "restored",
resolutionNote: "Operator confirmed the source issue is complete.",
});
expect(resolved.body.recoveryAction.resolvedAt).toBeTruthy();
expect(await recoveryActionSvc.getActiveForIssue(companyId, sourceIssueId)).toBeNull();
const detail = await request(app).get(`/api/issues/${sourceIssueId}`).expect(200);
expect(detail.body.activeRecoveryAction).toBeNull();
const activityRows = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, sourceIssueId));
expect(activityRows.map((row) => row.action)).toEqual(
expect.arrayContaining(["issue.updated", "issue.recovery_action_resolved"]),
);
});
it("rejects blocked recovery resolution when the source issue has no first-class blockers", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "issue_graph_liveness",
ownerType: "agent",
ownerAgentId: managerId,
cause: "issue_graph_liveness",
fingerprint: "graph-liveness:blocked-without-blocker",
evidence: { latestIssueStatus: "in_progress" },
nextAction: "Choose a disposition with a live continuation path.",
wakePolicy: { type: "manual" },
});
const app = createApp();
const rejected = await request(app)
.post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`)
.send({
actionId: action.id,
outcome: "blocked",
sourceIssueStatus: "blocked",
})
.expect(422);
expect(rejected.body.error).toContain("requires an unresolved first-class blocker");
const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
expect(sourceIssue?.status).toBe("in_progress");
const [actionRow] = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.id, action.id));
expect(actionRow).toMatchObject({
status: "active",
outcome: null,
resolvedAt: null,
});
});
it("allows blocked recovery resolution when the source issue has an unresolved first-class blocker", async () => {
const { companyId, managerId, sourceIssueId, prefix } = await seedCompany();
const blockerIssueId = randomUUID();
await db.insert(issues).values({
id: blockerIssueId,
companyId,
title: "Unblock recovery disposition",
status: "todo",
priority: "medium",
assigneeAgentId: managerId,
issueNumber: 2,
identifier: `${prefix}-2`,
});
await db.insert(issueRelations).values({
companyId,
issueId: blockerIssueId,
relatedIssueId: sourceIssueId,
type: "blocks",
});
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "issue_graph_liveness",
ownerType: "agent",
ownerAgentId: managerId,
cause: "issue_graph_liveness",
fingerprint: "graph-liveness:blocked-with-blocker",
evidence: { latestIssueStatus: "in_progress" },
nextAction: "Wait for the blocker before continuing.",
wakePolicy: { type: "manual" },
});
const app = createApp();
const resolved = await request(app)
.post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`)
.send({
actionId: action.id,
outcome: "blocked",
sourceIssueStatus: "blocked",
resolutionNote: "The source issue is explicitly blocked by a follow-up.",
})
.expect(200);
expect(resolved.body.issue).toMatchObject({
id: sourceIssueId,
status: "blocked",
activeRecoveryAction: null,
});
expect(resolved.body.recoveryAction).toMatchObject({
id: action.id,
status: "resolved",
outcome: "blocked",
resolutionNote: "The source issue is explicitly blocked by a follow-up.",
});
expect(await recoveryActionSvc.getActiveForIssue(companyId, sourceIssueId)).toBeNull();
});
it("rejects false-positive recovery resolution without an explicit source issue status", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "issue_graph_liveness",
ownerType: "agent",
ownerAgentId: managerId,
cause: "issue_graph_liveness",
fingerprint: "graph-liveness:fingerprint",
evidence: { latestIssueStatus: "in_progress" },
nextAction: "Confirm whether the issue is actually stranded.",
wakePolicy: { type: "manual" },
});
const app = createApp();
await request(app)
.post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`)
.send({
actionId: action.id,
outcome: "false_positive",
resolutionNote: "The source issue still has a live execution path.",
})
.expect(400);
const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
expect(sourceIssue?.status).toBe("in_progress");
const [actionRow] = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.id, action.id));
expect(actionRow).toMatchObject({
status: "active",
outcome: null,
resolutionNote: null,
});
});
it("allows false-positive recovery resolution to restore a blocked source issue in the same request", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
await db.update(issues).set({ status: "blocked" }).where(eq(issues.id, sourceIssueId));
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "issue_graph_liveness",
ownerType: "agent",
ownerAgentId: managerId,
cause: "issue_graph_liveness",
fingerprint: "graph-liveness:false-positive-unblock",
evidence: { latestIssueStatus: "blocked" },
nextAction: "Confirm whether the issue is actually stranded.",
wakePolicy: { type: "manual" },
});
const app = createApp();
const resolved = await request(app)
.post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`)
.send({
actionId: action.id,
outcome: "false_positive",
sourceIssueStatus: "in_review",
resolutionNote: "Recovery signal was stale; return to review.",
})
.expect(200);
expect(resolved.body.issue).toMatchObject({
id: sourceIssueId,
status: "in_review",
activeRecoveryAction: null,
});
expect(resolved.body.recoveryAction).toMatchObject({
id: action.id,
status: "resolved",
outcome: "false_positive",
resolutionNote: "Recovery signal was stale; return to review.",
});
});
it("enforces company scope when resolving recovery actions", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "missing_disposition",
ownerType: "agent",
ownerAgentId: managerId,
cause: "successful_run_missing_issue_disposition",
fingerprint: "missing-disposition:fingerprint",
evidence: { sourceRunId: "run-1" },
nextAction: "Choose a valid issue disposition.",
wakePolicy: { type: "wake_owner" },
});
const app = createApp({
type: "agent",
agentId: randomUUID(),
companyId: randomUUID(),
runId: randomUUID(),
source: "agent_jwt",
});
await request(app)
.post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`)
.send({
actionId: action.id,
outcome: "restored",
sourceIssueStatus: "done",
})
.expect(403);
const [actionRow] = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.id, action.id));
expect(actionRow?.status).toBe("active");
});
});
@@ -58,6 +58,10 @@ function registerModuleMocks() {
syncDocument: async () => undefined, syncDocument: async () => undefined,
syncIssue: async () => undefined, syncIssue: async () => undefined,
}), }),
issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
}),
issueService: () => mockIssueService, issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined), logActivity: vi.fn(async () => undefined),
projectService: () => ({}), projectService: () => ({}),
@@ -86,6 +86,10 @@ function registerModuleMocks() {
syncDocument: async () => undefined, syncDocument: async () => undefined,
syncIssue: async () => undefined, syncIssue: async () => undefined,
}), }),
issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
}),
issueService: () => mockIssueService, issueService: () => mockIssueService,
issueThreadInteractionService: () => mockInteractionService, issueThreadInteractionService: () => mockInteractionService,
logActivity: mockLogActivity, logActivity: mockLogActivity,
@@ -73,6 +73,10 @@ vi.mock("../services/index.js", () => ({
syncDocument: async () => undefined, syncDocument: async () => undefined,
syncIssue: async () => undefined, syncIssue: async () => undefined,
}), }),
issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
}),
issueService: () => mockIssueService, issueService: () => mockIssueService,
issueThreadInteractionService: () => mockIssueThreadInteractionService, issueThreadInteractionService: () => mockIssueThreadInteractionService,
logActivity: vi.fn(async () => undefined), logActivity: vi.fn(async () => undefined),
@@ -131,6 +135,10 @@ function registerModuleMocks() {
syncDocument: async () => undefined, syncDocument: async () => undefined,
syncIssue: async () => undefined, syncIssue: async () => undefined,
}), }),
issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
}),
issueService: () => mockIssueService, issueService: () => mockIssueService,
issueThreadInteractionService: () => mockIssueThreadInteractionService, issueThreadInteractionService: () => mockIssueThreadInteractionService,
logActivity: vi.fn(async () => undefined), logActivity: vi.fn(async () => undefined),
@@ -115,6 +115,10 @@ function registerRouteMocks() {
syncDocument: async () => undefined, syncDocument: async () => undefined,
syncIssue: async () => undefined, syncIssue: async () => undefined,
}), }),
issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
}),
issueService: () => mockIssueService, issueService: () => mockIssueService,
logActivity: mockLogActivity, logActivity: mockLogActivity,
projectService: () => ({}), projectService: () => ({}),
@@ -111,6 +111,10 @@ vi.mock("../services/index.js", () => ({
heartbeatService: () => mockHeartbeatService, heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService, instanceSettingsService: () => mockInstanceSettingsService,
issueApprovalService: () => ({}), issueApprovalService: () => ({}),
issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
}),
issueReferenceService: () => mockIssueReferenceService, issueReferenceService: () => mockIssueReferenceService,
issueService: () => mockIssueService, issueService: () => mockIssueService,
logActivity: mockLogActivity, logActivity: mockLogActivity,
+8 -4
View File
@@ -42,6 +42,7 @@ import {
heartbeatService, heartbeatService,
ISSUE_LIST_DEFAULT_LIMIT, ISSUE_LIST_DEFAULT_LIMIT,
issueApprovalService, issueApprovalService,
issueRecoveryActionService,
issueService, issueService,
logActivity, logActivity,
syncInstructionsBundleConfigFromFilePath, syncInstructionsBundleConfigFromFilePath,
@@ -1741,16 +1742,18 @@ export function agentRoutes(
} }
const issuesSvc = issueService(db); const issuesSvc = issueService(db);
const recoveryActionsSvc = issueRecoveryActionService(db);
const rows = await issuesSvc.list(req.actor.companyId, { const rows = await issuesSvc.list(req.actor.companyId, {
assigneeAgentId: req.actor.agentId, assigneeAgentId: req.actor.agentId,
status: "todo,in_progress,blocked", status: "todo,in_progress,blocked",
includeRoutineExecutions: true, includeRoutineExecutions: true,
limit: ISSUE_LIST_DEFAULT_LIMIT, limit: ISSUE_LIST_DEFAULT_LIMIT,
}); });
const dependencyReadiness = await issuesSvc.listDependencyReadiness( const issueIds = rows.map((issue) => issue.id);
req.actor.companyId, const [dependencyReadiness, recoveryActionByIssue] = await Promise.all([
rows.map((issue) => issue.id), issuesSvc.listDependencyReadiness(req.actor.companyId, issueIds),
); recoveryActionsSvc.listActiveForIssues(req.actor.companyId, issueIds),
]);
res.json( res.json(
rows.map((issue) => ({ rows.map((issue) => ({
@@ -1764,6 +1767,7 @@ export function agentRoutes(
parentId: issue.parentId, parentId: issue.parentId,
updatedAt: issue.updatedAt, updatedAt: issue.updatedAt,
activeRun: issue.activeRun, activeRun: issue.activeRun,
activeRecoveryAction: recoveryActionByIssue.get(issue.id) ?? null,
dependencyReady: dependencyReadiness.get(issue.id)?.isDependencyReady ?? true, dependencyReady: dependencyReadiness.get(issue.id)?.isDependencyReady ?? true,
unresolvedBlockerCount: dependencyReadiness.get(issue.id)?.unresolvedBlockerCount ?? 0, unresolvedBlockerCount: dependencyReadiness.get(issue.id)?.unresolvedBlockerCount ?? 0,
unresolvedBlockerIssueIds: dependencyReadiness.get(issue.id)?.unresolvedBlockerIssueIds ?? [], unresolvedBlockerIssueIds: dependencyReadiness.get(issue.id)?.unresolvedBlockerIssueIds ?? [],
+230 -11
View File
@@ -2,9 +2,16 @@ import { randomUUID } from "node:crypto";
import { Router, type Request, type Response } from "express"; import { Router, type Request, type Response } from "express";
import multer from "multer"; import multer from "multer";
import { z } from "zod"; import { z } from "zod";
import { and, desc, eq, inArray } from "drizzle-orm"; import { and, desc, eq, inArray, notInArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { activityLog, executionWorkspaces, issueExecutionDecisions, projectWorkspaces } from "@paperclipai/db"; import {
activityLog,
executionWorkspaces,
issueExecutionDecisions,
issueRelations,
issues as issueRows,
projectWorkspaces,
} from "@paperclipai/db";
import { import {
addIssueCommentSchema, addIssueCommentSchema,
acceptIssueThreadInteractionSchema, acceptIssueThreadInteractionSchema,
@@ -18,6 +25,7 @@ import {
createChildIssueSchema, createChildIssueSchema,
createIssueSchema, createIssueSchema,
resolveCreateIssueStatusDefault, resolveCreateIssueStatusDefault,
resolveIssueRecoveryActionSchema,
feedbackTargetTypeSchema, feedbackTargetTypeSchema,
feedbackTraceStatusSchema, feedbackTraceStatusSchema,
feedbackVoteValueSchema, feedbackVoteValueSchema,
@@ -37,6 +45,7 @@ import {
type CompanySearchQuery, type CompanySearchQuery,
type CompanySearchResponse, type CompanySearchResponse,
type ExecutionWorkspace, type ExecutionWorkspace,
type IssueRelationIssueSummary,
type SuccessfulRunHandoffState, type SuccessfulRunHandoffState,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry"; import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry";
@@ -53,6 +62,7 @@ import {
goalService, goalService,
heartbeatService, heartbeatService,
issueApprovalService, issueApprovalService,
issueRecoveryActionService,
issueThreadInteractionService, issueThreadInteractionService,
ISSUE_LIST_DEFAULT_LIMIT, ISSUE_LIST_DEFAULT_LIMIT,
ISSUE_LIST_MAX_LIMIT, ISSUE_LIST_MAX_LIMIT,
@@ -405,6 +415,47 @@ async function listSuccessfulRunHandoffStates(
return states; return states;
} }
type RecoveryActionsLister = {
listActiveForIssues: (
companyId: string,
sourceIssueIds: string[],
) => Promise<Map<string, NonNullable<IssueRelationIssueSummary["activeRecoveryAction"]>>>;
};
async function relationRecoveryActionMap(
recoveryActionsSvc: RecoveryActionsLister,
companyId: string,
relations: { blockedBy: IssueRelationIssueSummary[]; blocks: IssueRelationIssueSummary[] },
): Promise<Map<string, NonNullable<IssueRelationIssueSummary["activeRecoveryAction"]>>> {
const candidates: IssueRelationIssueSummary[] = [];
const visit = (summary: IssueRelationIssueSummary) => {
candidates.push(summary);
for (const terminal of summary.terminalBlockers ?? []) {
visit(terminal);
}
};
for (const blocker of relations.blockedBy) visit(blocker);
for (const blocking of relations.blocks) visit(blocking);
if (candidates.length === 0) return new Map();
const ids = [...new Set(candidates.map((summary) => summary.id))];
return recoveryActionsSvc.listActiveForIssues(companyId, ids);
}
function withRecoveryActionsOnRelationSummaries(
relations: { blockedBy: IssueRelationIssueSummary[]; blocks: IssueRelationIssueSummary[] },
recoveryActionByIssueId: Map<string, NonNullable<IssueRelationIssueSummary["activeRecoveryAction"]>>,
) {
const augment = (summary: IssueRelationIssueSummary): IssueRelationIssueSummary => ({
...summary,
activeRecoveryAction: recoveryActionByIssueId.get(summary.id) ?? summary.activeRecoveryAction ?? null,
terminalBlockers: summary.terminalBlockers?.map(augment),
});
return {
blockedBy: relations.blockedBy.map(augment),
blocks: relations.blocks.map(augment),
};
}
const ACTIVE_REVIEW_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]); const ACTIVE_REVIEW_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
const INVALID_AGENT_IN_REVIEW_DISPOSITION_MESSAGE = const INVALID_AGENT_IN_REVIEW_DISPOSITION_MESSAGE =
@@ -787,6 +838,7 @@ export function issueRoutes(
const projectsSvc = projectService(db); const projectsSvc = projectService(db);
const goalsSvc = goalService(db); const goalsSvc = goalService(db);
const issueApprovalsSvc = issueApprovalService(db); const issueApprovalsSvc = issueApprovalService(db);
const recoveryActionsSvc = issueRecoveryActionService(db);
const executionWorkspacesSvc = executionWorkspaceServiceDirect(db); const executionWorkspacesSvc = executionWorkspaceServiceDirect(db);
const workProductsSvc = workProductService(db); const workProductsSvc = workProductService(db);
const documentsSvc = documentService(db); const documentsSvc = documentService(db);
@@ -1447,14 +1499,15 @@ export function issueRoutes(
limit, limit,
offset, offset,
}); });
const handoffStates = await listSuccessfulRunHandoffStates( const issueIds = result.map((issue) => issue.id);
db, const [handoffStates, recoveryActionByIssue] = await Promise.all([
companyId, listSuccessfulRunHandoffStates(db, companyId, issueIds),
result.map((issue) => issue.id), recoveryActionsSvc.listActiveForIssues(companyId, issueIds),
); ]);
res.json(result.map((issue) => ({ res.json(result.map((issue) => ({
...issue, ...issue,
successfulRunHandoff: handoffStates.get(issue.id) ?? null, successfulRunHandoff: handoffStates.get(issue.id) ?? null,
activeRecoveryAction: recoveryActionByIssue.get(issue.id) ?? null,
}))); })));
}); });
@@ -1541,6 +1594,7 @@ export function issueRoutes(
attachments, attachments,
continuationSummary, continuationSummary,
currentExecutionWorkspace, currentExecutionWorkspace,
activeRecoveryAction,
] = ] =
await Promise.all([ await Promise.all([
resolveIssueProjectAndGoal(issue), resolveIssueProjectAndGoal(issue),
@@ -1554,7 +1608,17 @@ export function issueRoutes(
svc.listAttachments(issue.id), svc.listAttachments(issue.id),
documentsSvc.getIssueDocumentByKey(issue.id, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY), documentsSvc.getIssueDocumentByKey(issue.id, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY),
currentExecutionWorkspacePromise, currentExecutionWorkspacePromise,
recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id),
]); ]);
const recoveryActionsByRelationIssue = await relationRecoveryActionMap(
recoveryActionsSvc,
issue.companyId,
relations,
);
const relationsWithRecoveryActions = withRecoveryActionsOnRelationSummaries(
relations,
recoveryActionsByRelationIssue,
);
res.json({ res.json({
issue: { issue: {
@@ -1567,12 +1631,13 @@ export function issueRoutes(
...(blockerAttention ? { blockerAttention } : {}), ...(blockerAttention ? { blockerAttention } : {}),
productivityReview, productivityReview,
scheduledRetry, scheduledRetry,
activeRecoveryAction,
priority: issue.priority, priority: issue.priority,
projectId: issue.projectId, projectId: issue.projectId,
goalId: goal?.id ?? issue.goalId, goalId: goal?.id ?? issue.goalId,
parentId: issue.parentId, parentId: issue.parentId,
blockedBy: relations.blockedBy, blockedBy: relationsWithRecoveryActions.blockedBy,
blocks: relations.blocks, blocks: relationsWithRecoveryActions.blocks,
assigneeAgentId: issue.assigneeAgentId, assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId, assigneeUserId: issue.assigneeUserId,
originKind: issue.originKind, originKind: issue.originKind,
@@ -1649,6 +1714,7 @@ export function issueRoutes(
referenceSummary, referenceSummary,
successfulRunHandoffStates, successfulRunHandoffStates,
scheduledRetry, scheduledRetry,
activeRecoveryAction,
] = await Promise.all([ ] = await Promise.all([
resolveIssueProjectAndGoal(issue), resolveIssueProjectAndGoal(issue),
svc.getAncestors(issue.id), svc.getAncestors(issue.id),
@@ -1660,7 +1726,17 @@ export function issueRoutes(
issueReferencesSvc.listIssueReferenceSummary(issue.id), issueReferencesSvc.listIssueReferenceSummary(issue.id),
listSuccessfulRunHandoffStates(db, issue.companyId, [issue.id]), listSuccessfulRunHandoffStates(db, issue.companyId, [issue.id]),
svc.getCurrentScheduledRetry(issue.id), svc.getCurrentScheduledRetry(issue.id),
recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id),
]); ]);
const recoveryActionsByRelationIssue = await relationRecoveryActionMap(
recoveryActionsSvc,
issue.companyId,
relations,
);
const relationsWithRecoveryActions = withRecoveryActionsOnRelationSummaries(
relations,
recoveryActionsByRelationIssue,
);
const mentionedProjects = mentionedProjectIds.length > 0 const mentionedProjects = mentionedProjectIds.length > 0
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds) ? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
: []; : [];
@@ -1676,8 +1752,9 @@ export function issueRoutes(
productivityReview, productivityReview,
successfulRunHandoff: successfulRunHandoffStates.get(issue.id) ?? null, successfulRunHandoff: successfulRunHandoffStates.get(issue.id) ?? null,
scheduledRetry, scheduledRetry,
blockedBy: relations.blockedBy, activeRecoveryAction,
blocks: relations.blocks, blockedBy: relationsWithRecoveryActions.blockedBy,
blocks: relationsWithRecoveryActions.blocks,
relatedWork: referenceSummary, relatedWork: referenceSummary,
referencedIssueIdentifiers: referenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id), referencedIssueIdentifiers: referenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id),
...documentPayload, ...documentPayload,
@@ -1689,6 +1766,148 @@ export function issueRoutes(
}); });
}); });
router.get("/issues/:id/recovery-actions", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
const active = await recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id);
res.json({
active,
actions: active ? [active] : [],
});
});
router.post("/issues/:id/recovery-actions/resolve", validate(resolveIssueRecoveryActionSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return;
const { actionId, outcome, sourceIssueStatus, resolutionNote } = req.body;
if (outcome === "false_positive" || outcome === "cancelled") {
assertBoard(req);
}
const actor = getActorInfo(req);
const updateFields = sourceIssueStatus ? { status: sourceIssueStatus } : {};
await assertAgentInReviewReviewPath({
existing,
updateFields,
actorType: req.actor.type,
});
const actionStatus = outcome === "cancelled" ? "cancelled" : "resolved";
const result = await db.transaction(async (tx) => {
let issue = existing;
if (outcome === "blocked") {
const unresolvedBlockers = await tx
.select({ id: issueRows.id })
.from(issueRelations)
.innerJoin(issueRows, eq(issueRelations.issueId, issueRows.id))
.where(
and(
eq(issueRelations.companyId, existing.companyId),
eq(issueRelations.relatedIssueId, existing.id),
eq(issueRelations.type, "blocks"),
notInArray(issueRows.status, ["done", "cancelled"]),
),
)
.limit(1);
if (unresolvedBlockers.length === 0) {
throw unprocessable("Blocked recovery resolution requires an unresolved first-class blocker on the source issue");
}
}
if (sourceIssueStatus) {
const updatedIssue = await svc.update(
id,
{
status: sourceIssueStatus,
actorAgentId: actor.agentId ?? null,
actorUserId: actor.actorType === "user" ? actor.actorId : null,
},
tx,
);
if (!updatedIssue) throw notFound("Issue not found");
issue = updatedIssue;
}
const recoveryAction = await recoveryActionsSvc.resolveActiveForIssue(
{
companyId: existing.companyId,
sourceIssueId: existing.id,
actionId: actionId ?? null,
status: actionStatus,
outcome,
resolutionNote: resolutionNote ?? null,
},
tx,
);
if (!recoveryAction) throw notFound("Active recovery action not found");
return { issue, recoveryAction };
});
await routinesSvc.syncRunStatusForIssue(result.issue.id);
if (sourceIssueStatus && existing.status !== result.issue.status) {
await logActivity(db, {
companyId: result.issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.updated",
entityType: "issue",
entityId: result.issue.id,
details: {
identifier: result.issue.identifier,
status: result.issue.status,
source: "recovery_action_resolution",
recoveryActionId: result.recoveryAction.id,
_previous: {
status: existing.status,
},
},
});
}
await logActivity(db, {
companyId: result.issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.recovery_action_resolved",
entityType: "issue",
entityId: result.issue.id,
details: {
identifier: result.issue.identifier,
recoveryActionId: result.recoveryAction.id,
recoveryActionStatus: result.recoveryAction.status,
outcome: result.recoveryAction.outcome,
sourceIssueStatus: sourceIssueStatus ?? null,
resolutionNote: result.recoveryAction.resolutionNote,
},
});
res.json({
issue: {
...result.issue,
activeRecoveryAction: null,
},
recoveryAction: result.recoveryAction,
});
});
router.get("/issues/:id/work-products", async (req, res) => { router.get("/issues/:id/work-products", async (req, res) => {
const id = req.params.id as string; const id = req.params.id as string;
const issue = await svc.getById(id); const issue = await svc.getById(id);
+7 -3
View File
@@ -1691,6 +1691,7 @@ function shouldAutoCheckoutIssueForWake(input: {
const wakeReason = readNonEmptyString(input.contextSnapshot?.wakeReason); const wakeReason = readNonEmptyString(input.contextSnapshot?.wakeReason);
if (!wakeReason) return false; if (!wakeReason) return false;
if (wakeReason === "issue_comment_mentioned") return false; if (wakeReason === "issue_comment_mentioned") return false;
if (wakeReason === "source_scoped_recovery_action") return false;
if (wakeReason.startsWith("execution_")) return false; if (wakeReason.startsWith("execution_")) return false;
return true; return true;
@@ -5883,8 +5884,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
// Fix A (lazy locking): stamp executionRunId now that the run is actually running, // Fix A (lazy locking): stamp executionRunId now that the run is actually running,
// not at queue time. Guard is idempotent — safe if called more than once. // not at queue time. Guard is idempotent — safe if called more than once.
const claimedIssueId = readNonEmptyString(parseObject(claimed.contextSnapshot).issueId); const claimedContext = parseObject(claimed.contextSnapshot);
if (claimedIssueId) { const claimedIssueId = readNonEmptyString(claimedContext.issueId);
const claimedWakeReason = readNonEmptyString(claimedContext.wakeReason);
if (claimedIssueId && claimedWakeReason !== "source_scoped_recovery_action") {
const claimedAgent = await getAgent(claimed.agentId); const claimedAgent = await getAgent(claimed.agentId);
await db await db
.update(issues) .update(issues)
@@ -7873,7 +7876,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
}); });
const livenessRun = finalizedRun; const livenessRun = finalizedRun;
await refreshContinuationSummaryForRun(livenessRun, agent); await refreshContinuationSummaryForRun(livenessRun, agent);
if (issueId && outcome === "succeeded") { const skipRunIssueComment = parseObject(livenessRun.contextSnapshot).skipIssueComment === true;
if (issueId && outcome === "succeeded" && !skipRunIssueComment) {
try { try {
const existingRunComment = await findRunIssueComment(livenessRun.id, livenessRun.companyId, issueId); const existingRunComment = await findRunIssueComment(livenessRun.id, livenessRun.companyId, issueId);
if (!existingRunComment) { if (!existingRunComment) {
+1
View File
@@ -24,6 +24,7 @@ export { issueThreadInteractionService } from "./issue-thread-interactions.js";
export { issueTreeControlService } from "./issue-tree-control.js"; export { issueTreeControlService } from "./issue-tree-control.js";
export { issueApprovalService } from "./issue-approvals.js"; export { issueApprovalService } from "./issue-approvals.js";
export { issueReferenceService } from "./issue-references.js"; export { issueReferenceService } from "./issue-references.js";
export { issueRecoveryActionService } from "./issue-recovery-actions.js";
export { goalService } from "./goals.js"; export { goalService } from "./goals.js";
export { activityService, type ActivityFilters } from "./activity.js"; export { activityService, type ActivityFilters } from "./activity.js";
export { approvalService } from "./approvals.js"; export { approvalService } from "./approvals.js";
@@ -0,0 +1,295 @@
import { and, desc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { issueRecoveryActions } from "@paperclipai/db";
import type {
IssueRecoveryAction,
IssueRecoveryActionKind,
IssueRecoveryActionOwnerType,
IssueRecoveryActionOutcome,
IssueRecoveryActionStatus,
} from "@paperclipai/shared";
const ACTIVE_RECOVERY_ACTION_STATUSES = ["active", "escalated"] as const satisfies readonly IssueRecoveryActionStatus[];
const MAX_UPSERT_RETRIES = 3;
type IssueRecoveryActionRow = typeof issueRecoveryActions.$inferSelect;
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
type DbOrTransaction = Db | DbTransaction;
export type UpsertIssueRecoveryActionInput = {
companyId: string;
sourceIssueId: string;
recoveryIssueId?: string | null;
kind: IssueRecoveryActionKind;
ownerType?: IssueRecoveryActionOwnerType;
ownerAgentId?: string | null;
ownerUserId?: string | null;
previousOwnerAgentId?: string | null;
returnOwnerAgentId?: string | null;
cause: string;
fingerprint: string;
evidence?: Record<string, unknown>;
nextAction: string;
wakePolicy?: Record<string, unknown> | null;
monitorPolicy?: Record<string, unknown> | null;
maxAttempts?: number | null;
timeoutAt?: Date | null;
lastAttemptAt?: Date | null;
};
export type ResolveIssueRecoveryActionInput = {
companyId: string;
sourceIssueId: string;
actionId?: string | null;
status: Extract<IssueRecoveryActionStatus, "resolved" | "cancelled">;
outcome: IssueRecoveryActionOutcome;
resolutionNote?: string | null;
};
function toReadModel(row: IssueRecoveryActionRow): IssueRecoveryAction {
return {
id: row.id,
companyId: row.companyId,
sourceIssueId: row.sourceIssueId,
recoveryIssueId: row.recoveryIssueId,
kind: row.kind as IssueRecoveryAction["kind"],
status: row.status as IssueRecoveryAction["status"],
ownerType: row.ownerType as IssueRecoveryAction["ownerType"],
ownerAgentId: row.ownerAgentId,
ownerUserId: row.ownerUserId,
previousOwnerAgentId: row.previousOwnerAgentId,
returnOwnerAgentId: row.returnOwnerAgentId,
cause: row.cause,
fingerprint: row.fingerprint,
evidence: row.evidence,
nextAction: row.nextAction,
wakePolicy: row.wakePolicy,
monitorPolicy: row.monitorPolicy,
attemptCount: row.attemptCount,
maxAttempts: row.maxAttempts,
timeoutAt: row.timeoutAt,
lastAttemptAt: row.lastAttemptAt,
outcome: row.outcome as IssueRecoveryAction["outcome"],
resolutionNote: row.resolutionNote,
resolvedAt: row.resolvedAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function isUniqueRecoveryActionConflict(error: unknown) {
const maybe = error as { code?: string; constraint?: string; message?: string } | null;
return Boolean(
maybe &&
maybe.code === "23505" &&
(
maybe.constraint === "issue_recovery_actions_active_source_uq" ||
maybe.constraint === "issue_recovery_actions_active_fingerprint_uq" ||
typeof maybe.message === "string" && (
maybe.message.includes("issue_recovery_actions_active_source_uq") ||
maybe.message.includes("issue_recovery_actions_active_fingerprint_uq")
)
),
);
}
export function issueRecoveryActionService(db: Db) {
const upsertQueues = new Map<string, Promise<void>>();
async function runExclusiveUpsert<T>(
input: UpsertIssueRecoveryActionInput,
task: () => Promise<T>,
): Promise<T> {
const key = `${input.companyId}:${input.sourceIssueId}`;
const previous = upsertQueues.get(key) ?? Promise.resolve();
let release: () => void = () => {};
const current = new Promise<void>((resolve) => {
release = resolve;
});
const next = previous.catch(() => undefined).then(() => current);
upsertQueues.set(key, next);
await previous.catch(() => undefined);
try {
return await task();
} finally {
release();
if (upsertQueues.get(key) === next) {
upsertQueues.delete(key);
}
}
}
async function getActiveForIssue(companyId: string, sourceIssueId: string): Promise<IssueRecoveryAction | null> {
const row = await db
.select()
.from(issueRecoveryActions)
.where(
and(
eq(issueRecoveryActions.companyId, companyId),
eq(issueRecoveryActions.sourceIssueId, sourceIssueId),
inArray(issueRecoveryActions.status, [...ACTIVE_RECOVERY_ACTION_STATUSES]),
),
)
.orderBy(desc(issueRecoveryActions.updatedAt))
.limit(1)
.then((rows) => rows[0] ?? null);
return row ? toReadModel(row) : null;
}
async function listActiveForIssues(companyId: string, sourceIssueIds: string[]) {
if (sourceIssueIds.length === 0) return new Map<string, IssueRecoveryAction>();
const rows = await db
.select()
.from(issueRecoveryActions)
.where(
and(
eq(issueRecoveryActions.companyId, companyId),
inArray(issueRecoveryActions.sourceIssueId, [...new Set(sourceIssueIds)]),
inArray(issueRecoveryActions.status, [...ACTIVE_RECOVERY_ACTION_STATUSES]),
),
)
.orderBy(desc(issueRecoveryActions.updatedAt));
const result = new Map<string, IssueRecoveryAction>();
for (const row of rows) {
if (!result.has(row.sourceIssueId)) result.set(row.sourceIssueId, toReadModel(row));
}
return result;
}
async function retryUpsertSourceScoped(
input: UpsertIssueRecoveryActionInput,
retryCount: number,
error?: unknown,
): Promise<IssueRecoveryAction> {
if (retryCount >= MAX_UPSERT_RETRIES) {
if (error) throw error;
throw new Error(
`Failed to upsert active recovery action for issue ${input.sourceIssueId} after ${MAX_UPSERT_RETRIES} retries`,
);
}
return upsertSourceScopedUnlocked(input, retryCount + 1);
}
async function upsertSourceScopedUnlocked(
input: UpsertIssueRecoveryActionInput,
retryCount = 0,
): Promise<IssueRecoveryAction> {
const existing = await getActiveForIssue(input.companyId, input.sourceIssueId);
const now = new Date();
const ownerType = input.ownerType ?? (input.ownerAgentId ? "agent" : "board");
if (existing) {
const [updated] = await db
.update(issueRecoveryActions)
.set({
recoveryIssueId: input.recoveryIssueId ?? null,
kind: input.kind,
status: "active",
ownerType,
ownerAgentId: input.ownerAgentId ?? null,
ownerUserId: input.ownerUserId ?? null,
previousOwnerAgentId: input.previousOwnerAgentId ?? existing.previousOwnerAgentId,
returnOwnerAgentId: input.returnOwnerAgentId ?? existing.returnOwnerAgentId,
cause: input.cause,
fingerprint: input.fingerprint,
evidence: input.evidence ?? existing.evidence,
nextAction: input.nextAction,
wakePolicy: input.wakePolicy ?? null,
monitorPolicy: input.monitorPolicy ?? null,
attemptCount: existing.attemptCount + 1,
maxAttempts: input.maxAttempts ?? null,
timeoutAt: input.timeoutAt ?? null,
lastAttemptAt: input.lastAttemptAt ?? now,
outcome: null,
resolutionNote: null,
resolvedAt: null,
updatedAt: now,
})
.where(
and(
eq(issueRecoveryActions.id, existing.id),
inArray(issueRecoveryActions.status, [...ACTIVE_RECOVERY_ACTION_STATUSES]),
),
)
.returning();
if (!updated) {
return retryUpsertSourceScoped(input, retryCount);
}
return toReadModel(updated!);
}
try {
const [created] = await db
.insert(issueRecoveryActions)
.values({
companyId: input.companyId,
sourceIssueId: input.sourceIssueId,
recoveryIssueId: input.recoveryIssueId ?? null,
kind: input.kind,
status: "active",
ownerType,
ownerAgentId: input.ownerAgentId ?? null,
ownerUserId: input.ownerUserId ?? null,
previousOwnerAgentId: input.previousOwnerAgentId ?? null,
returnOwnerAgentId: input.returnOwnerAgentId ?? null,
cause: input.cause,
fingerprint: input.fingerprint,
evidence: input.evidence ?? {},
nextAction: input.nextAction,
wakePolicy: input.wakePolicy ?? null,
monitorPolicy: input.monitorPolicy ?? null,
attemptCount: 1,
maxAttempts: input.maxAttempts ?? null,
timeoutAt: input.timeoutAt ?? null,
lastAttemptAt: input.lastAttemptAt ?? now,
})
.returning();
return toReadModel(created!);
} catch (error) {
if (!isUniqueRecoveryActionConflict(error)) throw error;
return retryUpsertSourceScoped(input, retryCount, error);
}
}
async function upsertSourceScoped(
input: UpsertIssueRecoveryActionInput,
): Promise<IssueRecoveryAction> {
return runExclusiveUpsert(input, () => upsertSourceScopedUnlocked(input));
}
async function resolveActiveForIssue(
input: ResolveIssueRecoveryActionInput,
dbOrTx: DbOrTransaction = db,
): Promise<IssueRecoveryAction | null> {
const now = new Date();
const predicates = [
eq(issueRecoveryActions.companyId, input.companyId),
eq(issueRecoveryActions.sourceIssueId, input.sourceIssueId),
inArray(issueRecoveryActions.status, [...ACTIVE_RECOVERY_ACTION_STATUSES]),
];
if (input.actionId) {
predicates.push(eq(issueRecoveryActions.id, input.actionId));
}
const [updated] = await dbOrTx
.update(issueRecoveryActions)
.set({
status: input.status,
outcome: input.outcome,
resolutionNote: input.resolutionNote ?? null,
resolvedAt: now,
updatedAt: now,
})
.where(and(...predicates))
.returning();
return updated ? toReadModel(updated) : null;
}
return {
getActiveForIssue,
listActiveForIssues,
resolveActiveForIssue,
upsertSourceScoped,
};
}
+13
View File
@@ -17,6 +17,7 @@ import {
issueAttachments, issueAttachments,
issueInboxArchives, issueInboxArchives,
issueLabels, issueLabels,
issueRecoveryActions,
issueRelations, issueRelations,
issueComments, issueComments,
issueDocuments, issueDocuments,
@@ -1355,6 +1356,18 @@ async function listIssueBlockerAttentionMap(
explicitWaitingIssueIds.add(parsed.issueId); explicitWaitingIssueIds.add(parsed.issueId);
explicitWaitingIssueIds.add(parsed.leafIssueId); explicitWaitingIssueIds.add(parsed.leafIssueId);
} }
const recoveryActionRows: Array<{ sourceIssueId: string }> = await dbOrTx
.select({ sourceIssueId: issueRecoveryActions.sourceIssueId })
.from(issueRecoveryActions)
.where(
and(
eq(issueRecoveryActions.companyId, companyId),
inArray(issueRecoveryActions.status, ["active", "escalated"]),
inArray(issueRecoveryActions.sourceIssueId, explicitWaitCandidateIds),
),
);
for (const row of recoveryActionRows) explicitWaitingIssueIds.add(row.sourceIssueId);
} }
const agentRows: IssueBlockerAttentionAgentRow[] = agentIds.size > 0 const agentRows: IssueBlockerAttentionAgentRow[] = agentIds.size > 0
+274 -62
View File
@@ -12,10 +12,12 @@ import {
agentWakeupRequests, agentWakeupRequests,
approvals, approvals,
companies, companies,
issueComments,
heartbeatRunEvents, heartbeatRunEvents,
heartbeatRunWatchdogDecisions, heartbeatRunWatchdogDecisions,
heartbeatRuns, heartbeatRuns,
issueApprovals, issueApprovals,
issueRecoveryActions,
issueRelations, issueRelations,
issueThreadInteractions, issueThreadInteractions,
issues, issues,
@@ -29,6 +31,7 @@ import { redactSensitiveText } from "../../redaction.js";
import { logActivity } from "../activity-log.js"; import { logActivity } from "../activity-log.js";
import { budgetService } from "../budgets.js"; import { budgetService } from "../budgets.js";
import { instanceSettingsService } from "../instance-settings.js"; import { instanceSettingsService } from "../instance-settings.js";
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
import { issueTreeControlService } from "../issue-tree-control.js"; import { issueTreeControlService } from "../issue-tree-control.js";
import { issueService } from "../issues.js"; import { issueService } from "../issues.js";
import { getRunLogStore } from "../run-log-store.js"; import { getRunLogStore } from "../run-log-store.js";
@@ -37,6 +40,7 @@ import {
FINISH_SUCCESSFUL_RUN_HANDOFF_REASON, FINISH_SUCCESSFUL_RUN_HANDOFF_REASON,
SUCCESSFUL_RUN_MISSING_STATE_REASON, SUCCESSFUL_RUN_MISSING_STATE_REASON,
buildSuccessfulRunHandoffExhaustedNotice, buildSuccessfulRunHandoffExhaustedNotice,
noticeMetadataReferencesRecoveryAction,
type SuccessfulRunHandoffNotice, type SuccessfulRunHandoffNotice,
} from "./successful-run-handoff.js"; } from "./successful-run-handoff.js";
import { import {
@@ -386,6 +390,7 @@ function buildLivenessOriginalIssueComment(finding: IssueLivenessFinding, escala
export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) { export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) {
const issuesSvc = issueService(db); const issuesSvc = issueService(db);
const recoveryActionsSvc = issueRecoveryActionService(db);
const treeControlSvc = issueTreeControlService(db); const treeControlSvc = issueTreeControlService(db);
const budgets = budgetService(db); const budgets = budgetService(db);
const instanceSettings = instanceSettingsService(db); const instanceSettings = instanceSettingsService(db);
@@ -1566,6 +1571,136 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
return recovery; return recovery;
} }
function strandedRecoveryActionKind(cause: StrandedRecoveryCause) {
return cause === SUCCESSFUL_RUN_MISSING_STATE_REASON
? "missing_disposition" as const
: "stranded_assigned_issue" as const;
}
function strandedRecoveryActionFingerprint(input: {
issue: typeof issues.$inferSelect;
recoveryCause: StrandedRecoveryCause;
}) {
return [
"source_scoped_recovery",
input.issue.companyId,
input.issue.id,
input.recoveryCause,
].join(":");
}
function buildStrandedRecoveryActionEvidence(input: {
issue: typeof issues.$inferSelect;
latestRun: LatestIssueRun;
previousStatus: "todo" | "in_progress";
recoveryCause: StrandedRecoveryCause;
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
}) {
const context = parseObject(input.latestRun?.contextSnapshot);
return {
sourceIssueId: input.issue.id,
sourceIdentifier: input.issue.identifier,
previousStatus: input.previousStatus,
latestIssueStatus: input.issue.status,
latestRunId: input.latestRun?.id ?? null,
latestRunStatus: input.latestRun?.status ?? null,
latestRunErrorCode: input.latestRun?.errorCode ?? null,
retryReason: readNonEmptyString(context.retryReason) ?? null,
recoveryCause: input.recoveryCause,
sourceRunId: input.successfulRunHandoffEvidence?.sourceRunId ?? null,
correctiveRunId: input.successfulRunHandoffEvidence?.correctiveRunId ?? null,
missingDisposition: input.successfulRunHandoffEvidence?.missingDisposition ?? null,
handoffAttempt: input.successfulRunHandoffEvidence?.handoffAttempt ?? null,
maxHandoffAttempts: input.successfulRunHandoffEvidence?.maxHandoffAttempts ?? null,
};
}
async function ensureSourceScopedStrandedRecoveryAction(input: {
issue: typeof issues.$inferSelect;
latestRun: LatestIssueRun;
previousStatus: "todo" | "in_progress";
recoveryCause?: StrandedRecoveryCause;
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
}) {
const recoveryCause = input.recoveryCause ?? "stranded_assigned_issue";
const ownerAgentId = await resolveStrandedIssueRecoveryOwnerAgentId(input.issue);
const now = new Date();
const action = await recoveryActionsSvc.upsertSourceScoped({
companyId: input.issue.companyId,
sourceIssueId: input.issue.id,
kind: strandedRecoveryActionKind(recoveryCause),
ownerType: ownerAgentId ? "agent" : "board",
ownerAgentId,
previousOwnerAgentId: input.issue.assigneeAgentId,
returnOwnerAgentId: input.issue.assigneeAgentId,
cause: recoveryCause,
fingerprint: strandedRecoveryActionFingerprint({
issue: input.issue,
recoveryCause,
}),
evidence: buildStrandedRecoveryActionEvidence({
issue: input.issue,
latestRun: input.latestRun,
previousStatus: input.previousStatus,
recoveryCause,
successfulRunHandoffEvidence: input.successfulRunHandoffEvidence,
}),
nextAction: recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON
? "Choose and record a valid issue disposition without copying transcript content."
: "Restore a live execution path, fix the runtime/adapter failure, or record an intentional manual resolution.",
wakePolicy: ownerAgentId
? {
type: "wake_owner",
reason: "source_scoped_recovery_action",
ownerAgentId,
}
: {
type: "board_escalation",
reason: "no_invokable_recovery_owner",
},
monitorPolicy: null,
maxAttempts: null,
lastAttemptAt: now,
});
return action;
}
async function enqueueSourceScopedStrandedRecoveryWake(input: {
action: Awaited<ReturnType<typeof recoveryActionsSvc.upsertSourceScoped>>;
issue: typeof issues.$inferSelect;
latestRun: LatestIssueRun;
recoveryCause: StrandedRecoveryCause;
}) {
if (!input.action.ownerAgentId) return;
await deps.enqueueWakeup(input.action.ownerAgentId, {
source: "assignment",
triggerDetail: "system",
reason: "source_scoped_recovery_action",
idempotencyKey: `source_scoped_recovery_action:${input.action.id}:${input.action.attemptCount}`,
payload: withRecoveryModelProfileHint({
issueId: input.issue.id,
sourceIssueId: input.issue.id,
recoveryActionId: input.action.id,
strandedRunId: input.latestRun?.id ?? null,
recoveryCause: input.recoveryCause,
}),
requestedByActorType: "system",
requestedByActorId: null,
contextSnapshot: withRecoveryModelProfileHint({
issueId: input.issue.id,
taskId: input.issue.id,
wakeReason: "source_scoped_recovery_action",
skipIssueComment: true,
source: "issue_recovery_action",
recoveryActionId: input.action.id,
sourceIssueId: input.issue.id,
strandedRunId: input.latestRun?.id ?? null,
recoveryCause: input.recoveryCause,
}),
});
}
function buildRecoveryIssueInPlaceEscalationComment(input: { function buildRecoveryIssueInPlaceEscalationComment(input: {
issue: typeof issues.$inferSelect; issue: typeof issues.$inferSelect;
previousStatus: "todo" | "in_progress"; previousStatus: "todo" | "in_progress";
@@ -1682,29 +1817,32 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
recoveryCause?: StrandedRecoveryCause; recoveryCause?: StrandedRecoveryCause;
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null; successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
}) { }) {
const nestedRecoverySuppressed = isStrandedIssueRecoveryIssue(input.issue); if (isStrandedIssueRecoveryIssue(input.issue)) {
let recoveryIssue: typeof issues.$inferSelect | null = null; return escalateStrandedRecoveryIssueInPlace({
if (!nestedRecoverySuppressed) {
recoveryIssue = await ensureStrandedIssueRecoveryIssue({
issue: input.issue, issue: input.issue,
previousStatus: input.previousStatus, previousStatus: input.previousStatus,
latestRun: input.latestRun, latestRun: input.latestRun,
recoveryCause: input.recoveryCause,
successfulRunHandoffEvidence: input.successfulRunHandoffEvidence,
}); });
} }
const recoveryCause = input.recoveryCause ?? "stranded_assigned_issue";
const recoveryAction = await ensureSourceScopedStrandedRecoveryAction({
issue: input.issue,
previousStatus: input.previousStatus,
latestRun: input.latestRun,
recoveryCause,
successfulRunHandoffEvidence: input.successfulRunHandoffEvidence,
});
const blockerIds = await existingUnresolvedBlockerIssueIds(input.issue.companyId, input.issue.id); const blockerIds = await existingUnresolvedBlockerIssueIds(input.issue.companyId, input.issue.id);
const nextBlockerIds = recoveryIssue
? [...new Set([...blockerIds, recoveryIssue.id])]
: blockerIds;
const updated = await issuesSvc.update(input.issue.id, { const updated = await issuesSvc.update(input.issue.id, {
status: "blocked", status: "blocked",
blockedByIssueIds: nextBlockerIds, blockedByIssueIds: blockerIds,
assigneeAgentId: recoveryAction.ownerAgentId ?? input.issue.assigneeAgentId,
}); });
if (!updated) return null; if (!updated) return null;
const prefix = await getCompanyIssuePrefix(input.issue.companyId); const prefix = await getCompanyIssuePrefix(input.issue.companyId);
const recoveryOwner = recoveryIssue?.assigneeAgentId ? await getAgent(recoveryIssue.assigneeAgentId) : null; const recoveryOwner = recoveryAction.ownerAgentId ? await getAgent(recoveryAction.ownerAgentId) : null;
const sourceAssignee = input.issue.assigneeAgentId ? await getAgent(input.issue.assigneeAgentId) : null; const sourceAssignee = input.issue.assigneeAgentId ? await getAgent(input.issue.assigneeAgentId) : null;
let notice: SuccessfulRunHandoffNotice | null = null; let notice: SuccessfulRunHandoffNotice | null = null;
if (input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON && input.successfulRunHandoffEvidence) { if (input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON && input.successfulRunHandoffEvidence) {
@@ -1715,39 +1853,60 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
: null, : null,
correctiveRun: input.latestRun ? { id: input.latestRun.id, status: input.latestRun.status } : null, correctiveRun: input.latestRun ? { id: input.latestRun.id, status: input.latestRun.status } : null,
sourceAssignee, sourceAssignee,
recoveryIssue, recoveryIssue: null,
recoveryActionId: recoveryAction.id,
recoveryOwner, recoveryOwner,
latestIssueStatus: input.issue.status, latestIssueStatus: input.issue.status,
latestHandoffRunStatus: input.latestRun?.status ?? "unknown", latestHandoffRunStatus: input.latestRun?.status ?? "unknown",
missingDisposition: input.successfulRunHandoffEvidence.missingDisposition, missingDisposition: input.successfulRunHandoffEvidence.missingDisposition,
}); });
} }
let recoveryLine: string; const recoveryLine = recoveryAction.ownerAgentId
if (nestedRecoverySuppressed) { ? [
recoveryLine = await buildNestedStrandedRecoveryLine(input.issue, prefix);
} else if (recoveryIssue) {
recoveryLine = [
"", "",
`- Recovery issue: ${issueUiLink({ identifier: recoveryIssue.identifier, id: recoveryIssue.id }, prefix)}`, `- Recovery action: \`${recoveryAction.id}\``,
`- Recovery owner: ${agentUiLink(recoveryOwner, prefix)}`, `- Recovery owner: ${agentUiLink(recoveryOwner, prefix)}`,
"- Next action: the recovery owner should either restore a live execution path or record the manual resolution, then mark the recovery issue done.", "- Next action: the recovery owner should either restore a live execution path or record the manual resolution on the source issue.",
].join("\n"); ].join("\n")
} else { : [
recoveryLine = [
"", "",
"- Recovery issue: none created because Paperclip could not find an invokable manager, creator, or executive owner with budget available.", `- Recovery action: \`${recoveryAction.id}\``,
"- Recovery owner: board escalation, because Paperclip could not find an invokable manager, creator, or executive owner with budget available.",
"- Next action: a board operator should assign an invokable recovery owner, fix the agent/runtime state, or record an intentional manual resolution.", "- Next action: a board operator should assign an invokable recovery owner, fix the agent/runtime state, or record an intentional manual resolution.",
].join("\n"); ].join("\n");
}
if (notice) { if (recoveryAction.attemptCount === 1) {
await issuesSvc.addComment(input.issue.id, notice.body, {}, { const escalationCommentMarker = `Recovery action: \`${recoveryAction.id}\``;
authorType: "system",
presentation: notice.presentation, const hasEscalationComment = await db
metadata: notice.metadata, .select({ id: issueComments.id, body: issueComments.body, metadata: issueComments.metadata })
}); .from(issueComments)
} else { .where(
await issuesSvc.addComment(input.issue.id, `${input.comment ?? ""}${recoveryLine}`, {}); and(
eq(issueComments.issueId, input.issue.id),
eq(issueComments.authorType, "system"),
),
)
.orderBy(desc(issueComments.createdAt))
.limit(50)
.then((rows) => rows.some((row) =>
(row.body ?? "").includes(escalationCommentMarker) ||
noticeMetadataReferencesRecoveryAction(row.metadata, recoveryAction.id),
));
if (!hasEscalationComment) {
if (notice) {
await issuesSvc.addComment(input.issue.id, notice.body, {}, {
authorType: "system",
presentation: notice.presentation,
metadata: notice.metadata,
});
} else {
await issuesSvc.addComment(input.issue.id, `${input.comment ?? ""}${recoveryLine}`, {}, {
authorType: "system",
});
}
}
} }
await logActivity(db, { await logActivity(db, {
@@ -1772,12 +1931,44 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
latestRunId: input.latestRun?.id ?? null, latestRunId: input.latestRun?.id ?? null,
latestRunStatus: input.latestRun?.status ?? null, latestRunStatus: input.latestRun?.status ?? null,
latestRunErrorCode: input.latestRun?.errorCode ?? null, latestRunErrorCode: input.latestRun?.errorCode ?? null,
recoveryIssueId: recoveryIssue?.id ?? null, recoveryActionId: recoveryAction.id,
nestedRecoverySuppressed, recoveryOwnerAgentId: recoveryAction.ownerAgentId,
blockerIssueIds: nextBlockerIds, previousOwnerAgentId: recoveryAction.previousOwnerAgentId,
returnOwnerAgentId: recoveryAction.returnOwnerAgentId,
blockerIssueIds: blockerIds,
}, },
}); });
await enqueueSourceScopedStrandedRecoveryWake({
action: recoveryAction,
issue: input.issue,
latestRun: input.latestRun,
recoveryCause,
});
if (recoveryAction.ownerAgentId && recoveryAction.ownerAgentId === input.issue.assigneeAgentId) {
const [currentIssue] = await db
.select({
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
})
.from(issues)
.where(eq(issues.id, input.issue.id))
.limit(1);
if (
currentIssue &&
(currentIssue.status !== "blocked" ||
currentIssue.assigneeAgentId !== recoveryAction.ownerAgentId)
) {
const reblocked = await issuesSvc.update(input.issue.id, {
status: "blocked",
blockedByIssueIds: blockerIds,
assigneeAgentId: recoveryAction.ownerAgentId,
});
if (reblocked) return reblocked;
}
}
return updated; return updated;
} }
@@ -2038,6 +2229,33 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
} }
async function collectIssueGraphLivenessFindings() { async function collectIssueGraphLivenessFindings() {
const issueRowsPromise = Promise.resolve(db
.select({
id: issues.id,
companyId: issues.companyId,
identifier: issues.identifier,
title: issues.title,
status: issues.status,
projectId: issues.projectId,
goalId: issues.goalId,
parentId: issues.parentId,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
createdByAgentId: issues.createdByAgentId,
createdByUserId: issues.createdByUserId,
executionPolicy: issues.executionPolicy,
executionState: issues.executionState,
monitorNextCheckAt: issues.monitorNextCheckAt,
monitorAttemptCount: issues.monitorAttemptCount,
})
.from(issues)
.where(
and(
isNull(issues.hiddenAt),
notInArray(issues.originKind, [RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation]),
),
));
const [ const [
issueRows, issueRows,
relationRows, relationRows,
@@ -2048,33 +2266,9 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
interactionRows, interactionRows,
approvalRows, approvalRows,
recoveryIssueRows, recoveryIssueRows,
recoveryActionRows,
] = await Promise.all([ ] = await Promise.all([
db issueRowsPromise,
.select({
id: issues.id,
companyId: issues.companyId,
identifier: issues.identifier,
title: issues.title,
status: issues.status,
projectId: issues.projectId,
goalId: issues.goalId,
parentId: issues.parentId,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
createdByAgentId: issues.createdByAgentId,
createdByUserId: issues.createdByUserId,
executionPolicy: issues.executionPolicy,
executionState: issues.executionState,
monitorNextCheckAt: issues.monitorNextCheckAt,
monitorAttemptCount: issues.monitorAttemptCount,
})
.from(issues)
.where(
and(
isNull(issues.hiddenAt),
notInArray(issues.originKind, [RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation]),
),
),
db db
.select({ .select({
companyId: issueRelations.companyId, companyId: issueRelations.companyId,
@@ -2164,6 +2358,24 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
notInArray(issues.status, ["done", "cancelled"]), notInArray(issues.status, ["done", "cancelled"]),
), ),
), ),
issueRowsPromise.then((rows) => {
const issueIdsUnderAnalysis = rows.map((row) => row.id);
return issueIdsUnderAnalysis.length === 0
? []
: db
.select({
companyId: issueRecoveryActions.companyId,
issueId: issueRecoveryActions.sourceIssueId,
status: issueRecoveryActions.status,
})
.from(issueRecoveryActions)
.where(
and(
inArray(issueRecoveryActions.status, ["active", "escalated"]),
inArray(issueRecoveryActions.sourceIssueId, issueIdsUnderAnalysis),
),
);
}),
]); ]);
const openRecoveryIssues = recoveryIssueRows.flatMap((row) => { const openRecoveryIssues = recoveryIssueRows.flatMap((row) => {
@@ -2217,7 +2429,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
})), })),
pendingInteractions: interactionRows, pendingInteractions: interactionRows,
pendingApprovals: approvalRows, pendingApprovals: approvalRows,
openRecoveryIssues, openRecoveryIssues: openRecoveryIssues.concat(recoveryActionRows),
now: new Date(), now: new Date(),
}); });
} }
@@ -10,6 +10,7 @@ import {
decideSuccessfulRunHandoff, decideSuccessfulRunHandoff,
isIdempotentFinishSuccessfulRunHandoffWakeStatus, isIdempotentFinishSuccessfulRunHandoffWakeStatus,
isSuccessfulRunHandoffRequiredNoticeBody, isSuccessfulRunHandoffRequiredNoticeBody,
noticeMetadataReferencesRecoveryAction,
} from "./successful-run-handoff.js"; } from "./successful-run-handoff.js";
const run = { const run = {
@@ -256,6 +257,7 @@ describe("successful run handoff decision", () => {
title: "Recover missing next step PAP-1", title: "Recover missing next step PAP-1",
status: "todo", status: "todo",
} as any, } as any,
recoveryActionId: "77777777-7777-4777-8777-777777777777",
recoveryOwner: { id: "66666666-6666-4666-8666-666666666666", name: "CTO" } as any, recoveryOwner: { id: "66666666-6666-4666-8666-666666666666", name: "CTO" } as any,
latestIssueStatus: "in_progress", latestIssueStatus: "in_progress",
latestHandoffRunStatus: "failed", latestHandoffRunStatus: "failed",
@@ -273,7 +275,7 @@ describe("successful run handoff decision", () => {
expect.objectContaining({ expect.objectContaining({
title: "Recovery owner", title: "Recovery owner",
rows: expect.arrayContaining([ rows: expect.arrayContaining([
expect.objectContaining({ type: "issue_link", identifier: "PAP-2" }), expect.objectContaining({ type: "key_value", label: "Recovery action", value: "77777777-7777-4777-8777-777777777777" }),
expect.objectContaining({ type: "agent_link", label: "Recovery owner", name: "CTO" }), expect.objectContaining({ type: "agent_link", label: "Recovery owner", name: "CTO" }),
]), ]),
}), }),
@@ -286,6 +288,8 @@ describe("successful run handoff decision", () => {
]), ]),
}), }),
])); ]));
expect(noticeMetadataReferencesRecoveryAction(notice.metadata, "77777777-7777-4777-8777-777777777777")).toBe(true);
expect(noticeMetadataReferencesRecoveryAction(notice.metadata, "88888888-8888-4888-8888-888888888888")).toBe(false);
}); });
it("recognizes new notices and legacy markdown headings for fallback deduplication", () => { it("recognizes new notices and legacy markdown headings for fallback deduplication", () => {
@@ -61,6 +61,19 @@ export type SuccessfulRunHandoffNotice = {
metadata: IssueCommentMetadata; metadata: IssueCommentMetadata;
}; };
export function noticeMetadataReferencesRecoveryAction(
metadata: IssueCommentMetadata | null | undefined,
recoveryActionId: string,
) {
return (metadata?.sections ?? []).some((section) =>
section.rows.some((row) =>
row.type === "key_value" &&
row.label === "Recovery action" &&
row.value === recoveryActionId,
),
);
}
export type SuccessfulRunHandoffDecision = export type SuccessfulRunHandoffDecision =
| { | {
kind: "enqueue"; kind: "enqueue";
@@ -181,6 +194,7 @@ export function buildSuccessfulRunHandoffExhaustedNotice(input: {
correctiveRun: NullableNoticeRun; correctiveRun: NullableNoticeRun;
sourceAssignee: NullableNoticeAgent; sourceAssignee: NullableNoticeAgent;
recoveryIssue: NullableNoticeIssue; recoveryIssue: NullableNoticeIssue;
recoveryActionId?: string | null;
recoveryOwner: NullableNoticeAgent; recoveryOwner: NullableNoticeAgent;
latestIssueStatus: string; latestIssueStatus: string;
latestHandoffRunStatus: string; latestHandoffRunStatus: string;
@@ -200,7 +214,9 @@ export function buildSuccessfulRunHandoffExhaustedNotice(input: {
title: "Recovery owner", title: "Recovery owner",
rows: [ rows: [
issueLinkRow("Source issue", input.issue), issueLinkRow("Source issue", input.issue),
issueLinkRow("Recovery issue", input.recoveryIssue), input.recoveryActionId
? keyValueRow("Recovery action", input.recoveryActionId)
: issueLinkRow("Recovery issue", input.recoveryIssue),
agentLinkRow("Recovery owner", input.recoveryOwner), agentLinkRow("Recovery owner", input.recoveryOwner),
agentLinkRow("Source assignee", input.sourceAssignee), agentLinkRow("Source assignee", input.sourceAssignee),
keyValueRow("Suggested action", "choose and record a valid issue disposition without copying transcript content"), keyValueRow("Suggested action", "choose and record a valid issue disposition without copying transcript content"),
+20
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mockApi = vi.hoisted(() => ({ const mockApi = vi.hoisted(() => ({
get: vi.fn(), get: vi.fn(),
post: vi.fn(),
})); }));
vi.mock("./client", () => ({ vi.mock("./client", () => ({
@@ -13,7 +14,9 @@ import { issuesApi } from "./issues";
describe("issuesApi.list", () => { describe("issuesApi.list", () => {
beforeEach(() => { beforeEach(() => {
mockApi.get.mockReset(); mockApi.get.mockReset();
mockApi.post.mockReset();
mockApi.get.mockResolvedValue([]); mockApi.get.mockResolvedValue([]);
mockApi.post.mockResolvedValue({});
}); });
it("passes parentId through to the company issues endpoint", async () => { it("passes parentId through to the company issues endpoint", async () => {
@@ -47,4 +50,21 @@ describe("issuesApi.list", () => {
"/companies/company-1/issues?limit=500&offset=1500", "/companies/company-1/issues?limit=500&offset=1500",
); );
}); });
it("posts recovery action resolution to the source issue endpoint", async () => {
await issuesApi.resolveRecoveryAction("issue-1", {
actionId: "00000000-0000-0000-0000-0000000000aa",
outcome: "restored",
sourceIssueStatus: "done",
});
expect(mockApi.post).toHaveBeenCalledWith(
"/issues/issue-1/recovery-actions/resolve",
{
actionId: "00000000-0000-0000-0000-0000000000aa",
outcome: "restored",
sourceIssueStatus: "done",
},
);
});
}); });
+15
View File
@@ -12,6 +12,7 @@ import type {
IssueComment, IssueComment,
IssueDocument, IssueDocument,
IssueLabel, IssueLabel,
IssueRecoveryAction,
IssueRetryNowResponse, IssueRetryNowResponse,
IssueThreadInteraction, IssueThreadInteraction,
IssueTreeControlPreview, IssueTreeControlPreview,
@@ -27,6 +28,11 @@ export type IssueUpdateResponse = Issue & {
comment?: IssueComment | null; comment?: IssueComment | null;
}; };
export type ResolveRecoveryActionResponse = {
issue: Issue;
recoveryAction: IssueRecoveryAction;
};
export const issuesApi = { export const issuesApi = {
list: ( list: (
companyId: string, companyId: string,
@@ -94,6 +100,15 @@ export const issuesApi = {
api.post<Issue>(`/companies/${companyId}/issues`, data), api.post<Issue>(`/companies/${companyId}/issues`, data),
update: (id: string, data: Record<string, unknown>) => update: (id: string, data: Record<string, unknown>) =>
api.patch<IssueUpdateResponse>(`/issues/${id}`, data), api.patch<IssueUpdateResponse>(`/issues/${id}`, data),
resolveRecoveryAction: (
id: string,
data: {
actionId?: string;
outcome: "restored" | "false_positive" | "blocked" | "cancelled";
sourceIssueStatus: "done" | "in_review" | "blocked";
resolutionNote?: string | null;
},
) => api.post<ResolveRecoveryActionResponse>(`/issues/${id}/recovery-actions/resolve`, data),
previewTreeControl: (id: string, data: PreviewIssueTreeControl) => previewTreeControl: (id: string, data: PreviewIssueTreeControl) =>
api.post<IssueTreeControlPreview>(`/issues/${id}/tree-control/preview`, data), api.post<IssueTreeControlPreview>(`/issues/${id}/tree-control/preview`, data),
createTreeHold: (id: string, data: CreateIssueTreeHold) => createTreeHold: (id: string, data: CreateIssueTreeHold) =>
+33 -1
View File
@@ -1,17 +1,44 @@
import { memo, useMemo } from "react"; import { memo, useMemo } from "react";
import { Link } from "@/lib/router"; import { Link } from "@/lib/router";
import { useQueries, useQuery } from "@tanstack/react-query"; import { useQueries, useQuery } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared"; import type { Issue, IssueRecoveryAction } from "@paperclipai/shared";
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats"; import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
import type { TranscriptEntry } from "../adapters"; import type { TranscriptEntry } from "../adapters";
import { issuesApi } from "../api/issues"; import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { cn, relativeTime } from "../lib/utils"; import { cn, relativeTime } from "../lib/utils";
import {
deriveActiveRecoveryDisplayState,
RECOVERY_CHIP_DEFAULT_TONE,
} from "../lib/recovery-display";
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
import { Identity } from "./Identity"; import { Identity } from "./Identity";
import { RunChatSurface } from "./RunChatSurface"; import { RunChatSurface } from "./RunChatSurface";
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts"; import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
function RunCardRecoveryChip({ action }: { action: IssueRecoveryAction }) {
const state = deriveActiveRecoveryDisplayState(action);
if (!state) return null;
const tone = RECOVERY_CHIP_DEFAULT_TONE[state];
const Icon = tone.icon;
return (
<span
data-testid="active-agent-run-recovery-indicator"
data-recovery-state={state}
role="status"
aria-label={tone.label}
title={`${tone.label} — open the source issue to act.`}
className={cn(
"inline-flex shrink-0 items-center gap-0.5 rounded-full border px-1.5 py-0.5 text-[10px] font-medium",
tone.className,
)}
>
<Icon className="h-2.5 w-2.5" aria-hidden />
{tone.label}
</span>
);
}
const MIN_DASHBOARD_RUNS = 4; const MIN_DASHBOARD_RUNS = 4;
const DASHBOARD_RUN_CARD_LIMIT = 4; const DASHBOARD_RUN_CARD_LIMIT = 4;
const DASHBOARD_LOG_POLL_INTERVAL_MS = 15_000; const DASHBOARD_LOG_POLL_INTERVAL_MS = 15_000;
@@ -189,6 +216,11 @@ const AgentRunCard = memo(function AgentRunCard({
{issue?.identifier ?? run.issueId.slice(0, 8)} {issue?.identifier ?? run.issueId.slice(0, 8)}
{issue?.title ? ` - ${issue.title}` : ""} {issue?.title ? ` - ${issue.title}` : ""}
</Link> </Link>
{issue?.activeRecoveryAction ? (
<div className="mt-1.5">
<RunCardRecoveryChip action={issue.activeRecoveryAction} />
</div>
) : null}
</div> </div>
)} )}
</div> </div>
+67 -2
View File
@@ -2,8 +2,10 @@
import { act } from "react"; import { act } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import type { AnchorHTMLAttributes, ReactElement } from "react"; import type { AnchorHTMLAttributes, ReactElement, ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
import { IssueBlockedNotice } from "./IssueBlockedNotice"; import { IssueBlockedNotice } from "./IssueBlockedNotice";
vi.mock("@/lib/router", () => ({ vi.mock("@/lib/router", () => ({
@@ -27,11 +29,20 @@ afterEach(() => {
container = null; container = null;
}); });
function withProviders(node: ReactNode) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } } });
return (
<MemoryRouter>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</MemoryRouter>
);
}
function render(element: ReactElement) { function render(element: ReactElement) {
container = document.createElement("div"); container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
root = createRoot(container); root = createRoot(container);
act(() => root?.render(element)); act(() => root?.render(withProviders(element)));
return container; return container;
} }
@@ -102,4 +113,58 @@ describe("IssueBlockedNotice", () => {
expect(node.textContent).toBe(""); expect(node.textContent).toBe("");
}); });
it("renders a recovery indicator on a blocker chip when the blocker has an active recovery action", () => {
const node = render(
<IssueBlockedNotice
issueStatus="blocked"
blockers={[
{
id: "blocker-1",
identifier: "PAP-123",
title: "Build still red",
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
activeRecoveryAction: {
id: "rec-1",
companyId: "co-1",
sourceIssueId: "blocker-1",
recoveryIssueId: null,
kind: "missing_disposition",
status: "active",
ownerType: "agent",
ownerAgentId: "agent-cto",
ownerUserId: null,
previousOwnerAgentId: null,
returnOwnerAgentId: null,
cause: "successful_run_missing_state",
fingerprint: "fp-1",
evidence: {},
nextAction: "choose disposition",
wakePolicy: { type: "wake_owner" },
monitorPolicy: null,
attemptCount: 1,
maxAttempts: 3,
timeoutAt: null,
lastAttemptAt: null,
outcome: null,
resolutionNote: null,
resolvedAt: null,
createdAt: "2026-05-01T00:00:00.000Z",
updatedAt: "2026-05-01T00:00:00.000Z",
},
},
]}
/>,
);
const indicator = node.querySelector(
'[data-testid="issue-blocked-notice-recovery-indicator"]',
);
expect(indicator).not.toBeNull();
expect(indicator?.getAttribute("data-recovery-state")).toBe("needed");
expect(indicator?.textContent).toContain("Recovery needed");
});
}); });
+32 -1
View File
@@ -1,9 +1,38 @@
import type { IssueBlockerAttention, IssueRelationIssueSummary, SuccessfulRunHandoffState } from "@paperclipai/shared"; import type {
IssueBlockerAttention,
IssueRecoveryAction,
IssueRelationIssueSummary,
SuccessfulRunHandoffState,
} from "@paperclipai/shared";
import { AlertTriangle, Flag } from "lucide-react"; import { AlertTriangle, Flag } from "lucide-react";
import { Link } from "@/lib/router"; import { Link } from "@/lib/router";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb"; import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { IssueLinkQuicklook } from "./IssueLinkQuicklook"; import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
import { isAssignedBacklogBlocker } from "../lib/issue-blockers"; import { isAssignedBacklogBlocker } from "../lib/issue-blockers";
import {
deriveActiveRecoveryDisplayState,
RECOVERY_CHIP_DEFAULT_TONE,
} from "../lib/recovery-display";
function BlockerRecoveryIndicator({ action }: { action: IssueRecoveryAction }) {
const state = deriveActiveRecoveryDisplayState(action);
if (!state) return null;
const tone = RECOVERY_CHIP_DEFAULT_TONE[state];
const Icon = tone.icon;
return (
<span
data-testid="issue-blocked-notice-recovery-indicator"
data-recovery-state={state}
role="status"
aria-label={tone.label}
title={`${tone.label} — open the source issue to act.`}
className={`inline-flex shrink-0 items-center gap-0.5 rounded-full border px-1.5 py-0.5 text-[10px] font-medium ${tone.className}`}
>
<Icon className="h-2.5 w-2.5" aria-hidden />
{tone.label}
</span>
);
}
export function IssueBlockedNotice({ export function IssueBlockedNotice({
issueStatus, issueStatus,
@@ -69,6 +98,7 @@ export function IssueBlockedNotice({
const renderBlockerChip = (blocker: IssueRelationIssueSummary) => { const renderBlockerChip = (blocker: IssueRelationIssueSummary) => {
const issuePathId = blocker.identifier ?? blocker.id; const issuePathId = blocker.identifier ?? blocker.id;
const recoveryAction = blocker.activeRecoveryAction ?? null;
return ( return (
<IssueLinkQuicklook <IssueLinkQuicklook
key={blocker.id} key={blocker.id}
@@ -80,6 +110,7 @@ export function IssueBlockedNotice({
<span className="max-w-[18rem] truncate font-sans text-[11px] text-amber-800 dark:text-amber-200"> <span className="max-w-[18rem] truncate font-sans text-[11px] text-amber-800 dark:text-amber-200">
{blocker.title} {blocker.title}
</span> </span>
{recoveryAction ? <BlockerRecoveryIndicator action={recoveryAction} /> : null}
</IssueLinkQuicklook> </IssueLinkQuicklook>
); );
}; };
+53 -1
View File
@@ -36,6 +36,7 @@ import type {
FeedbackVoteValue, FeedbackVoteValue,
IssueAttachment, IssueAttachment,
IssueBlockerAttention, IssueBlockerAttention,
IssueRecoveryAction,
IssueRelationIssueSummary, IssueRelationIssueSummary,
SuccessfulRunHandoffState, SuccessfulRunHandoffState,
IssueWorkMode, IssueWorkMode,
@@ -134,6 +135,7 @@ import { Textarea } from "@/components/ui/textarea";
import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, ClipboardList, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, PauseCircle, Search, Square, ThumbsDown, ThumbsUp } from "lucide-react"; import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, ClipboardList, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, PauseCircle, Search, Square, ThumbsDown, ThumbsUp } from "lucide-react";
import { IssueBlockedNotice } from "./IssueBlockedNotice"; import { IssueBlockedNotice } from "./IssueBlockedNotice";
import { IssueAssignedBacklogNotice } from "./IssueAssignedBacklogNotice"; import { IssueAssignedBacklogNotice } from "./IssueAssignedBacklogNotice";
import { IssueRecoveryActionCard, type RecoveryResolveOutcome } from "./IssueRecoveryActionCard";
interface IssueChatMessageContext { interface IssueChatMessageContext {
feedbackDataSharingPreference: FeedbackDataSharingPreference; feedbackDataSharingPreference: FeedbackDataSharingPreference;
@@ -297,6 +299,14 @@ interface IssueChatThreadProps {
blockedBy?: IssueRelationIssueSummary[]; blockedBy?: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention | null; blockerAttention?: IssueBlockerAttention | null;
successfulRunHandoff?: SuccessfulRunHandoffState | null; successfulRunHandoff?: SuccessfulRunHandoffState | null;
recoveryAction?: IssueRecoveryAction | null;
onResolveRecoveryAction?: (outcome: RecoveryResolveOutcome) => void;
canFalsePositiveRecoveryAction?: boolean;
legacyRecoverySourceIssue?: {
identifier: string | null;
href: string;
title?: string | null;
} | null;
assigneeUserId?: string | null; assigneeUserId?: string | null;
onResumeFromBacklog?: () => Promise<void> | void; onResumeFromBacklog?: () => Promise<void> | void;
resumeFromBacklogPending?: boolean; resumeFromBacklogPending?: boolean;
@@ -3609,6 +3619,10 @@ export function IssueChatThread({
blockedBy = [], blockedBy = [],
blockerAttention = null, blockerAttention = null,
successfulRunHandoff = null, successfulRunHandoff = null,
recoveryAction = null,
onResolveRecoveryAction,
canFalsePositiveRecoveryAction = false,
legacyRecoverySourceIssue = null,
companyId, companyId,
projectId, projectId,
issueStatus, issueStatus,
@@ -4244,11 +4258,49 @@ export function IssueChatThread({
onResume={onResumeFromBacklog} onResume={onResumeFromBacklog}
resuming={resumeFromBacklogPending} resuming={resumeFromBacklogPending}
/> />
{recoveryAction ? (
<IssueRecoveryActionCard
action={recoveryAction}
agentMap={agentMap}
onResolve={onResolveRecoveryAction}
canFalsePositive={canFalsePositiveRecoveryAction}
/>
) : null}
{legacyRecoverySourceIssue ? (
<SystemNotice
tone="info"
label="Legacy recovery issue"
body={
<span>
Legacy recovery issue. Newer recovery actions live on the source issue
{legacyRecoverySourceIssue.identifier ? (
<>
{" — "}
<Link
to={legacyRecoverySourceIssue.href}
className="underline-offset-2 hover:underline"
>
{legacyRecoverySourceIssue.identifier}
{legacyRecoverySourceIssue.title ? (
<span className="text-muted-foreground">
{" "}
{legacyRecoverySourceIssue.title}
</span>
) : null}
</Link>
</>
) : (
"."
)}
</span>
}
/>
) : null}
<IssueBlockedNotice <IssueBlockedNotice
issueStatus={issueStatus} issueStatus={issueStatus}
blockers={unresolvedBlockers} blockers={unresolvedBlockers}
blockerAttention={blockerAttention} blockerAttention={blockerAttention}
successfulRunHandoff={successfulRunHandoff} successfulRunHandoff={recoveryAction ? null : successfulRunHandoff}
agentName={ agentName={
successfulRunHandoff?.assigneeAgentId successfulRunHandoff?.assigneeAgentId
? agentMap?.get(successfulRunHandoff.assigneeAgentId)?.name ?? null ? agentMap?.get(successfulRunHandoff.assigneeAgentId)?.name ?? null
@@ -0,0 +1,218 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { AnchorHTMLAttributes, ReactElement } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Agent, IssueRecoveryAction } from "@paperclipai/shared";
import { IssueRecoveryActionCard, deriveRecoveryCardState } from "./IssueRecoveryActionCard";
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => (
<a href={to} {...props}>{children}</a>
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let root: ReturnType<typeof createRoot> | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
if (root) {
act(() => root?.unmount());
}
root = null;
container?.remove();
container = null;
});
function render(element: ReactElement) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(element));
return container;
}
function click(element: Element | null) {
if (!element) throw new Error("Expected element to exist");
act(() => {
element.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}
const ownerAgent: Agent = {
id: "11111111-1111-1111-1111-111111111111",
companyId: "company-1",
name: "ClaudeCoder",
role: "engineer",
status: "idle",
adapterType: "claude_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
urlKey: "claudecoder",
} as unknown as Agent;
const returnAgent: Agent = {
...ownerAgent,
id: "22222222-2222-2222-2222-222222222222",
name: "CodexCoder",
urlKey: "codexcoder",
} as Agent;
function buildAction(overrides: Partial<IssueRecoveryAction> = {}): IssueRecoveryAction {
return {
id: "00000000-0000-0000-0000-0000000000aa",
companyId: "company-1",
sourceIssueId: "00000000-0000-0000-0000-0000000000ff",
recoveryIssueId: null,
kind: "missing_disposition",
status: "active",
ownerType: "agent",
ownerAgentId: ownerAgent.id,
ownerUserId: null,
previousOwnerAgentId: returnAgent.id,
returnOwnerAgentId: returnAgent.id,
cause: "missing_disposition",
fingerprint: "fp",
evidence: {
summary: "Run finished but no disposition was chosen.",
sourceRunId: "7accd7a4-c9ca-4db2-9233-3228a037cc09",
},
nextAction: "Choose and record a valid issue disposition.",
wakePolicy: { type: "wake_owner" },
monitorPolicy: null,
attemptCount: 1,
maxAttempts: 3,
timeoutAt: null,
lastAttemptAt: "2026-05-09T19:30:00.000Z",
outcome: null,
resolutionNote: null,
resolvedAt: null,
createdAt: "2026-05-09T19:30:00.000Z",
updatedAt: "2026-05-09T19:30:00.000Z",
...overrides,
};
}
describe("deriveRecoveryCardState", () => {
it("maps active missing_disposition to needed", () => {
expect(deriveRecoveryCardState(buildAction())).toBe("needed");
});
it("maps active_run_watchdog to observe_only", () => {
expect(deriveRecoveryCardState(buildAction({ kind: "active_run_watchdog" }))).toBe("observe_only");
});
it("maps escalated status to escalated", () => {
expect(deriveRecoveryCardState(buildAction({ status: "escalated" }))).toBe("escalated");
});
it("maps resolved/cancelled to resolved", () => {
expect(deriveRecoveryCardState(buildAction({ status: "resolved" }))).toBe("resolved");
expect(deriveRecoveryCardState(buildAction({ status: "cancelled" }))).toBe("resolved");
});
});
describe("IssueRecoveryActionCard", () => {
it("renders required fields and an aria-label naming the state", () => {
const node = render(
<IssueRecoveryActionCard
action={buildAction()}
agentMap={new Map([
[ownerAgent.id, ownerAgent],
[returnAgent.id, returnAgent],
])}
onResolve={() => {}}
/>,
);
const section = node.querySelector("section[aria-label]");
expect(section?.getAttribute("aria-label")).toBe("Recovery action: needed");
expect(node.textContent).toContain("RECOVERY NEEDED");
expect(node.textContent).toContain("Missing Disposition");
expect(node.textContent).not.toContain("missing_disposition");
expect(node.textContent).toContain("This issue's run finished, but no next step was chosen.");
expect(node.textContent).toContain("ClaudeCoder");
expect(node.textContent).toContain("CodexCoder");
expect(node.textContent).toContain("Choose and record a valid issue disposition.");
expect(node.textContent).toContain("Corrective wake queued");
});
it("falls back to em dash when wake policy is absent", () => {
const node = render(
<IssueRecoveryActionCard action={buildAction({ wakePolicy: null })} />,
);
expect(node.textContent).toContain("—");
});
it("renders observe_only tone for active_run_watchdog", () => {
const node = render(
<IssueRecoveryActionCard action={buildAction({ kind: "active_run_watchdog" })} />,
);
const section = node.querySelector("section[aria-label]");
expect(section?.getAttribute("aria-label")).toBe("Recovery action: observing active run");
expect(node.textContent).toContain("OBSERVING ACTIVE RUN");
});
it("renders the resolved label and outcome when resolved", () => {
const node = render(
<IssueRecoveryActionCard action={buildAction({ status: "resolved", outcome: "restored", resolvedAt: "2026-05-09T19:35:00.000Z" })} />,
);
expect(node.textContent).toContain("RECOVERY RESOLVED");
expect(node.textContent).toContain("Resolved as restored");
});
it("calls resolve with done and does not offer delegated recovery", () => {
const onResolve = vi.fn();
const node = render(
<IssueRecoveryActionCard action={buildAction()} onResolve={onResolve} />,
);
click(node.querySelector("[data-testid='recovery-action-resolve-trigger']"));
expect(document.body.textContent).toContain("Mark issue done");
expect(document.body.textContent).not.toContain("Mark blocked");
expect(document.body.textContent).not.toContain("Delegate follow-up issue");
click([...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Mark issue done")) ?? null);
expect(onResolve).toHaveBeenCalledWith("done");
});
it("does not offer blocked recovery resolution without a blocker selection flow", () => {
const node = render(
<IssueRecoveryActionCard action={buildAction()} onResolve={() => {}} canFalsePositive />,
);
click(node.querySelector("[data-testid='recovery-action-resolve-trigger']"));
expect(document.body.textContent).toContain("Mark issue done");
expect(document.body.textContent).toContain("Send for review");
expect(document.body.textContent).toContain("False positive, done");
expect(document.body.textContent).toContain("False positive, review");
expect(document.body.textContent).not.toContain("Mark blocked");
});
it("hides false-positive options unless canFalsePositive is set", () => {
const first = render(
<IssueRecoveryActionCard action={buildAction()} onResolve={() => {}} />,
);
click(first.querySelector("[data-testid='recovery-action-resolve-trigger']"));
expect(document.body.textContent).not.toContain("False positive");
act(() => root?.unmount());
root = null;
container?.remove();
container = null;
const onResolve = vi.fn();
const second = render(
<IssueRecoveryActionCard action={buildAction()} onResolve={onResolve} canFalsePositive />,
);
click(second.querySelector("[data-testid='recovery-action-resolve-trigger']"));
expect(document.body.textContent).toContain("False positive, done");
expect(document.body.textContent).toContain("False positive, review");
click([...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("False positive, done")) ?? null);
expect(onResolve).toHaveBeenCalledWith("false_positive_done");
});
});
@@ -0,0 +1,537 @@
import { useMemo } from "react";
import type {
Agent,
IssueRecoveryAction,
IssueRecoveryActionKind,
IssueRecoveryActionOutcome,
IssueRecoveryActionStatus,
} from "@paperclipai/shared";
import { Eye, OctagonAlert, RefreshCw, Sparkles, TriangleAlert } from "lucide-react";
import { Link } from "@/lib/router";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { agentUrl } from "@/lib/utils";
import { cn } from "@/lib/utils";
import {
deriveRecoveryDisplayState,
type RecoveryDisplayState,
} from "@/lib/recovery-display";
export type RecoveryCardCardState = RecoveryDisplayState;
export const deriveRecoveryCardState = deriveRecoveryDisplayState;
export type RecoveryResolveOutcome =
| "done"
| "in_review"
| "false_positive_done"
| "false_positive_in_review";
export interface IssueRecoveryActionCardProps {
action: IssueRecoveryAction;
agentMap?: ReadonlyMap<string, Agent>;
/** Preferred state hint (e.g. observe_only when watchdog tone is requested). Falls back to derived state. */
forcedState?: RecoveryCardCardState;
/** Optional click handler for resolve menu actions. If omitted, the buttons are not rendered. */
onResolve?: (outcome: RecoveryResolveOutcome) => void;
/** Whether the viewer can run destructive board-only actions (e.g. false-positive dismissal). */
canFalsePositive?: boolean;
className?: string;
}
const KIND_LABEL: Record<IssueRecoveryActionKind, string> = {
missing_disposition: "Missing Disposition",
stranded_assigned_issue: "Stranded Issue",
active_run_watchdog: "Active Watchdog",
issue_graph_liveness: "Graph Liveness",
};
const KIND_HEADLINE: Record<IssueRecoveryActionKind, string> = {
missing_disposition: "This issue's run finished, but no next step was chosen.",
stranded_assigned_issue:
"Paperclip retried this issue's last run and it still has no live execution path.",
active_run_watchdog:
"The active run has been silent. Recovery is observing without interrupting it.",
issue_graph_liveness:
"Paperclip detected this issue lost a live action path. A recovery owner needs to act.",
};
const STATE_TONE: Record<RecoveryCardCardState, {
label: string;
containerClass: string;
iconWrapClass: string;
iconClass: string;
labelClass: string;
Icon: typeof TriangleAlert;
divider: string;
}> = {
needed: {
label: "RECOVERY NEEDED",
containerClass:
"border-amber-300/70 bg-amber-50/85 text-amber-950 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100",
iconWrapClass: "bg-amber-100 text-amber-800 dark:bg-amber-500/20 dark:text-amber-200",
iconClass: "text-amber-700 dark:text-amber-300",
labelClass: "text-amber-900 dark:text-amber-200",
Icon: TriangleAlert,
divider: "border-amber-300/60 dark:border-amber-500/30",
},
in_progress: {
label: "RECOVERY IN PROGRESS",
containerClass:
"border-sky-300/70 bg-sky-50/80 text-sky-950 dark:border-sky-500/40 dark:bg-sky-500/10 dark:text-sky-100",
iconWrapClass: "bg-sky-100 text-sky-800 dark:bg-sky-500/20 dark:text-sky-200",
iconClass: "text-sky-700 dark:text-sky-300",
labelClass: "text-sky-900 dark:text-sky-200",
Icon: RefreshCw,
divider: "border-sky-300/60 dark:border-sky-500/30",
},
observe_only: {
label: "OBSERVING ACTIVE RUN",
containerClass:
"border-border bg-muted/40 text-foreground dark:bg-muted/20",
iconWrapClass: "bg-muted text-foreground/70",
iconClass: "text-muted-foreground",
labelClass: "text-muted-foreground",
Icon: Eye,
divider: "border-border/70",
},
escalated: {
label: "RECOVERY ESCALATED",
containerClass:
"border-red-400/60 bg-red-50/85 text-red-950 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-100",
iconWrapClass: "bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-200",
iconClass: "text-red-700 dark:text-red-300",
labelClass: "text-red-900 dark:text-red-200",
Icon: OctagonAlert,
divider: "border-red-400/50 dark:border-red-500/30",
},
resolved: {
label: "RECOVERY RESOLVED",
containerClass:
"border-emerald-300/70 bg-emerald-50/80 text-emerald-950 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-100",
iconWrapClass: "bg-emerald-100 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-200",
iconClass: "text-emerald-700 dark:text-emerald-300",
labelClass: "text-emerald-900 dark:text-emerald-200",
Icon: Sparkles,
divider: "border-emerald-300/60 dark:border-emerald-500/30",
},
};
const OUTCOME_LABEL: Record<IssueRecoveryActionOutcome, string> = {
restored: "restored",
delegated: "delegated to follow-up",
false_positive: "false positive",
blocked: "blocked",
escalated: "escalated",
cancelled: "cancelled",
};
function readEvidenceString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed) return null;
return trimmed.length > 240 ? `${trimmed.slice(0, 237)}` : trimmed;
}
function pickEvidenceSummary(action: IssueRecoveryAction): string | null {
const evidence = action.evidence ?? {};
const candidates = [
"summary",
"detectedProgressSummary",
"missingDisposition",
"retryReason",
"latestRunErrorCode",
"latestRunStatus",
"latestIssueStatus",
] as const;
for (const key of candidates) {
const next = readEvidenceString(evidence[key]);
if (next) return next;
}
return null;
}
function readEvidenceRunId(action: IssueRecoveryAction, key: "sourceRunId" | "correctiveRunId" | "latestRunId") {
const evidence = action.evidence ?? {};
const next = readEvidenceString(evidence[key]);
return next;
}
function readWakePolicySummary(action: IssueRecoveryAction): string | null {
const policy = action.wakePolicy;
if (!policy) return null;
const type = readEvidenceString(policy.type);
if (!type) return null;
if (type === "wake_owner") return "Corrective wake queued";
if (type === "board_escalation") return "Escalated to board";
if (type === "manual") return "Manual";
if (type === "monitor") {
const interval = readEvidenceString(policy.intervalLabel);
return interval ? `Monitor scheduled · ${interval}` : "Monitor scheduled";
}
return type.replaceAll("_", " ");
}
function formatTimeShort(value: string | Date | null | undefined): string | null {
if (!value) return null;
try {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return null;
const now = Date.now();
const diffMs = date.getTime() - now;
const absMin = Math.round(Math.abs(diffMs) / 60_000);
if (absMin < 60) {
return diffMs >= 0 ? `in ${absMin}m` : `${absMin}m ago`;
}
return date.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
} catch {
return null;
}
}
function shortenRunId(runId: string | null | undefined) {
if (!runId) return null;
if (runId.length <= 12) return runId;
return runId.slice(0, 8);
}
function MetadataRow({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="grid grid-cols-[7.5rem_1fr] gap-x-3 gap-y-0 px-3 py-1.5 text-xs sm:px-4">
<dt className="truncate text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
{label}
</dt>
<dd className="min-w-0 break-words text-foreground/90">{children}</dd>
</div>
);
}
function MissingValue() {
return <span className="text-muted-foreground"></span>;
}
function AgentLink({
agentId,
agentMap,
fallback,
}: {
agentId: string | null | undefined;
agentMap?: ReadonlyMap<string, Agent>;
fallback?: string | null;
}) {
if (!agentId) {
return fallback ? <span>{fallback}</span> : <MissingValue />;
}
const agent = agentMap?.get(agentId);
const label = agent?.name ?? `agent ${agentId.slice(0, 8)}`;
if (agent) {
return (
<Link
to={agentUrl(agent)}
className="rounded-sm font-medium underline-offset-2 hover:underline"
>
{label}
</Link>
);
}
return <span className="font-medium">{label}</span>;
}
function RunChip({
runId,
agentId,
status,
}: {
runId: string | null;
agentId: string | null | undefined;
status?: string | null;
}) {
if (!runId) return <MissingValue />;
const short = shortenRunId(runId);
const inner = (
<>
<code className="rounded bg-background/80 px-1.5 py-0.5 font-mono text-[11px] text-foreground/80">
run {short}
</code>
{status ? (
<span className="font-sans text-[11px] text-muted-foreground">{status}</span>
) : null}
</>
);
if (agentId) {
return (
<Link
to={`/agents/${agentId}/runs/${runId}`}
className="inline-flex items-center gap-2 rounded-sm underline-offset-2 hover:underline"
>
{inner}
</Link>
);
}
return <span className="inline-flex items-center gap-2">{inner}</span>;
}
const RESOLVE_OPTIONS: Array<{
outcome: RecoveryResolveOutcome;
label: string;
description: string;
destructive?: boolean;
boardOnly?: boolean;
}> = [
{
outcome: "done",
label: "Mark issue done",
description: "Restore by recording the requested work as complete.",
},
{
outcome: "in_review",
label: "Send for review",
description: "Hand off to a reviewer with a real review path.",
},
{
outcome: "false_positive_done",
label: "False positive, done",
description: "Dismiss recovery and mark the source issue complete.",
destructive: true,
boardOnly: true,
},
{
outcome: "false_positive_in_review",
label: "False positive, review",
description: "Dismiss recovery and send the source issue for review.",
destructive: true,
boardOnly: true,
},
];
export function IssueRecoveryActionCard({
action,
agentMap,
forcedState,
onResolve,
canFalsePositive = false,
className,
}: IssueRecoveryActionCardProps) {
const cardState: RecoveryCardCardState = forcedState ?? deriveRecoveryCardState(action);
const tone = STATE_TONE[cardState];
const ToneIcon = tone.Icon;
const headline = useMemo(() => {
if (cardState === "resolved" && action.outcome) {
return `Recovery resolved as ${OUTCOME_LABEL[action.outcome] ?? action.outcome}.`;
}
return KIND_HEADLINE[action.kind] ?? KIND_HEADLINE.missing_disposition;
}, [action.kind, action.outcome, cardState]);
const wakeSummary = readWakePolicySummary(action);
const evidenceSummary = pickEvidenceSummary(action);
const sourceRunId = readEvidenceRunId(action, "sourceRunId") ?? readEvidenceRunId(action, "latestRunId");
const correctiveRunId = readEvidenceRunId(action, "correctiveRunId");
const showAttempt = action.attemptCount > 1 && action.maxAttempts !== null;
const showTimeoutInline = (() => {
if (!action.timeoutAt) return false;
try {
const date = action.timeoutAt instanceof Date ? action.timeoutAt : new Date(action.timeoutAt);
const diffMs = date.getTime() - Date.now();
return diffMs > 0 && diffMs < 60 * 60 * 1000;
} catch {
return false;
}
})();
const updatedAtLabel = formatTimeShort(action.updatedAt);
const ariaState = ({
needed: "needed",
in_progress: "in progress",
observe_only: "observing active run",
escalated: "escalated",
resolved: "resolved",
} satisfies Record<RecoveryCardCardState, string>)[cardState];
const showResolveActions = onResolve !== undefined && cardState !== "resolved";
const visibleResolveOptions = RESOLVE_OPTIONS.filter((option) => {
if (option.boardOnly && !canFalsePositive) return false;
return true;
});
return (
<section
role="status"
aria-label={`Recovery action: ${ariaState}`}
data-recovery-state={cardState}
data-recovery-kind={action.kind}
className={cn(
"relative w-full overflow-hidden rounded-lg border text-sm shadow-[0_1px_0_rgba(15,23,42,0.02)]",
tone.containerClass,
className,
)}
>
<header className="flex items-start gap-3 px-3 py-2.5 sm:px-4">
<span
className={cn(
"mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md",
tone.iconWrapClass,
)}
aria-hidden
>
<ToneIcon className={cn("h-4 w-4", tone.iconClass)} />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] font-semibold uppercase tracking-[0.14em]">
<span className={tone.labelClass}>{tone.label}</span>
<span className="text-muted-foreground/60" aria-hidden>·</span>
<code className="rounded bg-background/70 px-1.5 py-0.5 font-mono text-[11px] tracking-normal text-muted-foreground">
{KIND_LABEL[action.kind] ?? action.kind}
</code>
{updatedAtLabel ? (
<>
<span className="text-muted-foreground/60" aria-hidden>·</span>
<span className="font-medium normal-case tracking-normal text-muted-foreground">
{updatedAtLabel}
</span>
</>
) : null}
</div>
<p className="mt-1 text-[14px] leading-6">{headline}</p>
</div>
</header>
<dl className={cn("border-t bg-background/40 dark:bg-background/20", tone.divider)}>
<MetadataRow label="Owner">
<span className="inline-flex flex-wrap items-center gap-1.5">
{action.ownerType === "agent" && action.ownerAgentId ? (
<>
<span className="text-muted-foreground">Recovery:</span>
<AgentLink agentId={action.ownerAgentId} agentMap={agentMap} />
</>
) : action.ownerType === "board" ? (
<span className="font-medium">Board</span>
) : action.ownerType === "user" && action.ownerUserId ? (
<span className="font-medium">user {action.ownerUserId.slice(0, 6)}</span>
) : action.ownerType === "system" ? (
<span className="font-medium">System</span>
) : (
<span className="text-muted-foreground">unassigned pick one to wake them</span>
)}
{action.returnOwnerAgentId ? (
<>
<span className="text-muted-foreground"> Returns to:</span>
<AgentLink agentId={action.returnOwnerAgentId} agentMap={agentMap} />
</>
) : null}
</span>
</MetadataRow>
<MetadataRow label="Source run">
<RunChip runId={sourceRunId} agentId={action.previousOwnerAgentId} />
</MetadataRow>
{correctiveRunId ? (
<MetadataRow label="Corrective run">
<RunChip runId={correctiveRunId} agentId={action.previousOwnerAgentId} />
</MetadataRow>
) : null}
<MetadataRow label="Evidence">
{evidenceSummary ? (
<span className="break-words font-mono text-[11px] text-foreground/80">{evidenceSummary}</span>
) : (
<MissingValue />
)}
</MetadataRow>
<MetadataRow label="Next action">
{action.nextAction ? <span>{action.nextAction}</span> : <MissingValue />}
</MetadataRow>
<MetadataRow label="Wake">
<span className="inline-flex flex-wrap items-center gap-1.5">
{wakeSummary ? <span>{wakeSummary}</span> : <MissingValue />}
{showAttempt ? (
<span className="rounded-md border border-border/50 bg-background/60 px-1.5 py-0.5 text-[11px] text-muted-foreground">
attempt {action.attemptCount} of {action.maxAttempts}
</span>
) : null}
{showTimeoutInline ? (
<span className="rounded-md border border-border/50 bg-background/60 px-1.5 py-0.5 text-[11px] text-muted-foreground">
Times out {formatTimeShort(action.timeoutAt) ?? "soon"}
</span>
) : null}
</span>
</MetadataRow>
{cardState === "resolved" && action.outcome ? (
<MetadataRow label="Resolution">
<span className={cn("font-medium", tone.labelClass)}>
Resolved as {OUTCOME_LABEL[action.outcome]}
{action.resolvedAt ? ` · ${formatTimeShort(action.resolvedAt) ?? ""}` : ""}
</span>
</MetadataRow>
) : null}
</dl>
{showResolveActions ? (
<div className={cn("flex flex-wrap items-center gap-2 border-t px-3 py-2.5 sm:px-4", tone.divider)}>
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
size="sm"
variant="default"
data-testid="recovery-action-resolve-trigger"
aria-label="Resolve recovery"
>
Resolve
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={6}
className="w-72 p-1.5"
>
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
Resolve recovery
</div>
<div className="flex flex-col">
{visibleResolveOptions.map((option) => (
<button
key={option.outcome}
type="button"
onClick={() => onResolve?.(option.outcome)}
className={cn(
"flex flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
"hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
option.destructive ? "text-destructive" : null,
)}
>
<span className="font-medium leading-5">{option.label}</span>
<span className="text-[11px] leading-4 text-muted-foreground">{option.description}</span>
</button>
))}
</div>
</PopoverContent>
</Popover>
{cardState === "observe_only" ? (
<span className="text-[11px] text-muted-foreground">
Recovery is observing without interrupting the live run.
</span>
) : (
<span className="text-[11px] text-muted-foreground">
The card stays open until an explicit decision is recorded.
</span>
)}
</div>
) : null}
</section>
);
}
export type { IssueRecoveryActionStatus };
export default IssueRecoveryActionCard;
+30 -1
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { Issue } from "@paperclipai/shared"; import type { Issue, IssueRecoveryAction } from "@paperclipai/shared";
import { Link } from "@/lib/router"; import { Link } from "@/lib/router";
import { Eye, Flag, X } from "lucide-react"; import { Eye, Flag, X } from "lucide-react";
import { import {
@@ -8,6 +8,7 @@ import {
withIssueDetailHeaderSeed, withIssueDetailHeaderSeed,
} from "../lib/issueDetailBreadcrumb"; } from "../lib/issueDetailBreadcrumb";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { deriveActiveRecoveryDisplayState, RECOVERY_CHIP_DEFAULT_TONE } from "../lib/recovery-display";
import { StatusIcon } from "./StatusIcon"; import { StatusIcon } from "./StatusIcon";
import { productivityReviewTriggerLabel } from "./ProductivityReviewBadge"; import { productivityReviewTriggerLabel } from "./ProductivityReviewBadge";
import { hasAssignedBacklogBlocker } from "../lib/issue-blockers"; import { hasAssignedBacklogBlocker } from "../lib/issue-blockers";
@@ -92,6 +93,8 @@ export function IssueRow({
Planning Planning
</span> </span>
) : null; ) : null;
const recoveryAction = issue.activeRecoveryAction ?? null;
const recoveryIndicator = recoveryAction ? renderRecoveryChip(recoveryAction, selected) : null;
const parkedBlockerIndicator = hasAssignedBacklogBlocker(issue.blockedBy) ? ( const parkedBlockerIndicator = hasAssignedBacklogBlocker(issue.blockedBy) ? (
<span <span
data-testid="issue-row-parked-blocker" data-testid="issue-row-parked-blocker"
@@ -125,6 +128,7 @@ export function IssueRow({
{productivityReviewIndicator} {productivityReviewIndicator}
{planningModeIndicator} {planningModeIndicator}
{parkedBlockerIndicator} {parkedBlockerIndicator}
{recoveryIndicator}
</span> </span>
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents"> <span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
<span className={cn("line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none", titleClassName)}> <span className={cn("line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none", titleClassName)}>
@@ -151,6 +155,7 @@ export function IssueRow({
</span> </span>
{planningModeIndicator} {planningModeIndicator}
{parkedBlockerIndicator} {parkedBlockerIndicator}
{recoveryIndicator}
</> </>
)} )}
{mobileMeta ? ( {mobileMeta ? (
@@ -230,3 +235,27 @@ export function IssueRow({
</Link> </Link>
); );
} }
function renderRecoveryChip(action: IssueRecoveryAction, selected: boolean): ReactNode {
const state = deriveActiveRecoveryDisplayState(action);
if (!state) return null;
const tone = RECOVERY_CHIP_DEFAULT_TONE[state];
const Icon = tone.icon;
return (
<span
data-testid="issue-row-recovery-indicator"
data-recovery-state={state}
role="status"
aria-label={tone.label}
className={cn(
"ml-1.5 inline-flex shrink-0 items-center gap-0.5 rounded-full border px-2 py-0.5 text-[10px] font-medium",
tone.className,
selected ? "!border-muted-foreground !text-muted-foreground" : null,
)}
title={`${tone.label} — open the source issue to act.`}
>
<Icon className="h-2.5 w-2.5" aria-hidden />
{tone.label}
</span>
);
}
+6
View File
@@ -46,6 +46,9 @@ const ACTIVITY_ROW_VERBS: Record<string, string> = {
"issue.successful_run_handoff_required": "flagged missing next step on", "issue.successful_run_handoff_required": "flagged missing next step on",
"issue.successful_run_handoff_resolved": "recorded next step chosen on", "issue.successful_run_handoff_resolved": "recorded next step chosen on",
"issue.successful_run_handoff_escalated": "escalated missing next step on", "issue.successful_run_handoff_escalated": "escalated missing next step on",
"issue.recovery_action_opened": "opened a recovery action on",
"issue.recovery_action_resolved": "resolved the recovery action on",
"issue.recovery_action_escalated": "escalated the recovery action on",
"agent.created": "created", "agent.created": "created",
"agent.updated": "updated", "agent.updated": "updated",
"agent.paused": "paused", "agent.paused": "paused",
@@ -98,6 +101,9 @@ const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
"issue.successful_run_handoff_required": "Run finished without a clear next step", "issue.successful_run_handoff_required": "Run finished without a clear next step",
"issue.successful_run_handoff_resolved": "Next step chosen", "issue.successful_run_handoff_resolved": "Next step chosen",
"issue.successful_run_handoff_escalated": "Run finished without a next step - recovery escalated", "issue.successful_run_handoff_escalated": "Run finished without a next step - recovery escalated",
"issue.recovery_action_opened": "Opened a source-scoped recovery action",
"issue.recovery_action_resolved": "Resolved the recovery action",
"issue.recovery_action_escalated": "Escalated the recovery action",
"agent.created": "created an agent", "agent.created": "created an agent",
"agent.updated": "updated the agent", "agent.updated": "updated the agent",
"agent.paused": "paused the agent", "agent.paused": "paused the agent",
+57
View File
@@ -0,0 +1,57 @@
import type { IssueRecoveryAction } from "@paperclipai/shared";
import { Eye, OctagonAlert, RefreshCw, TriangleAlert } from "lucide-react";
export type RecoveryDisplayState =
| "needed"
| "in_progress"
| "observe_only"
| "escalated"
| "resolved";
export type ActiveRecoveryDisplayState = Exclude<RecoveryDisplayState, "resolved">;
export const RECOVERY_CHIP_DEFAULT_TONE: Record<
ActiveRecoveryDisplayState,
{ className: string; icon: typeof TriangleAlert; label: string }
> = {
needed: {
className:
"border-amber-500/60 bg-amber-500/15 text-amber-700 dark:text-amber-300",
icon: TriangleAlert,
label: "Recovery needed",
},
in_progress: {
className:
"border-sky-500/60 bg-sky-500/15 text-sky-700 dark:text-sky-300",
icon: RefreshCw,
label: "Recovery in progress",
},
observe_only: {
className: "border-border bg-muted text-muted-foreground",
icon: Eye,
label: "Observing active run",
},
escalated: {
className: "border-red-500/60 bg-red-500/15 text-red-700 dark:text-red-300",
icon: OctagonAlert,
label: "Recovery escalated",
},
};
export function deriveRecoveryDisplayState(
action: Pick<IssueRecoveryAction, "status" | "kind" | "outcome">,
): RecoveryDisplayState {
if (action.status === "resolved") return "resolved";
if (action.status === "escalated") return "escalated";
if (action.status === "cancelled") return "resolved";
if (action.kind === "active_run_watchdog") return "observe_only";
if (action.outcome === "delegated") return "in_progress";
return "needed";
}
export function deriveActiveRecoveryDisplayState(
action: Pick<IssueRecoveryAction, "status" | "kind" | "outcome">,
): ActiveRecoveryDisplayState | null {
const state = deriveRecoveryDisplayState(action);
return state === "resolved" ? null : state;
}
+37 -1
View File
@@ -5,7 +5,7 @@ import type { Agent, Issue, IssueTreeControlPreview, IssueTreeHold } from "@pape
import { act, type ButtonHTMLAttributes, type ReactNode } from "react"; import { act, type ButtonHTMLAttributes, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueDetail } from "./IssueDetail"; import { canBoardResolveRecoveryAction, IssueDetail } from "./IssueDetail";
const mockIssuesApi = vi.hoisted(() => ({ const mockIssuesApi = vi.hoisted(() => ({
get: vi.fn(), get: vi.fn(),
@@ -1447,3 +1447,39 @@ describe("IssueDetail", () => {
expect(footer?.className).toContain("bg-background"); expect(footer?.className).toContain("bg-background");
}); });
}); });
describe("canBoardResolveRecoveryAction", () => {
it("falls back to companyIds when memberships are not populated", () => {
expect(
canBoardResolveRecoveryAction("company-1", {
companyIds: ["company-1"],
memberships: [],
isInstanceAdmin: false,
source: "session",
keyId: null,
user: null,
userId: "user-1",
}),
).toBe(true);
});
it("uses populated memberships as the authoritative board access source", () => {
expect(
canBoardResolveRecoveryAction("company-1", {
companyIds: ["company-1"],
memberships: [
{
companyId: "company-1",
membershipRole: "viewer",
status: "active",
},
],
isInstanceAdmin: false,
source: "session",
keyId: null,
user: null,
userId: "user-1",
}),
).toBe(false);
});
});
+106 -1
View File
@@ -8,7 +8,7 @@ import { approvalsApi } from "../api/approvals";
import { activityApi, type RunForIssue } from "../api/activity"; import { activityApi, type RunForIssue } from "../api/activity";
import { heartbeatsApi, type ActiveRunForIssue, type LiveRunForIssue } from "../api/heartbeats"; import { heartbeatsApi, type ActiveRunForIssue, type LiveRunForIssue } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings"; import { instanceSettingsApi } from "../api/instanceSettings";
import { accessApi } from "../api/access"; import { accessApi, type CurrentBoardAccess } from "../api/access";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth"; import { authApi } from "../api/auth";
import { projectsApi } from "../api/projects"; import { projectsApi } from "../api/projects";
@@ -157,6 +157,7 @@ import {
type CommentReassignment = IssueCommentReassignment; type CommentReassignment = IssueCommentReassignment;
type ActionableIssueThreadInteraction = SuggestTasksInteraction | RequestConfirmationInteraction; type ActionableIssueThreadInteraction = SuggestTasksInteraction | RequestConfirmationInteraction;
type ResolveRecoveryActionOutcome = "restored" | "false_positive" | "blocked" | "cancelled";
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & { type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
runId?: string | null; runId?: string | null;
runAgentId?: string | null; runAgentId?: string | null;
@@ -211,6 +212,23 @@ function treeControlPreviewErrorCopy(error: unknown): string {
return error instanceof Error ? error.message : "Unable to load preview."; return error instanceof Error ? error.message : "Unable to load preview.";
} }
export function canBoardResolveRecoveryAction(
companyId: string | null | undefined,
boardAccess: CurrentBoardAccess | undefined,
) {
if (!companyId || !boardAccess) return false;
if (boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin) return true;
if (!boardAccess.memberships || boardAccess.memberships.length === 0) {
return boardAccess.companyIds.includes(companyId);
}
const membership = boardAccess.memberships.find(
(item) => item.companyId === companyId && item.status === "active",
);
if (!membership) return false;
return membership.membershipRole !== "viewer" && membership.membershipRole !== null;
}
function resolveRunningIssueRun( function resolveRunningIssueRun(
activeRun: ActiveRunForIssue | null | undefined, activeRun: ActiveRunForIssue | null | undefined,
liveRuns: readonly LiveRunForIssue[] | undefined, liveRuns: readonly LiveRunForIssue[] | undefined,
@@ -598,6 +616,14 @@ type IssueDetailChatTabProps = {
blockedBy: Issue["blockedBy"]; blockedBy: Issue["blockedBy"];
blockerAttention: Issue["blockerAttention"] | null; blockerAttention: Issue["blockerAttention"] | null;
successfulRunHandoff: Issue["successfulRunHandoff"] | null; successfulRunHandoff: Issue["successfulRunHandoff"] | null;
recoveryAction: Issue["activeRecoveryAction"];
onResolveRecoveryAction?: (outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => void;
canFalsePositiveRecoveryAction?: boolean;
legacyRecoverySourceIssue?: {
identifier: string | null;
href: string;
title?: string | null;
} | null;
comments: IssueDetailComment[]; comments: IssueDetailComment[];
locallyQueuedCommentRunIds: ReadonlyMap<string, string>; locallyQueuedCommentRunIds: ReadonlyMap<string, string>;
interactions: IssueThreadInteraction[]; interactions: IssueThreadInteraction[];
@@ -661,6 +687,10 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
blockedBy, blockedBy,
blockerAttention, blockerAttention,
successfulRunHandoff, successfulRunHandoff,
recoveryAction,
onResolveRecoveryAction,
canFalsePositiveRecoveryAction,
legacyRecoverySourceIssue,
comments, comments,
locallyQueuedCommentRunIds, locallyQueuedCommentRunIds,
interactions, interactions,
@@ -867,6 +897,10 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
blockedBy={blockedBy ?? []} blockedBy={blockedBy ?? []}
blockerAttention={blockerAttention} blockerAttention={blockerAttention}
successfulRunHandoff={successfulRunHandoff} successfulRunHandoff={successfulRunHandoff}
recoveryAction={recoveryAction ?? null}
onResolveRecoveryAction={onResolveRecoveryAction}
canFalsePositiveRecoveryAction={canFalsePositiveRecoveryAction}
legacyRecoverySourceIssue={legacyRecoverySourceIssue ?? null}
companyId={companyId} companyId={companyId}
projectId={projectId} projectId={projectId}
issueStatus={issueStatus} issueStatus={issueStatus}
@@ -1374,6 +1408,7 @@ export function IssueDetail() {
selectedCompanyId selectedCompanyId
&& boardAccess?.companyIds?.includes(selectedCompanyId), && boardAccess?.companyIds?.includes(selectedCompanyId),
); );
const canResolveBoardRecoveryAction = canBoardResolveRecoveryAction(selectedCompanyId, boardAccess);
const { data: feedbackVotes } = useQuery({ const { data: feedbackVotes } = useQuery({
queryKey: queryKeys.issues.feedbackVotes(issueId!), queryKey: queryKeys.issues.feedbackVotes(issueId!),
queryFn: () => issuesApi.listFeedbackVotes(issueId!), queryFn: () => issuesApi.listFeedbackVotes(issueId!),
@@ -1709,6 +1744,34 @@ export function IssueDetail() {
} }
}, },
}); });
const resolveRecoveryAction = useMutation({
mutationFn: (data: {
actionId?: string;
outcome: ResolveRecoveryActionOutcome;
sourceIssueStatus: "done" | "in_review" | "blocked";
resolutionNote?: string | null;
}) => issuesApi.resolveRecoveryAction(issueId!, data),
onSuccess: ({ issue: nextIssue }) => {
const issueRefs = new Set<string>([issueId!, nextIssue.id]);
if (nextIssue.identifier) issueRefs.add(nextIssue.identifier);
mergeIssueResponseIntoCaches(issueRefs, nextIssue);
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) });
invalidateIssueCollections();
},
onError: (err) => {
pushToast({
title: "Recovery resolution failed",
body: err instanceof Error ? err.message : "Unable to resolve recovery action",
tone: "error",
});
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
if (selectedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
}
},
});
const executeTreeControl = useMutation({ const executeTreeControl = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (treeControlMode === "resume") { if (treeControlMode === "resume") {
@@ -2909,6 +2972,28 @@ export function IssueDetail() {
const handleResumeFromBacklog = useCallback(async () => { const handleResumeFromBacklog = useCallback(async () => {
await updateIssue.mutateAsync({ status: "todo" }); await updateIssue.mutateAsync({ status: "todo" });
}, [updateIssue.mutateAsync]); }, [updateIssue.mutateAsync]);
const activeRecoveryActionId = issue?.activeRecoveryAction?.id;
const handleResolveRecoveryAction = useCallback(
(outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => {
const actionId = activeRecoveryActionId;
if (!actionId) return;
switch (outcome) {
case "done":
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "done" });
return;
case "in_review":
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "in_review" });
return;
case "false_positive_done":
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "false_positive", sourceIssueStatus: "done" });
return;
case "false_positive_in_review":
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "false_positive", sourceIssueStatus: "in_review" });
return;
}
},
[activeRecoveryActionId, resolveRecoveryAction.mutateAsync],
);
const treePreviewAffectedIssues = useMemo( const treePreviewAffectedIssues = useMemo(
() => (treeControlPreview?.issues ?? []).filter((candidate) => !candidate.skipped), () => (treeControlPreview?.issues ?? []).filter((candidate) => !candidate.skipped),
@@ -2970,6 +3055,22 @@ export function IssueDetail() {
// Ancestors are returned oldest-first from the server (root at end, immediate parent at start) // Ancestors are returned oldest-first from the server (root at end, immediate parent at start)
const ancestors = issue.ancestors ?? []; const ancestors = issue.ancestors ?? [];
const legacyRecoverySourceIssue = (() => {
if (
issue.originKind !== "stranded_issue_recovery" &&
issue.originKind !== "stale_active_run_evaluation"
) {
return null;
}
const parent = ancestors.length > 0 ? ancestors[0] : null;
if (!parent) return null;
const ref = parent.identifier ?? parent.id;
return {
identifier: parent.identifier ?? null,
title: parent.title ?? null,
href: createIssueDetailPath(ref),
};
})();
const handleFilePicked = async (evt: ChangeEvent<HTMLInputElement>) => { const handleFilePicked = async (evt: ChangeEvent<HTMLInputElement>) => {
const files = evt.target.files; const files = evt.target.files;
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
@@ -3787,6 +3888,10 @@ export function IssueDetail() {
blockedBy={issue.blockedBy ?? []} blockedBy={issue.blockedBy ?? []}
blockerAttention={issue.blockerAttention ?? null} blockerAttention={issue.blockerAttention ?? null}
successfulRunHandoff={issue.successfulRunHandoff ?? null} successfulRunHandoff={issue.successfulRunHandoff ?? null}
recoveryAction={issue.activeRecoveryAction ?? null}
onResolveRecoveryAction={handleResolveRecoveryAction}
canFalsePositiveRecoveryAction={canResolveBoardRecoveryAction}
legacyRecoverySourceIssue={legacyRecoverySourceIssue}
comments={threadComments} comments={threadComments}
locallyQueuedCommentRunIds={locallyQueuedCommentRunIds} locallyQueuedCommentRunIds={locallyQueuedCommentRunIds}
interactions={interactions} interactions={interactions}
@@ -0,0 +1,381 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ReactNode } from "react";
import type { IssueRecoveryAction, IssueRelationIssueSummary } from "@paperclipai/shared";
import { Eye, ExternalLink, OctagonAlert, RefreshCw, TriangleAlert } from "lucide-react";
import { IssueRecoveryActionCard } from "@/components/IssueRecoveryActionCard";
import { IssueRow } from "@/components/IssueRow";
import { IssueBlockedNotice } from "@/components/IssueBlockedNotice";
import { storybookAgentMap, storybookAgents, createIssue } from "../fixtures/paperclipData";
const claudeAgent = storybookAgents.find((agent) => agent.name.toLowerCase().startsWith("claude")) ?? storybookAgents[0]!;
const codexAgent = storybookAgents.find((agent) => agent.name.toLowerCase().startsWith("codex")) ?? storybookAgents[0]!;
function StoryFrame({ title, description, children }: { title: string; description?: string; children: ReactNode }) {
return (
<main className="min-h-screen bg-background p-4 text-foreground sm:p-8">
<div className="mx-auto max-w-5xl space-y-5">
<header>
<div className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
Source-issue recovery
</div>
<h1 className="mt-1 text-2xl font-semibold">{title}</h1>
{description ? (
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">{description}</p>
) : null}
</header>
{children}
</div>
</main>
);
}
function buildAction(overrides: Partial<IssueRecoveryAction> = {}): IssueRecoveryAction {
return {
id: "00000000-0000-0000-0000-0000000000aa",
companyId: "company-storybook",
sourceIssueId: "00000000-0000-0000-0000-0000000000ff",
recoveryIssueId: null,
kind: "missing_disposition",
status: "active",
ownerType: "agent",
ownerAgentId: claudeAgent.id,
ownerUserId: null,
previousOwnerAgentId: codexAgent.id,
returnOwnerAgentId: codexAgent.id,
cause: "missing_disposition",
fingerprint: "fp",
evidence: {
summary: "Run finished without picking a disposition. The PR has tests passing on CI.",
sourceRunId: "7accd7a4-c9ca-4db2-9233-3228a037cc09",
correctiveRunId: "2606404d-3859-4142-ba37-3228a037cc09",
},
nextAction: "Choose and record a valid issue disposition without copying transcript content.",
wakePolicy: { type: "wake_owner" },
monitorPolicy: null,
attemptCount: 1,
maxAttempts: 3,
timeoutAt: null,
lastAttemptAt: "2026-04-20T11:55:00.000Z",
outcome: null,
resolutionNote: null,
resolvedAt: null,
createdAt: "2026-04-20T11:55:00.000Z",
updatedAt: "2026-04-20T11:55:00.000Z",
...overrides,
};
}
function CardPanel({ caption, action, forcedState, canFalsePositive }: {
caption: string;
action: IssueRecoveryAction;
forcedState?: React.ComponentProps<typeof IssueRecoveryActionCard>["forcedState"];
canFalsePositive?: boolean;
}) {
return (
<section className="space-y-2">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
{caption}
</div>
<IssueRecoveryActionCard
action={action}
agentMap={storybookAgentMap}
forcedState={forcedState}
onResolve={() => {}}
canFalsePositive={canFalsePositive}
/>
</section>
);
}
function AllStatesPanel() {
return (
<div className="grid gap-5 lg:grid-cols-1">
<CardPanel caption="State 1 · Recovery needed (default)" action={buildAction()} canFalsePositive />
<CardPanel
caption="State 2 · Recovery in progress"
action={buildAction({ outcome: "delegated", attemptCount: 2 })}
forcedState="in_progress"
canFalsePositive
/>
<CardPanel
caption="State 3 · Observing active run (watchdog)"
action={buildAction({
kind: "active_run_watchdog",
wakePolicy: { type: "monitor", intervalLabel: "in 4m" },
evidence: { summary: "The active run has been silent for 7 minutes. Last log: 'continuing checks…'" },
nextAction: "Observe the active run; intervene only if the silence persists past timeout.",
})}
/>
<CardPanel
caption="State 4 · Recovery escalated"
action={buildAction({
status: "escalated",
attemptCount: 3,
wakePolicy: { type: "board_escalation" },
evidence: {
summary: "Three corrective wakes failed. The recovery owner has not produced a disposition.",
sourceRunId: "7accd7a4-c9ca-4db2-9233-3228a037cc09",
},
nextAction: "Board operator: assign an invokable owner or record a manual resolution.",
})}
canFalsePositive
/>
<CardPanel
caption="State 5 · Recovery resolved"
action={buildAction({
status: "resolved",
outcome: "restored",
resolvedAt: "2026-04-20T12:01:00.000Z",
nextAction: "Issue restored to a valid disposition.",
})}
/>
</div>
);
}
function buildBlocker(
overrides: Partial<IssueRelationIssueSummary> = {},
): IssueRelationIssueSummary {
return {
id: "blocker-1",
identifier: "PAP-9065",
title: "Add full company search page",
status: "in_progress",
priority: "medium",
assigneeAgentId: claudeAgent.id,
assigneeUserId: null,
...overrides,
};
}
function BlockerNoticePanel() {
return (
<div className="space-y-4">
<IssueBlockedNotice
issueStatus="blocked"
blockers={[
buildBlocker({ activeRecoveryAction: buildAction() }),
buildBlocker({
id: "blocker-2",
identifier: "PAP-9099",
title: "Watchdog: PR review pipeline silent",
activeRecoveryAction: buildAction({ kind: "active_run_watchdog" }),
}),
buildBlocker({
id: "blocker-3",
identifier: "PAP-9073",
title: "Recovery escalated for stranded run",
status: "blocked",
activeRecoveryAction: buildAction({ status: "escalated" }),
}),
buildBlocker({
id: "blocker-4",
identifier: "PAP-9051",
title: "Bare blocker without recovery state",
}),
]}
/>
</div>
);
}
type RunCardRecoveryState = "needed" | "in_progress" | "observe_only" | "escalated";
const RUN_CARD_RECOVERY_TONE: Record<RunCardRecoveryState, { icon: typeof TriangleAlert; label: string; className: string }> = {
needed: {
icon: TriangleAlert,
label: "Recovery needed",
className: "border-amber-500/60 bg-amber-500/15 text-amber-700 dark:text-amber-300",
},
in_progress: {
icon: RefreshCw,
label: "Recovery in progress",
className: "border-sky-500/60 bg-sky-500/15 text-sky-700 dark:text-sky-300",
},
observe_only: {
icon: Eye,
label: "Observing active run",
className: "border-border bg-muted text-muted-foreground",
},
escalated: {
icon: OctagonAlert,
label: "Recovery escalated",
className: "border-red-500/60 bg-red-500/15 text-red-700 dark:text-red-300",
},
};
function ActiveRunRecoveryChip({ state }: { state: RunCardRecoveryState }) {
const tone = RUN_CARD_RECOVERY_TONE[state];
const Icon = tone.icon;
return (
<span
className={`inline-flex shrink-0 items-center gap-0.5 rounded-full border px-1.5 py-0.5 text-[10px] font-medium ${tone.className}`}
role="status"
aria-label={tone.label}
>
<Icon className="h-2.5 w-2.5" aria-hidden />
{tone.label}
</span>
);
}
function ActiveRunCardMock({
identifier,
title,
recoveryState,
}: {
identifier: string;
title: string;
recoveryState: RunCardRecoveryState;
}) {
return (
<div className="flex h-[260px] w-full max-w-[320px] flex-col overflow-hidden rounded-xl border border-cyan-500/25 bg-cyan-500/[0.04] shadow-[0_16px_40px_rgba(6,182,212,0.08)]">
<div className="border-b border-border/60 px-3 py-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-cyan-400 opacity-70" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-cyan-500" />
</span>
<span className="text-sm font-medium">CodexCoder</span>
</div>
<div className="mt-2 flex items-center gap-2 text-[11px] text-muted-foreground">
<span>Live now</span>
</div>
</div>
<span className="inline-flex items-center gap-1 rounded-full border border-border/70 bg-background/70 px-2 py-1 text-[10px] text-muted-foreground">
<ExternalLink className="h-2.5 w-2.5" />
</span>
</div>
<div className="mt-3 rounded-lg border border-border/60 bg-background/60 px-2.5 py-2 text-xs">
<span className="line-clamp-2 text-cyan-700 dark:text-cyan-300">
{identifier} - {title}
</span>
<div className="mt-1.5">
<ActiveRunRecoveryChip state={recoveryState} />
</div>
</div>
</div>
<div className="flex-1 px-3 py-2 text-[11px] text-muted-foreground">Live transcript</div>
</div>
);
}
function ActiveRunPanel() {
return (
<div className="grid gap-4 sm:grid-cols-2">
<ActiveRunCardMock
identifier="PAP-9065"
title="Add full company search page"
recoveryState="needed"
/>
<ActiveRunCardMock
identifier="PAP-9099"
title="Watchdog: PR review pipeline silent"
recoveryState="observe_only"
/>
<ActiveRunCardMock
identifier="PAP-9073"
title="Recovery escalated for stranded run"
recoveryState="escalated"
/>
<ActiveRunCardMock
identifier="PAP-9101"
title="Recovery in progress: delegated"
recoveryState="in_progress"
/>
</div>
);
}
function InboxRowPanel() {
const baseIssue = createIssue();
return (
<div className="rounded-lg border border-border/70 bg-background/80">
<IssueRow
issue={{
...baseIssue,
identifier: "PAP-9065",
title: "Add full company search page",
status: "in_progress",
activeRecoveryAction: buildAction(),
}}
/>
<IssueRow
issue={{
...baseIssue,
id: "issue-recovery-watch",
identifier: "PAP-9099",
title: "Watchdog: PR review pipeline silent",
status: "in_progress",
activeRecoveryAction: buildAction({ kind: "active_run_watchdog" }),
}}
/>
<IssueRow
issue={{
...baseIssue,
id: "issue-recovery-escalated",
identifier: "PAP-9073",
title: "Recovery escalated for stranded run",
status: "blocked",
activeRecoveryAction: buildAction({ status: "escalated" }),
}}
/>
</div>
);
}
const meta = {
title: "Paperclip/Source Issue Recovery",
component: AllStatesPanel,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof AllStatesPanel>;
export default meta;
type Story = StoryObj<typeof meta>;
export const RecoveryActionCardStates: Story = {
render: () => (
<StoryFrame
title="Recovery action card states"
description="Five states required by the source-issue recovery contract: needed, in progress, observe-only watchdog, escalated, resolved."
>
<AllStatesPanel />
</StoryFrame>
),
};
export const InboxRowChips: Story = {
render: () => (
<StoryFrame
title="Inbox row recovery chips"
description="Source rows expose recovery state inline; no synthetic sibling row appears for source-scoped recovery."
>
<InboxRowPanel />
</StoryFrame>
),
};
export const BlockerNoticeRecoveryIndicators: Story = {
render: () => (
<StoryFrame
title="Blocker notice recovery indicators"
description="Blocker chips inline a recovery indicator when the blocker has an active recovery action. Plain blockers stay clean."
>
<BlockerNoticePanel />
</StoryFrame>
),
};
export const ActiveRunPanelRecoveryChips: Story = {
render: () => (
<StoryFrame
title="Active run panel recovery chips"
description="Active run cards on the dashboard expose recovery state on the linked source issue."
>
<ActiveRunPanel />
</StoryFrame>
),
};