diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 728fe01f0e2..4ec8f3e0d88 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -325,11 +325,20 @@ type PaperclipWakeBlockerSummary = { priority: string | null; }; +type PaperclipWakeTreeHoldSummary = { + holdId: string | null; + rootIssueId: string | null; + mode: string | null; + reason: string | null; +}; + type PaperclipWakePayload = { reason: string | null; issue: PaperclipWakeIssue | null; checkedOutByHarness: boolean; dependencyBlockedInteraction: boolean; + treeHoldInteraction: boolean; + activeTreeHold: PaperclipWakeTreeHoldSummary | null; unresolvedBlockerIssueIds: string[]; unresolvedBlockerSummaries: PaperclipWakeBlockerSummary[]; executionStage: PaperclipWakeExecutionStage | null; @@ -435,6 +444,16 @@ function normalizePaperclipWakeBlockerSummary(value: unknown): PaperclipWakeBloc return { id, identifier, title, status, priority }; } +function normalizePaperclipWakeTreeHoldSummary(value: unknown): PaperclipWakeTreeHoldSummary | null { + const hold = parseObject(value); + const holdId = asString(hold.holdId, "").trim() || null; + const rootIssueId = asString(hold.rootIssueId, "").trim() || null; + const mode = asString(hold.mode, "").trim() || null; + const reason = asString(hold.reason, "").trim() || null; + if (!holdId && !rootIssueId && !mode && !reason) return null; + return { holdId, rootIssueId, mode, reason }; +} + function normalizePaperclipWakeExecutionPrincipal(value: unknown): PaperclipWakeExecutionPrincipal | null { const principal = parseObject(value); const typeRaw = asString(principal.type, "").trim().toLowerCase(); @@ -511,7 +530,8 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl .filter((entry): entry is PaperclipWakeBlockerSummary => Boolean(entry)) : []; - if (comments.length === 0 && commentIds.length === 0 && childIssueSummaries.length === 0 && unresolvedBlockerIssueIds.length === 0 && unresolvedBlockerSummaries.length === 0 && !executionStage && !continuationSummary && !livenessContinuation && !normalizePaperclipWakeIssue(payload.issue)) { + const activeTreeHold = normalizePaperclipWakeTreeHoldSummary(payload.activeTreeHold); + if (comments.length === 0 && commentIds.length === 0 && childIssueSummaries.length === 0 && unresolvedBlockerIssueIds.length === 0 && unresolvedBlockerSummaries.length === 0 && !activeTreeHold && !executionStage && !continuationSummary && !livenessContinuation && !normalizePaperclipWakeIssue(payload.issue)) { return null; } @@ -520,6 +540,8 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl issue: normalizePaperclipWakeIssue(payload.issue), checkedOutByHarness: asBoolean(payload.checkedOutByHarness, false), dependencyBlockedInteraction: asBoolean(payload.dependencyBlockedInteraction, false), + treeHoldInteraction: asBoolean(payload.treeHoldInteraction, false), + activeTreeHold, unresolvedBlockerIssueIds, unresolvedBlockerSummaries, executionStage, @@ -614,6 +636,14 @@ export function renderPaperclipWakePrompt( lines.push(`- unresolved blocker issue ids: ${normalized.unresolvedBlockerIssueIds.join(", ")}`); } } + if (normalized.treeHoldInteraction) { + lines.push("- tree-hold interaction: yes"); + lines.push("- execution scope: respond or triage the human comment; the subtree remains paused until an explicit resume action"); + if (normalized.activeTreeHold) { + const hold = normalized.activeTreeHold; + lines.push(`- active tree hold: ${hold.holdId ?? "unknown"}${hold.rootIssueId ? ` rooted at ${hold.rootIssueId}` : ""}${hold.mode ? ` (${hold.mode})` : ""}`); + } + } if (normalized.missingCount > 0) { lines.push(`- omitted comments: ${normalized.missingCount}`); } diff --git a/server/src/__tests__/heartbeat-dependency-scheduling.test.ts b/server/src/__tests__/heartbeat-dependency-scheduling.test.ts index 0108e3626b7..c08c2008334 100644 --- a/server/src/__tests__/heartbeat-dependency-scheduling.test.ts +++ b/server/src/__tests__/heartbeat-dependency-scheduling.test.ts @@ -346,7 +346,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () = expect(blockedWakeRequestCount).toBeGreaterThanOrEqual(2); }); - it("suppresses descendant wakeups while allowing root course-correction comment wakes under a pause hold", async () => { + it("suppresses normal wakeups while allowing comment interaction wakes under a pause hold", async () => { const companyId = randomUUID(); const agentId = randomUUID(); const rootIssueId = randomUUID(); @@ -424,31 +424,32 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () = .then((rows) => rows[0] ?? null); expect(skippedWake).toMatchObject({ status: "skipped", reason: "issue_tree_hold_active" }); - const rootCommentWake = await heartbeat.wakeup(agentId, { + const childCommentWake = await heartbeat.wakeup(agentId, { source: "automation", triggerDetail: "system", reason: "issue_commented", - payload: { issueId: rootIssueId, commentId: randomUUID() }, - contextSnapshot: { issueId: rootIssueId, wakeReason: "issue_commented" }, + payload: { issueId: childIssueId, commentId: randomUUID() }, + contextSnapshot: { issueId: childIssueId, wakeReason: "issue_commented" }, }); - expect(rootCommentWake).not.toBeNull(); - const rootRun = await db + expect(childCommentWake).not.toBeNull(); + const childRun = await db .select({ contextSnapshot: heartbeatRuns.contextSnapshot }) .from(heartbeatRuns) - .where(eq(heartbeatRuns.id, rootCommentWake!.id)) + .where(eq(heartbeatRuns.id, childCommentWake!.id)) .then((rows) => rows[0] ?? null); - expect(rootRun?.contextSnapshot).toMatchObject({ + expect(childRun?.contextSnapshot).toMatchObject({ + treeHoldInteraction: true, activeTreeHold: { holdId: hold.id, rootIssueId, mode: "pause", - courseCorrectionOnly: true, + interaction: true, }, }); }); - it("suppresses root comment wakes when the active hold is full_pause", async () => { + it("allows comment interaction wakes when a legacy hold has a full_pause note", async () => { const companyId = randomUUID(); const agentId = randomUUID(); const rootIssueId = randomUUID(); @@ -500,15 +501,19 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () = contextSnapshot: { issueId: rootIssueId, wakeReason: "issue_commented" }, }); - expect(rootCommentWake).toBeNull(); - const skippedWake = await db - .select({ - status: agentWakeupRequests.status, - reason: agentWakeupRequests.reason, - }) - .from(agentWakeupRequests) - .where(sql`${agentWakeupRequests.payload} ->> 'issueId' = ${rootIssueId}`) + expect(rootCommentWake).not.toBeNull(); + const rootRun = await db + .select({ contextSnapshot: heartbeatRuns.contextSnapshot }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, rootCommentWake!.id)) .then((rows) => rows[0] ?? null); - expect(skippedWake).toMatchObject({ status: "skipped", reason: "issue_tree_hold_active" }); + expect(rootRun?.contextSnapshot).toMatchObject({ + treeHoldInteraction: true, + activeTreeHold: { + rootIssueId, + mode: "pause", + interaction: true, + }, + }); }); }); diff --git a/server/src/__tests__/issue-tree-control-service.test.ts b/server/src/__tests__/issue-tree-control-service.test.ts index a9bf308a12b..d9db94724bc 100644 --- a/server/src/__tests__/issue-tree-control-service.test.ts +++ b/server/src/__tests__/issue-tree-control-service.test.ts @@ -333,12 +333,13 @@ describeEmbeddedPostgres("issueTreeControlService", () => { }); }); - it("blocks held descendant checkout but allows root comment course-correction checkout", async () => { + it("blocks normal checkout but allows comment interaction checkout under a pause hold", async () => { const companyId = randomUUID(); const agentId = randomUUID(); const rootIssueId = randomUUID(); const childIssueId = randomUUID(); const rootRunId = randomUUID(); + const childRunId = randomUUID(); await db.insert(companies).values({ id: companyId, @@ -376,15 +377,26 @@ describeEmbeddedPostgres("issueTreeControlService", () => { assigneeAgentId: agentId, }, ]); - await db.insert(heartbeatRuns).values({ - id: rootRunId, - companyId, - agentId, - invocationSource: "automation", - triggerDetail: "system", - status: "queued", - contextSnapshot: { issueId: rootIssueId, wakeReason: "issue_commented" }, - }); + await db.insert(heartbeatRuns).values([ + { + id: rootRunId, + companyId, + agentId, + invocationSource: "automation", + triggerDetail: "system", + status: "queued", + contextSnapshot: { issueId: rootIssueId, wakeReason: "issue_commented", commentId: randomUUID() }, + }, + { + id: childRunId, + companyId, + agentId, + invocationSource: "automation", + triggerDetail: "system", + status: "queued", + contextSnapshot: { issueId: childIssueId, wakeReason: "issue_commented", commentId: randomUUID() }, + }, + ]); const treeSvc = issueTreeControlService(db); await treeSvc.createHold(companyId, rootIssueId, { @@ -402,6 +414,10 @@ describeEmbeddedPostgres("issueTreeControlService", () => { }), }); + const checkedOutChild = await issueSvc.checkout(childIssueId, agentId, ["todo"], childRunId); + expect(checkedOutChild.status).toBe("in_progress"); + expect(checkedOutChild.checkoutRunId).toBe(childRunId); + const checkedOutRoot = await issueSvc.checkout(rootIssueId, agentId, ["todo"], rootRunId); expect(checkedOutRoot.status).toBe("in_progress"); expect(checkedOutRoot.checkoutRunId).toBe(rootRunId); @@ -429,12 +445,8 @@ describeEmbeddedPostgres("issueTreeControlService", () => { actor: { actorType: "user", actorId: "board-user", userId: "board-user" }, }); - await expect(issueSvc.checkout(rootIssueId, agentId, ["todo"], rootRunId)).rejects.toMatchObject({ - status: 409, - details: expect.objectContaining({ - rootIssueId, - mode: "pause", - }), - }); + const checkedOutLegacyFullPauseRoot = await issueSvc.checkout(rootIssueId, agentId, ["todo"], rootRunId); + expect(checkedOutLegacyFullPauseRoot.status).toBe("in_progress"); + expect(checkedOutLegacyFullPauseRoot.checkoutRunId).toBe(rootRunId); }); }); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 7fee1317dd1..bba7760e128 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -80,7 +80,7 @@ import { sanitizeRuntimeServiceBaseEnv, } from "./workspace-runtime.js"; import { issueService } from "./issues.js"; -import { issueTreeControlService, isCourseCorrectionPauseReleasePolicy } from "./issue-tree-control.js"; +import { issueTreeControlService } from "./issue-tree-control.js"; import { getIssueContinuationSummaryDocument, refreshIssueContinuationSummary, @@ -168,11 +168,6 @@ function resolveCodexTransientFallbackMode(attempt: number): CodexTransientFallb return "fresh_session_safer_invocation"; } const RUNNING_ISSUE_WAKE_REASONS_REQUIRING_FOLLOWUP = new Set(["approval_approved"]); -const TREE_HOLD_COURSE_CORRECTION_WAKE_REASONS = new Set([ - "issue_commented", - "issue_reopened_via_comment", - "issue_comment_mentioned", -]); const SESSIONED_LOCAL_ADAPTERS = new Set([ "claude_local", "codex_local", @@ -1257,17 +1252,17 @@ function shouldRequireIssueCommentForWake( ); } -const BLOCKED_INTERACTION_WAKE_REASONS = new Set([ +const ISSUE_INTERACTION_WAKE_REASONS = new Set([ "issue_commented", "issue_reopened_via_comment", "issue_comment_mentioned", ]); -function allowsBlockedIssueInteractionWake( +function allowsIssueInteractionWake( contextSnapshot: Record | null | undefined, ) { const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason); - if (!wakeReason || !BLOCKED_INTERACTION_WAKE_REASONS.has(wakeReason)) return false; + if (!wakeReason || !ISSUE_INTERACTION_WAKE_REASONS.has(wakeReason)) return false; return Boolean(deriveCommentId(contextSnapshot, null)); } @@ -1636,6 +1631,8 @@ async function buildPaperclipWakePayload(input: { : null, checkedOutByHarness: input.contextSnapshot[PAPERCLIP_HARNESS_CHECKOUT_KEY] === true, dependencyBlockedInteraction: input.contextSnapshot.dependencyBlockedInteraction === true, + treeHoldInteraction: input.contextSnapshot.treeHoldInteraction === true, + activeTreeHold: parseObject(input.contextSnapshot.activeTreeHold), unresolvedBlockerIssueIds: Array.isArray(input.contextSnapshot.unresolvedBlockerIssueIds) ? input.contextSnapshot.unresolvedBlockerIssueIds.filter((value): value is string => typeof value === "string" && value.length > 0) : [], @@ -3507,12 +3504,8 @@ export function heartbeatService(db: Db) { const issueId = readNonEmptyString(context.issueId); if (issueId) { const activePauseHold = await treeControlSvc.getActivePauseHoldGate(run.companyId, issueId); - const wakeReason = readNonEmptyString(context.wakeReason) ?? readNonEmptyString(context.reason); - const isRootCourseCorrectionWake = - activePauseHold?.isRoot === true && - isCourseCorrectionPauseReleasePolicy(activePauseHold.releasePolicy) && - Boolean(wakeReason && TREE_HOLD_COURSE_CORRECTION_WAKE_REASONS.has(wakeReason)); - if (activePauseHold && !isRootCourseCorrectionWake) { + const treeHoldInteractionWake = activePauseHold && allowsIssueInteractionWake(context); + if (activePauseHold && !treeHoldInteractionWake) { await cancelRunInternal(run.id, "Cancelled because issue is held by an active subtree pause hold"); await logActivity(db, { companyId: run.companyId, @@ -3536,7 +3529,7 @@ export function heartbeatService(db: Db) { const dependencyReadiness = await issuesSvc.listDependencyReadiness(run.companyId, [issueId]); const unresolvedBlockerCount = dependencyReadiness.get(issueId)?.unresolvedBlockerCount ?? 0; - if (unresolvedBlockerCount > 0 && !allowsBlockedIssueInteractionWake(context)) { + if (unresolvedBlockerCount > 0 && !allowsIssueInteractionWake(context)) { logger.debug({ runId: run.id, issueId, unresolvedBlockerCount }, "claimQueuedRun: skipping blocked run"); return null; } @@ -6119,12 +6112,8 @@ export function heartbeatService(db: Db) { const deferredPayload = parseObject(deferred.payload); const deferredContextSeed = parseObject(deferredPayload[DEFERRED_WAKE_CONTEXT_KEY]); const activePauseHold = await treeControlSvc.getActivePauseHoldGate(issue.companyId, issue.id); - const deferredWakeReason = readNonEmptyString(deferredContextSeed.wakeReason) ?? readNonEmptyString(deferred.reason); - const canPromoteRootCourseCorrection = - activePauseHold?.isRoot === true && - isCourseCorrectionPauseReleasePolicy(activePauseHold.releasePolicy) && - Boolean(deferredWakeReason && TREE_HOLD_COURSE_CORRECTION_WAKE_REASONS.has(deferredWakeReason)); - if (activePauseHold && !canPromoteRootCourseCorrection) { + const treeHoldInteractionWake = activePauseHold && allowsIssueInteractionWake(deferredContextSeed); + if (activePauseHold && !treeHoldInteractionWake) { await tx .update(agentWakeupRequests) .set({ @@ -6139,14 +6128,14 @@ export function heartbeatService(db: Db) { const promotedContextSeed: Record = { ...deferredContextSeed }; if (activePauseHold) { - const courseCorrectionOnly = isCourseCorrectionPauseReleasePolicy(activePauseHold.releasePolicy); + promotedContextSeed.treeHoldInteraction = true; promotedContextSeed.activeTreeHold = { holdId: activePauseHold.holdId, rootIssueId: activePauseHold.rootIssueId, mode: activePauseHold.mode, reason: activePauseHold.reason, releasePolicy: activePauseHold.releasePolicy, - courseCorrectionOnly, + interaction: true, }; } const deferredCommentIds = extractWakeCommentIds(deferredContextSeed); @@ -6540,13 +6529,9 @@ export function heartbeatService(db: Db) { if (issueId) { const activePauseHold = await treeControlSvc.getActivePauseHoldGate(agent.companyId, issueId); if (activePauseHold) { - const wakeReason = readNonEmptyString(enrichedContextSnapshot.wakeReason) ?? reason; - const isRootCourseCorrectionWake = - activePauseHold.isRoot && - isCourseCorrectionPauseReleasePolicy(activePauseHold.releasePolicy) && - Boolean(wakeReason && TREE_HOLD_COURSE_CORRECTION_WAKE_REASONS.has(wakeReason)); + const treeHoldInteractionWake = allowsIssueInteractionWake(enrichedContextSnapshot); - if (!isRootCourseCorrectionWake) { + if (!treeHoldInteractionWake) { await writeSkippedRequest("issue_tree_hold_active"); await logActivity(db, { companyId: agent.companyId, @@ -6569,13 +6554,14 @@ export function heartbeatService(db: Db) { return null; } + enrichedContextSnapshot.treeHoldInteraction = true; enrichedContextSnapshot.activeTreeHold = { holdId: activePauseHold.holdId, rootIssueId: activePauseHold.rootIssueId, mode: activePauseHold.mode, reason: activePauseHold.reason, releasePolicy: activePauseHold.releasePolicy, - courseCorrectionOnly: isCourseCorrectionPauseReleasePolicy(activePauseHold.releasePolicy), + interaction: true, }; } } @@ -6697,7 +6683,7 @@ export function heartbeatService(db: Db) { const blockedInteractionWake = dependencyReadiness && !dependencyReadiness.isDependencyReady && - allowsBlockedIssueInteractionWake(enrichedContextSnapshot); + allowsIssueInteractionWake(enrichedContextSnapshot); if (blockedInteractionWake) { enrichedContextSnapshot.dependencyBlockedInteraction = true; diff --git a/server/src/services/issue-tree-control.ts b/server/src/services/issue-tree-control.ts index eb21d796d62..c94d59aeacc 100644 --- a/server/src/services/issue-tree-control.ts +++ b/server/src/services/issue-tree-control.ts @@ -77,13 +77,6 @@ function normalizeReleasePolicy( return releasePolicy ?? DEFAULT_RELEASE_POLICY; } -export function isCourseCorrectionPauseReleasePolicy( - releasePolicy: IssueTreeHoldReleasePolicy | null | undefined, -) { - const note = typeof releasePolicy?.note === "string" ? releasePolicy.note.trim().toLowerCase() : ""; - return note !== "full_pause"; -} - function coerceIssueStatus(status: string): IssueStatus { return ISSUE_STATUSES.includes(status as IssueStatus) ? (status as IssueStatus) : "backlog"; } diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 79dde01b71c..d0a043af218 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -38,7 +38,6 @@ import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallbac import { getDefaultCompanyGoal } from "./goals.js"; import { issueTreeControlService, - isCourseCorrectionPauseReleasePolicy, type ActiveIssueTreePauseHoldGate, } from "./issue-tree-control.js"; @@ -50,7 +49,7 @@ const ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE = 500; export const MAX_CHILD_ISSUES_CREATED_BY_HELPER = 25; const MAX_CHILD_COMPLETION_SUMMARIES = 20; const CHILD_COMPLETION_SUMMARY_BODY_MAX_CHARS = 500; -const TREE_HOLD_COURSE_CORRECTION_WAKE_REASONS = new Set([ +const ISSUE_INTERACTION_WAKE_REASONS = new Set([ "issue_commented", "issue_reopened_via_comment", "issue_comment_mentioned", @@ -87,6 +86,18 @@ function readStringFromRecord(record: unknown, key: string) { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +function readLatestWakeCommentId(record: unknown) { + if (!record || typeof record !== "object") return null; + const value = (record as Record).wakeCommentIds; + if (Array.isArray(value)) { + const latest = value + .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + .at(-1); + if (latest) return latest.trim(); + } + return readStringFromRecord(record, "wakeCommentId") ?? readStringFromRecord(record, "commentId"); +} + export interface IssueFilters { status?: string; assigneeAgentId?: string; @@ -941,12 +952,12 @@ export function issueService(db: Db) { } } - async function isRootCourseCorrectionCheckoutAllowed( + async function isTreeHoldInteractionCheckoutAllowed( companyId: string, checkoutRunId: string | null, - gate: ActiveIssueTreePauseHoldGate, + _gate: ActiveIssueTreePauseHoldGate, ) { - if (!gate.isRoot || !checkoutRunId || !isCourseCorrectionPauseReleasePolicy(gate.releasePolicy)) return false; + if (!checkoutRunId) return false; const run = await db .select({ contextSnapshot: heartbeatRuns.contextSnapshot }) .from(heartbeatRuns) @@ -955,7 +966,11 @@ export function issueService(db: Db) { const wakeReason = readStringFromRecord(run?.contextSnapshot, "wakeReason") ?? readStringFromRecord(run?.contextSnapshot, "reason"); - return Boolean(wakeReason && TREE_HOLD_COURSE_CORRECTION_WAKE_REASONS.has(wakeReason)); + return Boolean( + wakeReason && + ISSUE_INTERACTION_WAKE_REASONS.has(wakeReason) && + readLatestWakeCommentId(run?.contextSnapshot), + ); } async function assertAssignableUser(companyId: string, userId: string) { @@ -2228,7 +2243,7 @@ export function issueService(db: Db) { const activePauseHold = await treeControlSvc.getActivePauseHoldGate(issueCompany.companyId, id); if ( activePauseHold && - !(await isRootCourseCorrectionCheckoutAllowed(issueCompany.companyId, checkoutRunId, activePauseHold)) + !(await isTreeHoldInteractionCheckoutAllowed(issueCompany.companyId, checkoutRunId, activePauseHold)) ) { throw conflict("Issue checkout blocked by active subtree pause hold", { issueId: id, diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 682bdfa267d..3b52e52addd 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { Issue, IssueTreeControlPreview, IssueTreeHold } from "@paperclipai/shared"; +import type { Agent, Issue, IssueTreeControlPreview, IssueTreeHold } from "@paperclipai/shared"; import { act, type ButtonHTMLAttributes, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -273,11 +273,13 @@ vi.mock("@/components/ui/popover", () => ({ vi.mock("@/components/ui/dialog", () => ({ Dialog: ({ children, open }: { children?: ReactNode; open?: boolean }) => (open ?
{children}
: null), - DialogContent: ({ children }: { children?: ReactNode }) =>
{children}
, - DialogDescription: ({ children }: { children?: ReactNode }) =>

{children}

, - DialogFooter: ({ children }: { children?: ReactNode }) =>
{children}
, - DialogHeader: ({ children }: { children?: ReactNode }) =>
{children}
, - DialogTitle: ({ children }: { children?: ReactNode }) =>

{children}

, + DialogContent: ({ children, className }: { children?: ReactNode; className?: string }) => ( +
{children}
+ ), + DialogDescription: ({ children, className }: { children?: ReactNode; className?: string }) =>

{children}

, + DialogFooter: ({ children, className }: { children?: ReactNode; className?: string }) =>
{children}
, + DialogHeader: ({ children, className }: { children?: ReactNode; className?: string }) =>
{children}
, + DialogTitle: ({ children, className }: { children?: ReactNode; className?: string }) =>

{children}

, })); vi.mock("@/components/ui/sheet", () => ({ @@ -366,6 +368,34 @@ function createIssue(overrides: Partial = {}): Issue { } as Issue; } +function createAgent(overrides: Partial = {}): Agent { + return { + id: "agent-1", + companyId: "company-1", + name: "CodexCoder", + urlKey: "codexcoder", + role: "engineer", + title: "Software Engineer", + icon: "code", + status: "active", + reportsTo: null, + capabilities: null, + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + pauseReason: null, + pausedAt: null, + permissions: { canCreateAgents: false }, + lastHeartbeatAt: null, + metadata: null, + createdAt: new Date("2026-04-21T00:00:00.000Z"), + updatedAt: new Date("2026-04-21T00:00:00.000Z"), + ...overrides, + }; +} + function createPauseHold(overrides: Partial = {}): IssueTreeHold { const now = new Date("2026-04-21T00:00:00.000Z"); return { @@ -444,7 +474,7 @@ function createResumePreview(): IssueTreeControlPreview { skippedIssues: 0, activeRuns: 0, queuedRuns: 0, - affectedAgents: 0, + affectedAgents: 1, }, countsByStatus: { todo: 2 }, issues: [ @@ -455,7 +485,7 @@ function createResumePreview(): IssueTreeControlPreview { status: "todo", parentId: null, depth: 0, - assigneeAgentId: null, + assigneeAgentId: "agent-1", assigneeUserId: null, activeRun: null, activeHoldIds: ["hold-1"], @@ -470,7 +500,7 @@ function createResumePreview(): IssueTreeControlPreview { status: "todo", parentId: "issue-1", depth: 1, - assigneeAgentId: null, + assigneeAgentId: "agent-1", assigneeUserId: null, activeRun: null, activeHoldIds: ["hold-1"], @@ -481,6 +511,61 @@ function createResumePreview(): IssueTreeControlPreview { ], skippedIssues: [], activeRuns: [], + affectedAgents: [{ agentId: "agent-1", issueCount: 2, activeRunCount: 0 }], + warnings: [], + }; +} + +function createPausePreview(): IssueTreeControlPreview { + return { + companyId: "company-1", + rootIssueId: "issue-1", + mode: "pause", + generatedAt: new Date("2026-04-21T00:00:00.000Z"), + releasePolicy: { strategy: "manual" }, + totals: { + totalIssues: 2, + affectedIssues: 2, + skippedIssues: 0, + activeRuns: 1, + queuedRuns: 0, + affectedAgents: 0, + }, + countsByStatus: { todo: 2 }, + issues: [ + { + id: "issue-1", + identifier: "PAP-1", + title: "Issue detail smoke", + status: "todo", + parentId: null, + depth: 0, + assigneeAgentId: null, + assigneeUserId: null, + activeRun: null, + activeHoldIds: [], + action: "pause", + skipped: false, + skipReason: null, + }, + { + id: "child-1", + identifier: "PAP-2", + title: "Paused child", + status: "in_review", + parentId: "issue-1", + depth: 1, + assigneeAgentId: null, + assigneeUserId: null, + activeRun: null, + activeHoldIds: [], + action: "pause", + skipped: false, + skipReason: null, + }, + ], + skippedIssues: [], + activeRuns: [], affectedAgents: [], warnings: [], }; @@ -557,6 +642,46 @@ function createRestorePreview(): IssueTreeControlPreview { }; } +function createCancelPreview(issueCount = 8): IssueTreeControlPreview { + const issues = Array.from({ length: issueCount }, (_, index) => ({ + id: index === 0 ? "issue-1" : `child-${index}`, + identifier: index === 0 ? "PAP-1" : `PAP-${index + 1}`, + title: index === 0 ? "Issue detail smoke" : `Cancellable child ${index}`, + status: "todo" as const, + parentId: index === 0 ? null : "issue-1", + depth: index === 0 ? 0 : 1, + assigneeAgentId: null, + assigneeUserId: null, + activeRun: null, + activeHoldIds: [], + action: "cancel" as const, + skipped: false, + skipReason: null, + })); + + return { + companyId: "company-1", + rootIssueId: "issue-1", + mode: "cancel", + generatedAt: new Date("2026-04-21T00:00:00.000Z"), + releasePolicy: { strategy: "manual" }, + totals: { + totalIssues: issueCount, + affectedIssues: issueCount, + skippedIssues: 0, + activeRuns: 0, + queuedRuns: 0, + affectedAgents: 0, + }, + countsByStatus: { todo: issueCount }, + issues, + skippedIssues: [], + activeRuns: [], + affectedAgents: [], + warnings: [], + }; +} + async function flushReact() { await act(async () => { await Promise.resolve(); @@ -702,6 +827,7 @@ describe("IssueDetail", () => { ); mockIssuesApi.listTreeHolds.mockResolvedValue([activeHold]); mockIssuesApi.previewTreeControl.mockResolvedValue(createResumePreview()); + mockAgentsApi.list.mockResolvedValue([createAgent()]); mockIssuesApi.releaseTreeHold.mockImplementation(() => { activePauseHoldState = null; return Promise.resolve(releasedHold); @@ -739,6 +865,7 @@ describe("IssueDetail", () => { .filter((button) => button.textContent?.trim() === "Resume subtree") .at(-1); expect(applyResumeButton).toBeTruthy(); + expect(container.textContent).toContain("CodexCoder"); await act(async () => { applyResumeButton!.click(); @@ -748,7 +875,7 @@ describe("IssueDetail", () => { expect(mockIssuesApi.releaseTreeHold).toHaveBeenCalledWith("PAP-1", "hold-1", { reason: null, - metadata: { wakeAgents: false }, + metadata: { wakeAgents: true }, }); expect(mockIssuesApi.getTreeControlState.mock.calls.length).toBeGreaterThanOrEqual(2); expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ @@ -761,6 +888,88 @@ describe("IssueDetail", () => { }); }); + it("uses simplified full-subtree pause controls", async () => { + const childIssue = createIssue({ + id: "child-1", + parentId: "issue-1", + identifier: "PAP-2", + issueNumber: 2, + title: "Paused child", + }); + const pausePreview = createPausePreview(); + const pauseHold = createPauseHold({ + id: "pause-hold-1", + mode: "pause", + reason: null, + releasePolicy: { strategy: "manual", note: "full_pause" }, + members: [], + }); + + mockIssuesApi.get.mockResolvedValue(createIssue()); + mockIssuesApi.list.mockImplementation((_companyId, filters?: { parentId?: string }) => + Promise.resolve(filters?.parentId === "issue-1" ? [childIssue] : []), + ); + mockIssuesApi.previewTreeControl.mockResolvedValue(pausePreview); + mockIssuesApi.createTreeHold.mockResolvedValue({ hold: pauseHold, preview: pausePreview }); + mockAuthApi.getSession.mockResolvedValue({ + session: { userId: "user-1" }, + user: { id: "user-1" }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + const moreButton = container.querySelector('button[aria-label="More issue actions"]') as HTMLButtonElement | null; + expect(moreButton).toBeTruthy(); + + await act(async () => { + moreButton!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + }); + await flushReact(); + + const pauseMenuButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "Pause subtree..."); + expect(pauseMenuButton).toBeTruthy(); + + await act(async () => { + pauseMenuButton!.click(); + }); + await flushReact(); + await flushReact(); + + expect(mockIssuesApi.previewTreeControl).toHaveBeenCalledWith("PAP-1", { + mode: "pause", + releasePolicy: { strategy: "manual" }, + }); + expect(container.textContent).not.toContain("Pause mode"); + expect(container.textContent).not.toContain("Release policy"); + expect(container.textContent).not.toContain("Status breakdown"); + expect(container.textContent).not.toContain("Active runs cancelled"); + expect(container.textContent).toContain("Paused child"); + + const pauseApplyButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "Pause and stop work"); + expect(pauseApplyButton).toBeTruthy(); + + await act(async () => { + pauseApplyButton!.click(); + }); + await flushReact(); + + expect(mockIssuesApi.createTreeHold).toHaveBeenCalledWith("PAP-1", { + mode: "pause", + reason: null, + releasePolicy: { strategy: "manual", note: "full_pause" }, + }); + }); + it("exposes restore subtree from the issue actions menu", async () => { const childIssue = createIssue({ id: "child-1", @@ -835,7 +1044,7 @@ describe("IssueDetail", () => { releasePolicy: { strategy: "manual" }, }); expect(container.textContent).toContain("Restore issues cancelled by this subtree operation so work can resume."); - expect(container.textContent).toContain("Issues restored: 1"); + expect(container.textContent).toContain("Cancelled child"); const restoreApplyButton = Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.trim() === "Restore 1 issues"); @@ -853,4 +1062,72 @@ describe("IssueDetail", () => { metadata: { wakeAgents: false }, }); }); + + it("bounds the subtree control dialog with an internal scroll body", async () => { + const childIssue = createIssue({ + id: "child-1", + parentId: "issue-1", + identifier: "PAP-2", + issueNumber: 2, + title: "Cancellable child", + }); + + mockIssuesApi.get.mockResolvedValue(createIssue()); + mockIssuesApi.list.mockImplementation((_companyId, filters?: { parentId?: string }) => + Promise.resolve(filters?.parentId === "issue-1" ? [childIssue] : []), + ); + mockIssuesApi.previewTreeControl.mockResolvedValue(createCancelPreview(24)); + mockAuthApi.getSession.mockResolvedValue({ + session: { userId: "user-1" }, + user: { id: "user-1" }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + const cancelMenuButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "Cancel subtree..."); + expect(cancelMenuButton).toBeTruthy(); + + await act(async () => { + cancelMenuButton!.click(); + }); + await flushReact(); + await flushReact(); + + expect(mockIssuesApi.previewTreeControl).toHaveBeenCalledWith("PAP-1", { + mode: "cancel", + releasePolicy: { strategy: "manual" }, + }); + + const dialogContent = container.querySelector('[data-slot="dialog-content"]') as HTMLDivElement | null; + expect(dialogContent).toBeTruthy(); + expect(dialogContent!.className).toContain("max-h-[calc(100dvh-2rem)]"); + expect(dialogContent!.className).toContain("overflow-hidden"); + expect(dialogContent!.className).toContain("flex-col"); + + const bodyScrollRegion = Array.from(dialogContent!.querySelectorAll("div")) + .find((element) => + typeof element.className === "string" + && element.className.includes("overflow-y-auto") + && element.textContent?.includes("Reason (required)"), + ); + expect(bodyScrollRegion?.className).toContain("min-h-0"); + expect(bodyScrollRegion?.className).toContain("overscroll-contain"); + + const footer = Array.from(dialogContent!.querySelectorAll("div")) + .find((element) => + typeof element.className === "string" + && element.className.includes("border-t") + && element.textContent?.includes("Close"), + ); + expect(footer?.className).toContain("bg-background"); + }); }); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 5740b02e58c..f37b06a6618 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -65,6 +65,7 @@ import { IssueChatThread, type IssueChatComposerHandle } from "../components/Iss import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff"; import { IssueDocumentsSection } from "../components/IssueDocumentsSection"; import { IssuesList } from "../components/IssuesList"; +import { AgentIcon } from "../components/AgentIconPicker"; import { IssueReferenceActivitySummary } from "../components/IssueReferenceActivitySummary"; import { IssueRelatedWorkPanel } from "../components/IssueRelatedWorkPanel"; import { IssueProperties } from "../components/IssueProperties"; @@ -157,33 +158,11 @@ const TREE_CONTROL_MODE_LABEL: Record = { restore: "Restore subtree", }; const TREE_CONTROL_MODE_HELP_TEXT: Record = { - pause: "Pause active execution in this issue subtree and hold descendant execution until release.", + pause: "Pause active execution in this issue subtree until an explicit resume.", resume: "Release the active subtree pause hold so held work can continue.", cancel: "Cancel non-terminal issues in this subtree and stop queued/running work where possible.", restore: "Restore issues cancelled by this subtree operation so work can resume.", }; -const TREE_CONTROL_STRATEGY_OPTIONS = [ - { value: "manual", label: "Manual release" }, - { value: "after_active_runs_finish", label: "Release after active runs finish" }, -] as const; -const TREE_CONTROL_PAUSE_VARIANTS = [ - { - value: "course_correction", - label: "Course-correction pause", - description: "Hold descendants while the root can still run for planner follow-up.", - }, - { - value: "full_pause", - label: "Full subtree pause", - description: "Stop root and descendant execution until an explicit resume action.", - }, -] as const; -type TreeControlPauseVariant = (typeof TREE_CONTROL_PAUSE_VARIANTS)[number]["value"]; - -function parsePauseVariantFromNote(note: string | null | undefined): TreeControlPauseVariant { - const normalized = note?.trim().toLowerCase(); - return normalized === "course_correction" ? "course_correction" : "full_pause"; -} function treeControlPreviewErrorCopy(error: unknown): string { if (error instanceof ApiError) { @@ -1048,12 +1027,8 @@ export function IssueDetail() { const [treeControlOpen, setTreeControlOpen] = useState(false); const [treeControlMode, setTreeControlMode] = useState("pause"); const [treeControlReason, setTreeControlReason] = useState(""); - const [treeControlPauseVariant, setTreeControlPauseVariant] = useState("course_correction"); const [treeControlWakeAgentsOnResume, setTreeControlWakeAgentsOnResume] = useState(false); const [treeControlCancelConfirmed, setTreeControlCancelConfirmed] = useState(false); - const [treeControlReleaseStrategy, setTreeControlReleaseStrategy] = useState<"manual" | "after_active_runs_finish">( - "manual", - ); const [optimisticComments, setOptimisticComments] = useState([]); const [locallyQueuedCommentRunIds, setLocallyQueuedCommentRunIds] = useState>(() => new Map()); const [pendingCommentComposerFocusKey, setPendingCommentComposerFocusKey] = useState(0); @@ -1247,14 +1222,12 @@ export function IssueDetail() { "tree-control-preview", issueId ?? "pending", treeControlMode, - treeControlReleaseStrategy, - treeControlPauseVariant, ], queryFn: () => issuesApi.previewTreeControl(issueId!, { mode: treeControlMode, releasePolicy: { - strategy: treeControlReleaseStrategy, + strategy: "manual", }, }), enabled: treeControlOpen && !!issueId && canManageTreeControl, @@ -1543,10 +1516,8 @@ export function IssueDetail() { mode: treeControlMode, reason: treeControlReason.trim() || null, releasePolicy: { - strategy: treeControlReleaseStrategy, - ...(treeControlMode === "pause" - ? { note: treeControlPauseVariant } - : {}), + strategy: "manual", + ...(treeControlMode === "pause" ? { note: "full_pause" } : {}), }, ...(treeControlMode === "restore" ? { metadata: { wakeAgents: treeControlWakeAgentsOnResume } } @@ -1557,7 +1528,6 @@ export function IssueDetail() { onSuccess: async (result) => { const modeLabel = TREE_CONTROL_MODE_LABEL[result.hold.mode]; const cancelCount = result.preview?.totals.activeRuns ?? 0; - const pauseModeText = treeControlPauseVariant === "course_correction" ? "Course-correction pause enabled." : "Full subtree pause enabled."; pushToast({ title: result.kind === "release" ? "Subtree resumed" @@ -1567,10 +1537,10 @@ export function IssueDetail() { body: result.kind === "release" ? (result.hold.releaseReason?.trim() || "Active subtree pause released.") : result.hold.mode === "pause" - ? `${pauseModeText} ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.` + ? `Subtree paused. ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.` : result.hold.reason?.trim() ? result.hold.reason - : `Control applied with ${result.hold.releasePolicy?.strategy ?? "manual"} release policy.`, + : "Subtree control applied.", }); setTreeControlOpen(false); setTreeControlReason(""); @@ -2671,26 +2641,13 @@ export function IssueDetail() { const hasAttachments = attachmentList.length > 0; const treePreviewWarnings = treeControlPreview?.warnings ?? []; - const activePauseVariant = parsePauseVariantFromNote( - activeRootPauseHold?.releasePolicy?.note ?? activePauseHold?.releasePolicy?.note, - ); const heldDescendantCount = activeRootPauseHold?.members?.filter((member) => member.depth > 0 && !member.skipped).length ?? Math.max(heldIssueIds.size - 1, 0); const canShowSubtreeControls = canManageTreeControl && childIssues.length > 0; const canResumeSubtree = canShowSubtreeControls && activePauseHold?.isRoot === true; const canRestoreSubtree = canShowSubtreeControls && activeCancelHolds.length > 0; - const previewTerminalSkippedCount = treeControlPreview?.skippedIssues.filter((candidate) => candidate.skipReason === "terminal_status").length ?? 0; const previewAffectedIssueCount = treePreviewAffectedIssues.length; - const previewAffectedDescendantCount = treePreviewAffectedIssues.filter((candidate) => candidate.depth > 0).length; const previewAffectedAgentCount = treeControlPreview?.totals.affectedAgents ?? 0; - const treePreviewPrimaryCountLabel = - treeControlMode === "pause" - ? "Issues held" - : treeControlMode === "cancel" - ? "Issues cancelled" - : treeControlMode === "restore" - ? "Issues restored" - : "Issues released"; const treeControlPrimaryButtonLabel = treeControlMode === "pause" ? "Pause and stop work" @@ -2699,27 +2656,39 @@ export function IssueDetail() { : treeControlMode === "restore" ? `Restore ${previewAffectedIssueCount} issues` : "Resume subtree"; - const deferredComposerHint = activePauseHold && !activePauseHold.isRoot - ? "Will be delivered when the subtree resumes." - : null; - const rootCourseCorrectionComposerHint = activePauseHold?.isRoot && activePauseVariant === "course_correction" + const treePreviewAffectedIssueRows = treePreviewAffectedIssues.map((candidate) => ({ + candidate, + issue: { + ...issue, + id: candidate.id, + identifier: candidate.identifier, + title: candidate.title, + status: candidate.status, + parentId: candidate.parentId, + assigneeAgentId: candidate.assigneeAgentId, + assigneeUserId: candidate.assigneeUserId, + executionRunId: candidate.activeRun?.id ?? null, + } satisfies Issue, + })); + const treePreviewAffectedAgentRows = (treeControlPreview?.affectedAgents ?? []) + .map((previewAgent) => ({ + ...previewAgent, + agent: agentMap.get(previewAgent.agentId) ?? null, + })) + .sort((a, b) => (a.agent?.name ?? a.agentId).localeCompare(b.agent?.name ?? b.agentId)); + const pausedComposerHint = activePauseHold ? ( issue.assigneeAgentId - ? `Sending this comment will wake ${agentMap.get(issue.assigneeAgentId)?.name ?? "the assignee"} in course-correction mode. Descendants stay held.` - : "Assign a planner above to run course corrections." + ? `Sending this comment will wake ${agentMap.get(issue.assigneeAgentId)?.name ?? "the assignee"} for triage while the subtree remains paused.` + : "Assign an agent to wake them for triage while the subtree remains paused." ) : null; - const composerHint = rootCourseCorrectionComposerHint ?? deferredComposerHint; - const queuedCommentReason: "hold" | "active_run" | "other" = - activePauseHold && (!activePauseHold.isRoot || activePauseVariant === "full_pause") - ? "hold" - : "active_run"; + const composerHint = pausedComposerHint; + const queuedCommentReason: "hold" | "active_run" | "other" = "active_run"; const canApplyTreeControl = Boolean(treeControlPreview) && !treeControlPreviewLoading - && (treeControlMode !== "cancel" || (treeControlReason.trim().length > 0 && treeControlCancelConfirmed)) - && (treeControlMode !== "resume" || !treeControlWakeAgentsOnResume || previewAffectedAgentCount > 0) - && (treeControlMode !== "restore" || !treeControlWakeAgentsOnResume || previewAffectedAgentCount > 0); + && (treeControlMode !== "cancel" || (treeControlReason.trim().length > 0 && treeControlCancelConfirmed)); const attachmentUploadButton = ( <> Subtree pause is active. - {activePauseVariant === "course_correction" - ? "Course-correction mode keeps descendants held while root planning can continue." - : "Full pause mode holds the full subtree until resume."} + Root and descendant execution is held until resume. Human comments can still wake assignees for triage.
@@ -2807,7 +2774,7 @@ export function IssueDetail() { size="sm" onClick={() => { setTreeControlMode("resume"); - setTreeControlWakeAgentsOnResume(false); + setTreeControlWakeAgentsOnResume(true); setTreeControlOpen(true); }} > @@ -2818,6 +2785,7 @@ export function IssueDetail() { size="sm" onClick={() => { setTreeControlMode("resume"); + setTreeControlWakeAgentsOnResume(true); setTreeControlOpen(true); }} > @@ -3017,7 +2985,7 @@ export function IssueDetail() { className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50" onClick={() => { setTreeControlMode("resume"); - setTreeControlWakeAgentsOnResume(false); + setTreeControlWakeAgentsOnResume(true); setTreeControlOpen(true); setMoreOpen(false); }} @@ -3448,46 +3416,20 @@ export function IssueDetail() { - - + + {TREE_CONTROL_MODE_LABEL[treeControlMode]} {TREE_CONTROL_MODE_HELP_TEXT[treeControlMode]} -
+
{treeControlMode === "cancel" ? (
Cancelling a subtree is destructive. Non-terminal issues will be marked cancelled, and running or queued work will be interrupted where possible.
) : null} - {treeControlMode === "pause" ? ( -
-

Pause mode

- {TREE_CONTROL_PAUSE_VARIANTS.map((variant) => ( - - ))} -
- ) : null} -
{(treeControlMode === "resume" || treeControlMode === "restore") ? ( -