Files
PaperClipAI/ui/src/lib/task-status-presentation.ts
T
DottaandPaperclip 4696ff4990 fix: reserve agent In Progress display for active runs (#19453)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task status must tell operators whether an agent is working or
waiting.
> - A task can retain its in-progress lifecycle status after its run
stops.
> - The shared read projection only handled scheduled monitoring waits.
> - The UI also ignored other projected statuses for in-progress tasks.
> - This pull request requires an active task run for the In Progress
display.
> - Operators can see waits without changing execution or recovery
authority.

## Linked Issues or Issue Description

**What happened?**

An agent task still showed In Progress after its run ended with an
access failure and left a pending connection request. Stale execution
locks and saved lifecycle state could show the same false activity.

**Expected behavior**

An agent task shows In Progress only with a task-linked queued or
running run. Otherwise, it shows Blocked for recovery, In Review for a
pending response or review, Monitoring for a scheduled check, or Todo
for remaining work. Human ownership keeps its selected status.

**Steps to reproduce**

1. Create an agent-owned task with an in-progress lifecycle status.
2. End its run and retain a pending human request.
3. Read the task, its summary, and search results. Before this change,
they still show In Progress.

## What Changed

- Derive visible activity from company-scoped, task-linked runs in the
existing shared SQL projection.
- Project inactive tasks to existing waiting or todo states. Preserve
lifecycle status, locks, admission, and recovery.
- Make the shared UI resolver use the projection while preserving
explicit and optimistic status changes.
- Cover stopped runs, stale locks, human waits, company isolation,
monitoring, filters, counts, search, and UI labels.
- Document the presentation rule in the product and execution contracts.

## Verification

- Red before the server fix: 7 new regression cases failed because they
returned in_progress. The 10 existing cases passed.
- Green after the server fix: 20 cases passed in
issue-display-status.test.ts, using embedded Postgres and real task
APIs/search. Seven nearby active-question cases also passed.
- Review regression: dismissed requests, historical questions, and
returned review work each failed before the follow-up fix. All three now
pass using the existing active-interaction rule and pending review
state.
- Red before the UI fix: 4 label cases failed. Five additional safety
cases caught stale projections hiding explicit transitions.
- Green after the UI fix: 43 tests passed across
task-status-presentation, issue-filters, and issue-detail-subissues.
- pnpm -r typecheck passed. All UI token gates and synchronization
checks passed.
- pnpm build passed. Server typecheck and build passed again after the
review fixes.
- Full CI passed on 45c4ababc5, including
typecheck, build, general/serialized tests, Runner tests, and browser
checks. The final rollup has 55 successful checks, one neutral check,
and three configured skips.
- The local pnpm test:run command passed all 1,931 chat integration
tests. Its remaining duplicate full-suite run was stopped once full CI
passed on the same commit. This is not a claim that the complete local
command finished.
- Greptile reviewed the final commit at 5/5. Both prior findings have
regression coverage and all review threads are resolved.
- Live acceptance at commit 45c4ababc5: started a fresh isolated local
instance, created an inactive agent task, and saved a pending human
request. API reads changed from Todo to In Review. An initial readiness
probe ran before startup finished; the doctor then verified the exact
serving revision before acceptance. Chrome showed In Review in the task
header, breadcrumb, and sidebar. Stored lifecycle status remained
in_progress. The instance was stopped after verification.
- The reported stopped state was inspected in the live UI. The fix is
not deployed there. Local API tests reproduce the failure shape without
replaying the real deployment task.

## Risks

- This changes visible status and display-status filter membership for
inactive agent tasks. Stored lifecycle filters keep their existing
meaning.
- The projection uses existing indexes on task runs, recovery actions,
and interactions. It adds no worker, timer, schema change, or execution
action.
- A queued run counts as active admission. Human-owned tasks retain
explicit ownership status.

## Model Used

- OpenAI GPT-6 through Codex, with reasoning, repository tools, and code
execution. A more specific model ID and context-window size are not
exposed in this session.

## 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-06 07:59:38 -06:00

114 lines
4.9 KiB
TypeScript

import type { IssueBlockerAttention } from "@paperclipai/shared";
/**
* Everything a task status surface needs to pick its glyph and label. Every
* view (sidebar rows, task header, properties, status picker, lists, inbox,
* pills, search) resolves through {@link resolveTaskStatusPresentation}, so one
* underlying state always renders the same icon and label (BUG-07).
*/
export interface TaskStatusPresentationInput {
status: string;
/** Server-derived presentation; mutations still use the canonical status. */
displayStatus?: string | null;
externalConversationState?: "active" | "waiting" | null;
blockerAttention?: IssueBlockerAttention | null;
}
export interface TaskStatusPresentation {
/** Key into the unified StatusGlyph icon/colour tables. */
glyphStatus: string;
/** Short visible label ("Blocked", "Idle", "Monitoring", …). */
label: string;
/** Full accessible/tooltip copy, including the blocked reason. */
ariaLabel: string;
}
export function taskStatusLabel(status: string): string {
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null | undefined) {
if (!blockerAttention || blockerAttention.state === "none") return "Blocked";
if (blockerAttention.reason === "active_child") {
const count = blockerAttention.coveredBlockerCount;
if (count === 1 && blockerAttention.sampleBlockerIdentifier) {
return `Blocked · waiting on active sub-task ${blockerAttention.sampleBlockerIdentifier}`;
}
if (count === 1) return "Blocked · waiting on 1 active sub-task";
return `Blocked · waiting on ${count} active sub-tasks`;
}
if (blockerAttention.reason === "active_dependency") {
const count = blockerAttention.coveredBlockerCount;
if (count === 1 && blockerAttention.sampleBlockerIdentifier) {
return `Blocked · covered by active dependency ${blockerAttention.sampleBlockerIdentifier}`;
}
if (count === 1) return "Blocked · covered by 1 active dependency";
return `Blocked · covered by ${count} active dependencies`;
}
if (blockerAttention.reason === "stalled_review") {
const count = blockerAttention.stalledBlockerCount;
const leaf = blockerAttention.sampleStalledBlockerIdentifier ?? blockerAttention.sampleBlockerIdentifier;
if (count === 1 && leaf) return `Blocked · review stalled on ${leaf}`;
if (count === 1) return "Blocked · review stalled with no clear next step";
return `Blocked · ${count} reviews stalled with no clear next step`;
}
if (blockerAttention.reason === "attention_required") {
const count = blockerAttention.attentionBlockerCount || blockerAttention.unresolvedBlockerCount;
const attentionCopy = `${count} ${count === 1 ? "blocker needs" : "blockers need"} attention`;
const coveredCount = blockerAttention.coveredBlockerCount;
if (coveredCount > 0) {
return `Blocked · ${attentionCopy}; ${coveredCount} covered by active work`;
}
return `Blocked · ${attentionCopy}`;
}
return "Blocked";
}
/**
* The one state → glyph/label mapping:
* - in_review whose external conversation is waiting → "Idle"
* - in_progress presented as monitoring → "Monitoring" (clock)
* - blocked and covered by active work → the blue "in queue" glyph, with the
* blocked reason on the accessible label
*/
export function resolveTaskStatusPresentation(input: TaskStatusPresentationInput): TaskStatusPresentation {
const { status, displayStatus, externalConversationState, blockerAttention } = input;
// A saved/optimistic transition must win over the previous read projection.
const projectedStatus = status === "in_progress" ? (displayStatus ?? status) : status;
const visibleStatus = projectedStatus === "in_review" && externalConversationState === "waiting"
? "idle"
: projectedStatus;
const isCoveredBlocked = projectedStatus === "blocked" && blockerAttention?.state === "covered";
return {
glyphStatus: isCoveredBlocked ? "in_queue" : visibleStatus,
label: taskStatusLabel(visibleStatus),
ariaLabel: projectedStatus === "blocked" ? blockedAttentionLabel(blockerAttention) : taskStatusLabel(visibleStatus),
};
}
type TaskStatusSource = {
status: string;
displayStatus?: string | null;
externalConversationState?: "active" | "waiting" | null;
blockerAttention?: IssueBlockerAttention | null;
};
/**
* Pick every status-presentation field from a task record so a view cannot
* silently drop one (e.g. `blockerAttention`) and render a different glyph
* than the task pane. Spread into `<StatusIcon {...taskStatusIconProps(task)} />`.
*/
export function taskStatusIconProps(task: TaskStatusSource): TaskStatusPresentationInput {
return {
status: task.status,
displayStatus: task.displayStatus ?? null,
externalConversationState: task.externalConversationState ?? null,
blockerAttention: task.blockerAttention ?? null,
};
}