diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index cba3bec4ae2..230d7e38cb3 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -97,6 +97,11 @@ When an agent creates a subtask or follow-up task, its source task's feed shows a linked card at creation time with the current title, task number, and status. The Tasks tab keeps the full relationship list. +Tasks waiting between automatic checks show **Monitoring** with a blue clock. +Task lists, board columns, filters, search, recent navigation and task properties +use the same read-only display state. The task retains its execution lifecycle +status, and the monitor banner shows when the next check is due. + Automatic monitor checks pause when a task is blocked. A person can use Check now to request one check of a retained monitor through normal execution admission. Dependencies, recovery, pause, budgets and ownership still apply. Provider-usage diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 59874d54e8b..135b70dcf47 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -423,6 +423,7 @@ retain their titles and default to no generation request. - `title` text not null - `description` text null - `status` enum: `backlog | todo | in_progress | in_review | done | blocked | cancelled` +- Task reads additionally expose read-only `displayStatus`: the lifecycle status, or `monitoring` for an agent-owned scheduled ordinary monitor wait with no active execution, locks, review participant or active recovery action. Board collection/search `displayStatus` filters and counts use the same projection before pagination. This field is not persisted and cannot authorize a status transition; existing `status` filters retain their lifecycle semantics. - `priority` enum: `critical | high | medium | low` - `review_policy` nullable enum: `anyone | not_creator | human_only`; null is equivalent to `anyone` - `assignee_agent_id` uuid fk `agents.id` null diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index dd0378688f5..03d2d05ebbc 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -112,6 +112,22 @@ The issue is actively owned work. For agent-owned issues, `in_progress` should not be allowed to become a silent dead state. +### Monitoring presentation + +An agent-owned `in_progress` task displays **Monitoring** with a blue clock while +it has a persisted ordinary monitor deadline and no active run, execution or +checkout lock, review participant, or active recovery action. The persisted +status remains `in_progress`. Monitor admission, checkout, finalization, recovery, +and agent lifecycle filters keep their existing contracts. Review, blocked, +terminal, and provider-quota recovery states keep their own presentation. + +Task reads expose optional, read-only `displayStatus`. Board task collections and +search can filter by `displayStatus`; filtering happens before pagination and +counts. Existing `status` filters retain their lifecycle meaning. Monitoring is +not a manually selectable task status or a board drag target. An overdue check +retains its visible deadline warning; the label does not attest that a provider +is running or that a check succeeded. + ### `blocked` The issue cannot proceed until something external changes. diff --git a/maps/agent-access-map/tasks.md b/maps/agent-access-map/tasks.md index 7f80a1314c0..6ba136f9091 100644 --- a/maps/agent-access-map/tasks.md +++ b/maps/agent-access-map/tasks.md @@ -522,3 +522,9 @@ Evidence: `source-only` — reviewed at `3bbc2104b65caf9f7763938a01260a5673e12f7 Verification: `not-run` — Compare eligible owner/admin ordinary denied with explicit read/audit+notice; ordinary operator/agent/foreign denied, grants unchanged. Gaps: Local implicit/instance admin bypass cannot establish ordinary two-user private isolation. + +Task reads expose optional read-only `displayStatus`. `monitoring` describes a +scheduled ordinary monitor wait; it is not accepted as a writable task `status`. +Existing lifecycle `status` filters retain their meaning. Board collection and +search readers can use the separate `displayStatus` filter before pagination. +Agents keep using the existing monitor and semantic completion tools. diff --git a/maps/feature-map/tasks.md b/maps/feature-map/tasks.md index a9afa296295..2f335db00d6 100644 --- a/maps/feature-map/tasks.md +++ b/maps/feature-map/tasks.md @@ -56,6 +56,12 @@ Use New task from the global launcher, task list, or project task view. ### `task-list` +Scheduled ordinary monitor waits display **Monitoring** with a blue clock in +current task indicators, groups, board columns, and filters. Compare Monitoring +with In Progress, then start the check and verify the status returns to its +execution presentation. Retained monitors must not override blocked/review or +terminal states. Inspect due/overdue copy separately; no runner is implied. + Open `/issues` or a project’s Issues view; legacy `/tasks` links redirect. Filters → **Routine tasks** selects tasks created by routine runs. The `/issues?view=all&originKind=routine_execution` link opens that selection. diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 2d6d93331d5..28f9fe42585 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -198,6 +198,12 @@ export const ISSUE_STATUSES = [ ] as const; export type IssueStatus = (typeof ISSUE_STATUSES)[number]; +/** Read-only task presentation. Monitoring never grants execution authority. */ +export const ISSUE_DISPLAY_STATUSES = [ + "backlog", "todo", "in_progress", "monitoring", "in_review", "done", "blocked", "cancelled", +] as const; +export type IssueDisplayStatus = (typeof ISSUE_DISPLAY_STATUSES)[number]; + export const INBOX_MINE_ISSUE_STATUSES = [ "backlog", "todo", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d6a9e5cbd02..da43d193d30 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -364,6 +364,7 @@ export { AGENT_ICON_NAMES, PROJECT_ICON_NAMES, ISSUE_STATUSES, + ISSUE_DISPLAY_STATUSES, ISSUE_SUMMARIES_MAX_IDS, INBOX_MINE_ISSUE_STATUSES, INBOX_MINE_ISSUE_STATUS_FILTER, @@ -562,6 +563,7 @@ export { type AgentIconName, type ProjectIconName, type IssueStatus, + type IssueDisplayStatus, type IssuePriority, type IssueReviewPolicy, type IssueWorkMode, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 29a8008ec80..451f745db97 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -20,6 +20,7 @@ import type { IssueHarnessKind, IssueOriginKind, IssuePriority, + IssueDisplayStatus, IssueReviewPolicy, IssueRecoveryActionKind, IssueRecoveryActionOutcome, @@ -270,6 +271,7 @@ export interface AcceptedPlanDecompositionSummary extends AcceptedPlanDecomposit } export interface IssueRelationIssueSummary { + displayStatus?: IssueDisplayStatus; id: string; identifier: string | null; title: string; @@ -850,12 +852,14 @@ export type IssueChanges = Record; export type IssueSummary = Pick< Issue, - "id" | "companyId" | "title" | "identifier" | "status" | "updatedAt" + "id" | "companyId" | "title" | "identifier" | "status" | "displayStatus" | "updatedAt" > & { externalConversationState: "active" | "waiting" | null; }; export interface Issue { + /** Read-only status for task presentation; never use it for execution admission. */ + displayStatus?: IssueDisplayStatus; /** Collection projections omit task bodies and cannot seed the detail cache. */ listProjection?: "index" | "rows"; statusVersion?: number; @@ -985,6 +989,7 @@ export type CompactIssue = Pick< | "privacyRootIssueId" | "title" | "description" + | "displayStatus" | "status" | "workMode" | "priority" diff --git a/packages/shared/src/types/search.ts b/packages/shared/src/types/search.ts index 825d4d4d1c4..bc9ea562d53 100644 --- a/packages/shared/src/types/search.ts +++ b/packages/shared/src/types/search.ts @@ -1,4 +1,4 @@ -import type { IssuePriority, IssueStatus } from "../constants.js"; +import type { IssueDisplayStatus, IssuePriority, IssueStatus } from "../constants.js"; export const COMPANY_SEARCH_SCOPES = ["all", "issues", "comments", "documents", "artifacts", "agents", "projects"] as const; export type CompanySearchScope = (typeof COMPANY_SEARCH_SCOPES)[number]; @@ -38,6 +38,7 @@ export interface CompanySearchIssueSummary { identifier: string | null; title: string; status: IssueStatus; + displayStatus?: IssueDisplayStatus; externalConversationState?: "active" | "waiting" | null; priority: IssuePriority; assigneeAgentId: string | null; @@ -76,6 +77,7 @@ export interface CompanySearchResult { export interface CompanySearchFilterOptionCounts { status: Partial>; + displayStatus?: Partial>; priority: Partial>; assigneeAgentId: Record; assigneeUserId: Record; diff --git a/packages/shared/src/types/user-profile.ts b/packages/shared/src/types/user-profile.ts index d961a407485..1ac26e033a7 100644 --- a/packages/shared/src/types/user-profile.ts +++ b/packages/shared/src/types/user-profile.ts @@ -1,4 +1,4 @@ -import type { IssuePriority, IssueStatus } from "../constants.js"; +import type { IssuePriority, IssueStatus, IssueDisplayStatus } from "../constants.js"; export interface UserProfileIdentity { id: string; @@ -42,6 +42,7 @@ export interface UserProfileIssueSummary { identifier: string | null; title: string; status: IssueStatus; + displayStatus?: IssueDisplayStatus; priority: IssuePriority; assigneeAgentId: string | null; assigneeUserId: string | null; diff --git a/packages/shared/src/validators/search.ts b/packages/shared/src/validators/search.ts index 839f4cb5296..3b81dae3836 100644 --- a/packages/shared/src/validators/search.ts +++ b/packages/shared/src/validators/search.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { ISSUE_PRIORITIES, ISSUE_STATUSES } from "../constants.js"; +import { ISSUE_DISPLAY_STATUSES, ISSUE_PRIORITIES, ISSUE_STATUSES } from "../constants.js"; import { isUuidLike } from "../agent-url-key.js"; import { COMPANY_SEARCH_EXTRACT_KINDS, @@ -156,6 +156,9 @@ export const companySearchQuerySchema = z.object({ status: z.unknown() .optional() .transform((value, ctx) => parseEnumList(value, ctx, "status", ISSUE_STATUSES)), + displayStatus: z.unknown() + .optional() + .transform((value, ctx) => parseEnumList(value, ctx, "displayStatus", ISSUE_DISPLAY_STATUSES)), priority: z.unknown() .optional() .transform((value, ctx) => parseEnumList(value, ctx, "priority", ISSUE_PRIORITIES)), diff --git a/server/src/__tests__/issue-display-status.test.ts b/server/src/__tests__/issue-display-status.test.ts new file mode 100644 index 00000000000..17207b8fbb0 --- /dev/null +++ b/server/src/__tests__/issue-display-status.test.ts @@ -0,0 +1,111 @@ +import { randomUUID } from "node:crypto"; +import { eq, sql } from "drizzle-orm"; +import { expect, it } from "vitest"; +import request from "supertest"; +import { agents, heartbeatRuns, issueRecoveryActions, issues } from "@paperclipai/db"; +import { companySearchQuerySchema, createIssueSchema, PROVIDER_QUOTA_MONITOR_SERVICE_NAME } from "@paperclipai/shared"; +import { issueDisplayStatusSql } from "../services/issue-display-status.js"; +import { issueService } from "../services/issues.js"; +import { companySearchService } from "../services/company-search.js"; +import { issueRoutes } from "../routes/issues.js"; +import { + describeEmbeddedPostgres, routeApp, + seedCompanyWithBoardAccess, useEmbeddedPostgres, +} from "./helpers/route-test-harness.js"; + +describeEmbeddedPostgres("Monitoring task presentation", () => { + const ctx = useEmbeddedPostgres("paperclip-monitoring-status-", { + resetEach: async (db) => { await db.execute(sql`SET client_min_messages = warning`); await db.execute(sql`TRUNCATE TABLE companies CASCADE`); }, + }); + + async function fixture() { + const company = await seedCompanyWithBoardAccess(ctx.db, "Monitoring"); + const agentId = randomUUID(); + await ctx.db.insert(agents).values({ id: agentId, companyId: company.companyId, name: "Monitor owner", role: "engineer", status: "paused", adapterType: "process", adapterConfig: {} }); + const nextCheckAt = new Date(Date.now() + 60 * 60_000); + const issueId = randomUUID(); + await ctx.db.insert(issues).values({ + id: issueId, companyId: company.companyId, title: "Check deployment", status: "in_progress", + assigneeAgentId: agentId, createdByUserId: company.actor.userId, + monitorNextCheckAt: nextCheckAt, + executionPolicy: { mode: "normal", stages: [], commentRequired: false, monitor: { nextCheckAt: nextCheckAt.toISOString(), notes: "Check deployment" } }, + }); + return { ...company, agentId, issueId }; + } + + async function displayStatus(id: string) { + const [row] = await ctx.db.select({ status: issues.status, displayStatus: issueDisplayStatusSql() }).from(issues).where(eq(issues.id, id)); + return row; + } + + it("projects a scheduled wait consistently across detail, compact rows, summaries, search and counts", async () => { + const f = await fixture(); + const svc = issueService(ctx.db); + expect(await svc.getById(f.issueId)).toMatchObject({ status: "in_progress", displayStatus: "monitoring" }); + expect(await svc.list(f.companyId, { displayStatus: "monitoring", listProjection: "index" })).toEqual([expect.objectContaining({ id: f.issueId, status: "in_progress", displayStatus: "monitoring" })]); + expect(await svc.count(f.companyId, { displayStatus: "monitoring" })).toBe(1); + const app = routeApp(ctx.db, f.actor, issueRoutes); + const summary = await request(app).get(`/api/companies/${f.companyId}/issues/summaries?ids=${f.issueId}`).expect(200); + expect(summary.body[0]).toMatchObject({ status: "in_progress", displayStatus: "monitoring" }); + const result = await companySearchService(ctx.db).search(f.companyId, companySearchQuerySchema.parse({ q: "deployment", displayStatus: "monitoring" })); + expect(result.results[0]?.issue).toMatchObject({ id: f.issueId, status: "in_progress", displayStatus: "monitoring" }); + expect(result.filterOptionCounts.displayStatus?.monitoring).toBe(1); + expect(result.filterOptionCounts.status.in_progress).toBe(1); + // Existing agent/API lifecycle filters keep their original meaning. + expect(await svc.count(f.companyId, { status: "in_progress" })).toBe(1); + expect(createIssueSchema.safeParse({ title: "Manual monitoring", status: "monitoring" }).success).toBe(false); + }); + + it("filters before pagination and keeps In Progress separate from Monitoring", async () => { + const f = await fixture(); + await ctx.db.insert(issues).values({ companyId: f.companyId, title: "Ordinary work", status: "in_progress", assigneeAgentId: f.agentId }); + const svc = issueService(ctx.db); + expect(await svc.list(f.companyId, { displayStatus: "monitoring", limit: 1, offset: 0 })).toEqual([expect.objectContaining({ id: f.issueId })]); + expect(await svc.list(f.companyId, { displayStatus: "monitoring", limit: 1, offset: 1 })).toEqual([]); + expect(await svc.count(f.companyId, { displayStatus: "in_progress" })).toBe(1); + expect(await svc.count(f.companyId, { status: "in_progress" })).toBe(2); + }); + + it("returns to In Progress during the check and Monitoring after another yield, then Done", async () => { + const f = await fixture(); + const runId = randomUUID(); + await ctx.db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.agentId, issueId: f.issueId, status: "queued" }); + expect((await displayStatus(f.issueId)).displayStatus).toBe("in_progress"); + await ctx.db.update(heartbeatRuns).set({ status: "running" }).where(eq(heartbeatRuns.id, runId)); + await ctx.db.update(issues).set({ executionRunId: runId, checkoutRunId: runId }).where(eq(issues.id, f.issueId)); + expect((await displayStatus(f.issueId)).displayStatus).toBe("in_progress"); + await ctx.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId)); + await ctx.db.update(issues).set({ executionRunId: null, checkoutRunId: null }).where(eq(issues.id, f.issueId)); + expect((await displayStatus(f.issueId)).displayStatus).toBe("monitoring"); + await ctx.db.update(issues).set({ status: "done" }).where(eq(issues.id, f.issueId)); + expect(await displayStatus(f.issueId)).toEqual({ status: "done", displayStatus: "done" }); + }); + + it.each(["blocked", "in_review", "todo", "cancelled"])("preserves %s even with a retained monitor", async (status) => { + const f = await fixture(); + await ctx.db.update(issues).set({ status }).where(eq(issues.id, f.issueId)); + expect((await displayStatus(f.issueId)).displayStatus).toBe(status); + }); + + it("does not label cleared, consumed, orphaned or quota-recovery schedules Monitoring", async () => { + const f = await fixture(); + await ctx.db.update(issues).set({ executionState: { monitor: { status: "cleared" } } }).where(eq(issues.id, f.issueId)); + expect((await displayStatus(f.issueId)).displayStatus).toBe("in_progress"); + await ctx.db.update(issues).set({ executionState: null, monitorNextCheckAt: null }).where(eq(issues.id, f.issueId)); + expect((await displayStatus(f.issueId)).displayStatus).toBe("in_progress"); + await ctx.db.update(issues).set({ monitorNextCheckAt: new Date(), executionPolicy: { monitor: { serviceName: PROVIDER_QUOTA_MONITOR_SERVICE_NAME } } }).where(eq(issues.id, f.issueId)); + expect((await displayStatus(f.issueId)).displayStatus).toBe("in_progress"); + await ctx.db.update(issues).set({ executionPolicy: null }).where(eq(issues.id, f.issueId)); + expect((await displayStatus(f.issueId)).displayStatus).toBe("in_progress"); + }); + + it("keeps recovery and review holds visible and never writes a new lifecycle state", async () => { + const f = await fixture(); + await ctx.db.insert(issueRecoveryActions).values({ companyId: f.companyId, sourceIssueId: f.issueId, kind: "source", ownerType: "board", cause: "process_lost", fingerprint: randomUUID(), nextAction: "Inspect stopped run" }); + expect((await displayStatus(f.issueId)).displayStatus).toBe("in_progress"); + await ctx.db.execute(sql`UPDATE issue_recovery_actions SET status = 'resolved' WHERE source_issue_id = ${f.issueId}`); + expect((await displayStatus(f.issueId)).displayStatus).toBe("monitoring"); + await ctx.db.update(issues).set({ executionState: { currentParticipant: { type: "user", userId: "reviewer" } } }).where(eq(issues.id, f.issueId)); + expect((await displayStatus(f.issueId)).displayStatus).toBe("in_progress"); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 3d6af2376f9..b63d1135afa 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -8379,6 +8379,7 @@ export function issueRoutes( title: row.title, identifier: row.identifier, status: row.status as IssueSummary["status"], + displayStatus: row.displayStatus, updatedAt: row.updatedAt, externalConversationState: row.externalConversationState, })); @@ -8619,6 +8620,7 @@ export function issueRoutes( readCondition: await issueReadSqlCondition(db, req.actor), attention: attention === "blocked" ? "blocked" : undefined, status: req.query.status as string | string[] | undefined, + displayStatus: req.query.displayStatus as string | string[] | undefined, assigneeAgentId, participantAgentId: uuidFilters.participantAgentId, assigneeUserId, @@ -8869,6 +8871,7 @@ export function issueRoutes( readCondition: await issueReadSqlCondition(db, req.actor), attention: "blocked", status: req.query.status as string | string[] | undefined, + displayStatus: req.query.displayStatus as string | string[] | undefined, assigneeAgentId: req.query.assigneeAgentId as string | undefined, participantAgentId: countUuidFilters.participantAgentId, assigneeUserId: req.query.assigneeUserId as string | undefined, diff --git a/server/src/routes/user-profiles.ts b/server/src/routes/user-profiles.ts index 24ed9f9e007..4dc8a9e0109 100644 --- a/server/src/routes/user-profiles.ts +++ b/server/src/routes/user-profiles.ts @@ -1,3 +1,4 @@ +import { issueDisplayStatusSql } from "../services/issue-display-status.js"; import { Router } from "express"; import { and, desc, eq, gte, inArray, isNull, or, sql, type SQLWrapper } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; @@ -347,6 +348,7 @@ export function userProfileRoutes(db: Db) { identifier: issues.identifier, title: issues.title, status: issues.status, + displayStatus: issueDisplayStatusSql(), priority: issues.priority, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, diff --git a/server/src/services/company-search.ts b/server/src/services/company-search.ts index fb27743e0d2..17055aa3146 100644 --- a/server/src/services/company-search.ts +++ b/server/src/services/company-search.ts @@ -1,3 +1,4 @@ +import { issueDisplayStatusSql } from "./issue-display-status.js"; import { externalConversationStateSql } from "./slack-conversation-state.js"; import { and, desc, eq, gte, inArray, isNotNull, isNull, notInArray, or, sql } from "drizzle-orm"; import type { SQL } from "drizzle-orm"; @@ -21,6 +22,7 @@ import { COMPANY_ARTIFACTS_MAX_QUERY_LENGTH, ISSUE_PRIORITIES, ISSUE_STATUSES, + ISSUE_DISPLAY_STATUSES, SYSTEM_ISSUE_DOCUMENT_KEYS, type CompanyArtifact, type CompanySearchArtifactSummary, @@ -50,6 +52,7 @@ type IssueSearchRow = { title: string; description: string | null; status: string; + displayStatus: string; priority: string; assigneeAgentId: string | null; assigneeUserId: string | null; @@ -193,6 +196,7 @@ function emptySearchCounts(): Record { function emptyFilterOptionCounts(): CompanySearchFilterOptionCounts { return { status: {}, + displayStatus: {}, priority: {}, assigneeAgentId: {}, assigneeUserId: {}, @@ -224,7 +228,7 @@ function updatedWithinStart(value: string | undefined, now = new Date()): Date | } function issueOnlyFiltersActive(query: CompanySearchQuery) { - return query.status.length > 0 + return (query.status.length > 0 || query.displayStatus.length > 0) || query.priority.length > 0 || query.assigneeAgentId !== undefined || Boolean(query.assigneeUserId) @@ -236,7 +240,7 @@ function issueOnlyFiltersActive(query: CompanySearchQuery) { function activeIssueFilters(query: CompanySearchQuery): Array<{ key: CompanySearchIssueFilterKey; values: string[] }> { const filters: Array<{ key: CompanySearchIssueFilterKey; values: string[] }> = []; - if (query.status.length > 0) filters.push({ key: "status", values: query.status }); + if (query.status.length > 0 || query.displayStatus.length > 0) filters.push({ key: "status", values: [...query.status, ...query.displayStatus] }); if (query.assigneeAgentId !== undefined) filters.push({ key: "assigneeAgentId", values: [query.assigneeAgentId ?? "null"] }); if (query.assigneeUserId) filters.push({ key: "assigneeUserId", values: [query.assigneeUserId] }); if (query.projectId) filters.push({ key: "projectId", values: [query.projectId] }); @@ -251,6 +255,7 @@ function queryWithoutFilter(query: CompanySearchQuery, key: CompanySearchIssueFi return { ...query, status: key === "status" ? [] : query.status, + displayStatus: key === "status" ? [] : query.displayStatus, priority: key === "priority" ? [] : query.priority, assigneeAgentId: key === "assigneeAgentId" ? undefined : query.assigneeAgentId, assigneeUserId: key === "assigneeUserId" ? undefined : query.assigneeUserId, @@ -265,6 +270,7 @@ function queryWithoutIssueFilters(query: CompanySearchQuery): CompanySearchQuery return { ...query, status: [], + displayStatus: [], priority: [], assigneeAgentId: undefined, assigneeUserId: undefined, @@ -277,6 +283,7 @@ function queryWithoutIssueFilters(query: CompanySearchQuery): CompanySearchQuery function issueFilterConditions(companyId: string, query: CompanySearchQuery, omit?: CompanySearchIssueFilterKey): SQL[] { const conditions: SQL[] = []; + if (omit !== "status" && query.displayStatus.length) conditions.push(inArray(issueDisplayStatusSql(), query.displayStatus)); if (omit !== "status" && query.status.length > 0) { conditions.push(query.status.length === 1 ? eq(issues.status, query.status[0]!) : inArray(issues.status, query.status)); } @@ -315,6 +322,7 @@ function issueFilterConditions(companyId: string, query: CompanySearchQuery, omi // matched-issues CTE (plain columns, no drizzle table references). function matchedFacetConditions(companyId: string, query: CompanySearchQuery, omit?: CompanySearchIssueFilterKey): SQL[] { const conditions: SQL[] = []; + if (omit !== "status" && query.displayStatus.length) conditions.push(sql`m.display_status = ANY(${sqlTextArray(query.displayStatus)})`); if (omit !== "status" && query.status.length > 0) { conditions.push(sql`m.status = ANY(${sqlTextArray(query.status)})`); } @@ -447,6 +455,7 @@ function issueResult(row: IssueSearchRow, prefix: string, normalizedQuery: strin identifier: row.identifier, title: row.title, status: row.status as CompanySearchIssueSummary["status"], + displayStatus: row.displayStatus as CompanySearchIssueSummary["displayStatus"], priority: row.priority as CompanySearchIssueSummary["priority"], assigneeAgentId: row.assigneeAgentId, assigneeUserId: row.assigneeUserId, @@ -626,7 +635,7 @@ export function companySearchService(db: Db) { conditions.length > 0 ? sql`WHERE ${sql.join(conditions, sql` AND `)}` : sql``; // Count branches must match the result branch's column list; the // trailing NULLs pad the issue data columns. - const countTail = sql`, NULL::uuid, NULL::text, NULL::text, NULL::text, NULL::text, NULL::text, NULL::uuid, NULL::text, NULL::uuid, NULL::timestamptz, NULL::timestamptz, NULL::double precision, NULL::text[]`; + const countTail = sql`, NULL::uuid, NULL::text, NULL::text, NULL::text, NULL::text, NULL::text, NULL::text, NULL::uuid, NULL::text, NULL::uuid, NULL::timestamptz, NULL::timestamptz, NULL::double precision, NULL::text[]`; const branches: SQL[] = []; const wantResultRows = scopeIncludesIssues(scope) @@ -652,6 +661,7 @@ export function companySearchService(db: Db) { (SELECT issue_text.description FROM issues issue_text WHERE issue_text.id = m.id AND issue_text.company_id = ${companyId}) AS description, m.status, + m.display_status AS "displayStatus", m.priority, m.assignee_agent_id AS "assigneeAgentId", m.assignee_user_id AS "assigneeUserId", @@ -690,6 +700,7 @@ export function companySearchService(db: Db) { GROUP BY 2 `; branches.push(facetBranch("facet:status", sql`m.status`, "status")); + branches.push(facetBranch("facet:displayStatus", sql`m.display_status`, "status")); branches.push(facetBranch("facet:priority", sql`m.priority`, "priority")); branches.push(facetBranch("facet:assigneeAgentId", sql`m.assignee_agent_id`, "assigneeAgentId", [sql`m.assignee_agent_id IS NOT NULL`])); branches.push(facetBranch("facet:assigneeUserId", sql`m.assignee_user_id`, "assigneeUserId", [sql`m.assignee_user_id IS NOT NULL`])); @@ -757,6 +768,7 @@ export function companySearchService(db: Db) { title: row.title, description: row.description, status: row.status, + displayStatus: row.displayStatus, priority: row.priority, assigneeAgentId: row.assigneeAgentId, assigneeUserId: row.assigneeUserId, @@ -780,6 +792,9 @@ export function companySearchService(db: Db) { if (row.kind === "type:issue") aggregates.typeCounts.issue = count; else if (row.kind === "type:comment") aggregates.typeCounts.comment = count; else if (row.kind === "type:document") aggregates.typeCounts.document = count; + else if (row.kind === "facet:displayStatus" && row.value && (ISSUE_DISPLAY_STATUSES as readonly string[]).includes(row.value)) { + aggregates.filterOptionCounts.displayStatus![row.value as keyof NonNullable] = count; + } else if (row.kind === "facet:status" && row.value && (ISSUE_STATUSES as readonly string[]).includes(row.value)) { aggregates.filterOptionCounts.status[row.value as keyof CompanySearchFilterOptionCounts["status"]] = count; } else if (row.kind === "facet:priority" && row.value && (ISSUE_PRIORITIES as readonly string[]).includes(row.value)) { diff --git a/server/src/services/issue-display-status.ts b/server/src/services/issue-display-status.ts new file mode 100644 index 00000000000..e0f5fcc61f8 --- /dev/null +++ b/server/src/services/issue-display-status.ts @@ -0,0 +1,37 @@ +import { sql, type SQL } from "drizzle-orm"; +import { issues } from "@paperclipai/db"; +import { PROVIDER_QUOTA_MONITOR_SERVICE_NAME, type IssueDisplayStatus } from "@paperclipai/shared"; + +/** Read-only projection, shared by task reads, search, filters and counts. + * The persisted deadline is the scheduler's source of truth. Locks and recovery + * take precedence; a retained monitor alone must not disguise active work. + */ +export function issueDisplayStatusSql(): SQL { + // Drizzle removes column qualifiers in a single-table SELECT. Keep these + // outer references explicitly qualified so correlated reads cannot bind to + // the run/recovery table's own id or company_id instead. + const issueId = sql`"issues"."id"`; + const companyId = sql`"issues"."company_id"`; + return sql`CASE WHEN + ${issues.status} = 'in_progress' + AND ${issues.assigneeAgentId} IS NOT NULL AND ${issues.assigneeUserId} IS NULL + AND ${issues.monitorNextCheckAt} IS NOT NULL + AND ${issues.executionRunId} IS NULL AND ${issues.checkoutRunId} IS NULL + AND ${issues.executionState}->>'currentParticipant' IS NULL + AND COALESCE(${issues.executionState}#>>'{monitor,status}', 'scheduled') = 'scheduled' + AND ${issues.executionPolicy}->'monitor' IS NOT NULL + AND COALESCE(${issues.executionPolicy}#>>'{monitor,serviceName}', '') <> ${PROVIDER_QUOTA_MONITOR_SERVICE_NAME} + AND NOT EXISTS ( + SELECT 1 FROM heartbeat_runs display_run + WHERE display_run.company_id = ${companyId} + AND display_run.issue_id = ${issueId} + AND display_run.status IN ('queued', 'running') + ) + AND NOT EXISTS ( + SELECT 1 FROM issue_recovery_actions display_recovery + WHERE display_recovery.company_id = ${companyId} + AND display_recovery.source_issue_id = ${issueId} + AND display_recovery.status = 'active' + ) + THEN 'monitoring' ELSE ${issues.status} END`; +} diff --git a/server/src/services/issue-summaries.ts b/server/src/services/issue-summaries.ts index 485bb2250db..645ff8a86c2 100644 --- a/server/src/services/issue-summaries.ts +++ b/server/src/services/issue-summaries.ts @@ -1,3 +1,4 @@ +import { issueDisplayStatusSql } from "./issue-display-status.js"; import { and, eq, inArray, isNull, type SQL } from "drizzle-orm"; import { issues, type Db } from "@paperclipai/db"; import { ISSUE_SUMMARIES_MAX_IDS } from "@paperclipai/shared"; @@ -18,6 +19,7 @@ export async function listIssueSummaries( title: issues.title, identifier: issues.identifier, status: issues.status, + displayStatus: issueDisplayStatusSql(), updatedAt: issues.updatedAt, externalConversationState: externalConversationStateSql(), projectId: issues.projectId, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index ecdb45c43a4..b6c0a1f6e1c 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1,3 +1,5 @@ +import type { IssueDisplayStatus } from "@paperclipai/shared"; +import { issueDisplayStatusSql } from "./issue-display-status.js"; import { notifyStrandedAgentStatus, notifyActiveRunSilence } from "./live-run-recovery-notifications.js"; import { finishedIdleWake } from "./idle-work-proof.js"; import { notifyDeferredExecutionEligibility } from "./deferred-execution-notifications.js"; @@ -1900,6 +1902,7 @@ export function parseStatusFilter( export interface IssueFilters { attention?: "blocked"; status?: string | readonly string[]; + displayStatus?: string | readonly string[]; /** * Filter by assignee agent ID. * - `string` (UUID): match issues assigned to that agent. @@ -1951,7 +1954,7 @@ export interface IssueFilters { updatedSince?: string; } -type IssueRow = typeof issues.$inferSelect & { externalConversationState?: "active" | "waiting" | null }; +type IssueRow = typeof issues.$inferSelect & { displayStatus?: IssueDisplayStatus; externalConversationState?: "active" | "waiting" | null }; type IssueLabelRow = typeof labels.$inferSelect; type IssuePlanDecompositionRow = typeof issuePlanDecompositions.$inferSelect; type IssueActiveRunRow = { @@ -3603,6 +3606,7 @@ function appendBlockerAttentionEdges( } type IssueRelationSummaryRow = { + displayStatus?: IssueDisplayStatus; relatedId: string; identifier: string | null; title: string; @@ -3620,6 +3624,7 @@ function summarizeIssueRelationRow( identifier: row.identifier, title: row.title, status: row.status as IssueRelationIssueSummary["status"], + displayStatus: row.displayStatus, priority: row.priority as IssueRelationIssueSummary["priority"], assigneeAgentId: row.assigneeAgentId, assigneeUserId: row.assigneeUserId, @@ -3735,6 +3740,7 @@ async function terminalExplicitBlockersByRoot( identifier: issues.identifier, title: issues.title, status: issues.status, + displayStatus: issueDisplayStatusSql(), priority: issues.priority, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, @@ -5039,6 +5045,7 @@ const issueListSelect = { END `, status: issues.status, + displayStatus: issueDisplayStatusSql(), statusVersion: issues.statusVersion, lastStatusDecisionId: issues.lastStatusDecisionId, workMode: issues.workMode, @@ -5301,6 +5308,7 @@ async function blockedByMapForIssues( identifier: row.identifier, title: row.title, status: row.status as IssueRelationIssueSummary["status"], + displayStatus: row.displayStatus, priority: row.priority as IssueRelationIssueSummary["priority"], assigneeAgentId: row.assigneeAgentId, assigneeUserId: row.assigneeUserId, @@ -6420,6 +6428,8 @@ async function blockedInboxIssueConditions( filters?.lowTrustBoundary, ); if (lowTrustCondition) conditions.push(lowTrustCondition); + const displayStatuses = parseStatusFilter(filters?.displayStatus); + if (displayStatuses.length) conditions.push(inArray(issueDisplayStatusSql(), displayStatuses)); const statuses = parseStatusFilter(filters?.status); if (statuses.length > 0) { conditions.push( @@ -6872,7 +6882,7 @@ export function issueService(db: Db) { async function getIssueByUuid(id: string) { const row = await retryIdempotentDatabaseOperation(() => db - .select({ ...getTableColumns(issues), externalConversationState: externalConversationStateSql() }) + .select({ ...getTableColumns(issues), displayStatus: issueDisplayStatusSql(), externalConversationState: externalConversationStateSql() }) .from(issues) .where(eq(issues.id, id)) .then((rows) => rows[0] ?? null)); @@ -6883,7 +6893,7 @@ export function issueService(db: Db) { async function getIssueByIdentifier(identifier: string) { const row = await retryIdempotentDatabaseOperation(() => db - .select({ ...getTableColumns(issues), externalConversationState: externalConversationStateSql() }) + .select({ ...getTableColumns(issues), displayStatus: issueDisplayStatusSql(), externalConversationState: externalConversationStateSql() }) .from(issues) .where(eq(issues.identifier, identifier.toUpperCase())) .then((rows) => rows[0] ?? null)); @@ -7482,6 +7492,7 @@ export function issueService(db: Db) { identifier: issues.identifier, title: issues.title, status: issues.status, + displayStatus: issueDisplayStatusSql(), priority: issues.priority, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, @@ -7502,6 +7513,7 @@ export function issueService(db: Db) { identifier: issues.identifier, title: issues.title, status: issues.status, + displayStatus: issueDisplayStatusSql(), priority: issues.priority, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, @@ -8281,6 +8293,8 @@ export function issueService(db: Db) { filters?.lowTrustBoundary, ); if (lowTrustCondition) conditions.push(lowTrustCondition); + const displayStatuses = parseStatusFilter(filters?.displayStatus); + if (displayStatuses.length) conditions.push(inArray(issueDisplayStatusSql(), displayStatuses)); const statuses = parseStatusFilter(filters?.status); if (statuses.length === 1) { conditions.push(eq(issues.status, statuses[0])); @@ -8574,6 +8588,8 @@ export function issueService(db: Db) { conditions.push(nonIdleSlackIssueCondition()); } } + const displayStatuses = parseStatusFilter(filters?.displayStatus); + if (displayStatuses.length) conditions.push(inArray(issueDisplayStatusSql(), displayStatuses)); const statuses = parseStatusFilter(filters?.status); if (statuses.length === 1) conditions.push(eq(issues.status, statuses[0]!)); @@ -8895,6 +8911,7 @@ export function issueService(db: Db) { identifier: issues.identifier, title: issues.title, status: issues.status, + displayStatus: issueDisplayStatusSql(), priority: issues.priority, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, @@ -9522,6 +9539,7 @@ export function issueService(db: Db) { identifier: issues.identifier, title: issues.title, status: issues.status, + displayStatus: issueDisplayStatusSql(), priority: issues.priority, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, diff --git a/server/src/services/task-search.ts b/server/src/services/task-search.ts index 600e355217e..9f56b5a7996 100644 --- a/server/src/services/task-search.ts +++ b/server/src/services/task-search.ts @@ -1,3 +1,4 @@ +import { issueDisplayStatusSql } from "./issue-display-status.js"; import { sql, type SQL } from "drizzle-orm"; import { COMPANY_SEARCH_MAX_QUERY_LENGTH, COMPANY_SEARCH_MAX_TOKENS } from "@paperclipai/shared"; import { visibleIssueCondition } from "./issue-visibility.js"; @@ -134,7 +135,7 @@ export function taskSearchCtes(companyId: string, search: TaskSearch, includeCon // The search page fetches descriptions only for its result window. const flags = (fuzzyMatch: SQL) => sql` SELECT issues.id, issues.identifier, issues.title, - issues.status, issues.priority, issues.assignee_agent_id, issues.assignee_user_id, + issues.status, ${issueDisplayStatusSql()} AS display_status, issues.priority, issues.assignee_agent_id, issues.assignee_user_id, issues.project_id, issues.created_at, issues.updated_at, ${identExact} AS ident_exact, ${identStarts} AS ident_starts, ${phraseMatch(sql`issues.identifier`)} AS ident_phrase, diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index abfc428b39a..afe23d44d09 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -194,6 +194,7 @@ export type IssueListFilters = { projection?: "index" | "rows"; attention?: "blocked"; status?: string; + displayStatus?: string; projectId?: string; parentId?: string; assigneeAgentId?: string; @@ -229,6 +230,7 @@ function issueListSearchParams(filters?: IssueListFilters) { const params = new URLSearchParams(); if (filters?.attention) params.set("attention", filters.attention); if (filters?.status) params.set("status", filters.status); + if (filters?.displayStatus) params.set("displayStatus", filters.displayStatus); if (filters?.projectId) params.set("projectId", filters.projectId); if (filters?.parentId) params.set("parentId", filters.parentId); if (filters?.parentIds) params.set("parentIds", filters.parentIds.join(",")); diff --git a/ui/src/api/search.ts b/ui/src/api/search.ts index 5715d8685b8..b9e97435685 100644 --- a/ui/src/api/search.ts +++ b/ui/src/api/search.ts @@ -1,4 +1,4 @@ -import type { CompanySearchResponse, CompanySearchScope, CompanySearchSort, IssuePriority, IssueStatus } from "@paperclipai/shared"; +import type { CompanySearchResponse, CompanySearchScope, CompanySearchSort, IssuePriority, IssueDisplayStatus } from "@paperclipai/shared"; import { api } from "./client"; export interface CompanySearchParams { @@ -6,7 +6,7 @@ export interface CompanySearchParams { scope?: CompanySearchScope; limit?: number; offset?: number; - status?: IssueStatus[]; + status?: IssueDisplayStatus[]; priority?: IssuePriority[]; assigneeAgentId?: string | null; assigneeUserId?: string; @@ -28,7 +28,7 @@ export const searchApi = { if (params.scope) search.set("scope", params.scope); if (params.limit !== undefined) search.set("limit", String(params.limit)); if (params.offset !== undefined) search.set("offset", String(params.offset)); - appendMulti(search, "status", params.status); + appendMulti(search, "displayStatus", params.status); appendMulti(search, "priority", params.priority); if (params.assigneeAgentId !== undefined) search.set("assigneeAgentId", params.assigneeAgentId ?? "null"); if (params.assigneeUserId !== undefined) search.set("assigneeUserId", params.assigneeUserId); diff --git a/ui/src/components/BlockedInboxView.tsx b/ui/src/components/BlockedInboxView.tsx index 0cb145a359f..fb854d377df 100644 --- a/ui/src/components/BlockedInboxView.tsx +++ b/ui/src/components/BlockedInboxView.tsx @@ -376,7 +376,7 @@ function BlockedInboxRow({ showDivider={presentation === "legacy"} statusSlot={presentation === "task" ? showStatusColumn - ? + ? :