mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 00:56:52 +01:00
## 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 on45c4ababc5, 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 commit45c4ababc5: 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>
114 lines
4.9 KiB
TypeScript
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,
|
|
};
|
|
}
|