diff --git a/DESIGN.md b/DESIGN.md index 128fc6fafb..e965c7a3b7 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -35,6 +35,14 @@ Existing tiers already in index.css (~80+ tokens) — extraction maps to these o 7. **Words are part of the system.** One name per concept across the entire UI — the canonical term is *task* (never *issue* or *ticket* in copy, labels, or empty states). Buttons name the action ("Approve hire," not "Submit"). Errors say what happened and what to do. Empty states say what to do first. **Note:** enforcing the task rename is a visible change and is explicitly OUT of the zero-visual-change extraction run; it happens in its own follow-up run. 8. **Agent-modifiable by design.** The system must be changeable via instructions: single token source, lint rules that enforce it, and this document kept current. A correct change should be expressible as "edit tokens + run checks," not "visit 40 files." +## Contextual feedback + +Do not show a toast for task or run state already visible on the current screen. +This includes descendant runs represented by the open subtree. Show local action +results in place; keep failures actionable inline. Notifications for other work +remain useful. Expected cancellation is neutral gray, not an error. A paused +subtree needs only “Subtree is paused.” and “Resume subtree.” + ## Enforcement (what "compliant" means for the extraction run) - **Zero visual change is proven, not promised:** Storybook visual snapshots are baselined before any refactor, and all snapshots match baseline after it. A change that alters rendered output must be intentional and human-approved. diff --git a/doc/composer-stop.md b/doc/composer-stop.md new file mode 100644 index 0000000000..019e4365f3 --- /dev/null +++ b/doc/composer-stop.md @@ -0,0 +1,155 @@ +# Composer Stop and task controls + +The empty composer shows **Stop** while this task has a live execution and the +viewer can manage task controls. Stop creates the same manual pause hold as +**Pause work** / **Pause subtree** in the task menu. A parent pause includes its +descendants. Cancellation remains a separate menu action. + +Text (after trimming) or attachments switch the button back to Send. Uploading +and failed attachments retain their existing restrictions. Keyboard submission +never invokes Stop. Queued-message editing and structured interactions keep +their existing actions, and text entered while stopping remains in the draft. + +Pause dispatches without a preview dialog or reason. The button stays pending +while affected run state is checked; native cancellation must be acknowledged. +A saved hold with unconfirmed termination produces an error rather than a +success claim. The cancellation dialog requires a valid preview and excludes +terminal tasks. Resume/restore retain the optional wake-agents checkbox. + +## Resume and execution recovery + +Resume releases a hold. Waking agents is optional and only applies to tasks in +`todo`, `in_progress`, or `in_review`; parked and terminal tasks stay untouched. +The current execution-recovery policy requires verified outcomes before a stopped +provider can restart. If any affected task still needs that reconciliation, +Resume with wake enabled returns an inline error and preserves the pause. The +operator can release the pause without waking agents, then use the existing +execution-reconciliation flow after reviewing the stopped run. Resume does not +claim that unknown provider actions completed or were never performed. + +A completed release remains successful if a best-effort wake fails. Its response +includes optional `wakeFailures`, the page reports them inline, and remaining +eligible tasks still receive their wake requests. No new endpoint is introduced. +The deterministic E2E fixtures prove interruption, then record their known lack +of external effects through the existing reconciliation API before continuing. + +## Quiet task feedback + +The visible task/subtree does not produce duplicate state toasts. Its live +notifications are suppressed while foregrounded, including descendant runs; +unrelated and background work retains notifications. Tree-control results use +inline state, and failures stay in the composer, page, or confirmation dialog. +The pause row contains only “Subtree is paused.” (or “Task is paused.”) and +Resume. Expected cancellation uses a muted gray disclosure with optional details. +This is recorded as a product rule in `DESIGN.md`. + +The follow-up passed 213 focused tests, both isolated runner E2E journeys +(including no-toast assertions), UI typecheck/build, token gates, and Storybook +build. Paused, expanded cancellation, Stop without toasts, mobile, and light +stories were inspected in the browser. + +## Storybook + +Run from the worktree: + +```sh +pnpm --filter @paperclipai/ui exec storybook dev -p 6016 -c storybook/.storybook --no-open +``` + +Open `http://localhost:6016/?path=/story/tasks-execution-controls--running-empty`. +The `Tasks / Execution Controls` stories compose the production composer and +menu/dialog controls together. They cover text switching, attachment-only, +idle, stopping, paused, errors, cancellation preview/loading, and mobile/light +presentations. The Storybook state transitions simulate requests; runner +verification belongs to the isolated browser suite below. + +## Automated verification + +```sh +pnpm exec vitest run --project @paperclipai/ui ui/src/components/task-chat/TaskChatComposer.test.tsx ui/src/components/TaskChatThread.test.tsx ui/src/pages/IssueDetail.test.tsx ui/src/lib/wait-for-stopped-runs.test.ts +pnpm exec vitest run --project @paperclipai/server server/src/__tests__/issue-tree-control-routes.test.ts +pnpm check:token-gates +pnpm build-storybook +pnpm -r typecheck +pnpm test:run +pnpm build +``` + +The component/page tests cover whitespace, attachments and upload restrictions, +permissions, keyboard submission, queue edits, duplicate clicks, draft +preservation, shared pause requests, compact cancellation, and visible errors. +Stop verification tests include continued execution, native acknowledgment, +network failures, and hung status requests. Route tests cover pause dispatch, +authorization, cancellation, and opt-in resume wakeups with terminal/company +exclusions. + +## Isolated browser acceptance + +Legacy process coverage needs no provider credentials: + +```sh +pnpm exec playwright test --config tests/e2e/playwright-composer-stop.config.ts +``` + +To include native execution, build the real runner and deterministic Codex +protocol fixture from this checkout, then point the suite at their absolute +paths: + +```sh +cargo build --manifest-path packages/paperclip-runner/runner/Cargo.toml --bin paperclip-runnerd --bin fake-codex-app-server +PAPERCLIP_STOP_FAKE_CODEX="$PWD/packages/paperclip-runner/runner/target/debug/fake-codex-app-server" \ +PAPERCLIP_RUNNER_BINARY="$PWD/packages/paperclip-runner/runner/target/debug/paperclip-runnerd" \ +pnpm exec playwright test --config tests/e2e/playwright-composer-stop.config.ts +``` + +The suite boots a disposable local-trusted instance on port 3199 (override with +`PAPERCLIP_E2E_PORT`). It never attaches to an existing server. Native coverage +is explicitly skipped without the fixture; it must not use a logged-in provider +as a fallback. Test companies are archived during cleanup. + +For each runner, the journey starts a parent, child, and unrelated task, plus a +terminal child. It sends while running and verifies the durable queue, clicks +Stop, verifies interruption and the persisted hold, and observes three +ten-second scheduler intervals without continuation. It resumes with wakeups, +pauses from the menu, dismisses and then confirms cancellation, and verifies +terminal-task exclusions and unrelated execution. + +Timing attachments distinguish click-to-request from request-to-observed-stop. +Native proof requires provider `turn/start` before the click, `turn/interrupt` +after it, and durable cancellation acknowledgment. Legacy proof checks the +actual parent and child PIDs have exited with a one-second configured grace +period. HTTP success alone is insufficient. + +For a release with real providers, repeat while an actual long-running tool is +active, confirm the tool's own process/output stops, and verify a queued message +is consumed on continuation. The deterministic provider exercises the native +transport and cancellation protocol, not external provider behavior or every +tool's process cleanup. Preserve these limits in any test report. + +## Recorded acceptance run (2026-09-09) + +Both isolated browser journeys passed. Legacy Stop dispatched after 214 ms and +observed both runs stopped 155 ms after dispatch; native dispatched after 212 ms +and observed stop 320 ms later. These are single local observations including +browser automation overhead, not latency guarantees. Native used the real +runnerd and the repository's deterministic Codex protocol fixture. Real hosted +provider/tool cleanup remains a release acceptance check. + +Repository typecheck, production build, Storybook build, token gates, targeted +UI tests, and tree-control route tests passed. The broad UI run passed 5,491 +tests with one five-second timeout in an unchanged `IssuesList` test; rerunning +that file passed all 46 tests. The repository test run encountered 17 failures +in unchanged suites, all reproduced in the original checkout: + +- `server/src/__tests__/workspace-runtime.test.ts`: 2 failures. +- `server/src/services/workspace-runtime-exposure.test.ts`: 7 failures. +- `server/src/__tests__/execution-workspace-runtime-control-conflict.test.ts`: 4 failures. +- `server/src/__tests__/workspace-instance-cleanup.test.ts`: 1 failure. +- `server/src/__tests__/company-skills.test.ts`: 2 failures. +- `server/src/__tests__/worktree-seed-server-spawn.test.ts`: 1 failure. + +These baseline failures prevent a green repository-wide test result. +The broad run was stopped after more than 30 minutes in its serial server lane +once these failures were independently reproduced. Later full-suite groups +did not run. The full UI suite and the feature's server route suite were run +separately as described above. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index db3002bb8d..67f818fec2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1220,6 +1220,7 @@ export type { IssueLabel, IssueTreeControlPreview, IssueTreeHold, + ReleaseIssueTreeHoldResponse, IssueTreeHoldMember, IssueTreeHoldReleasePolicy, IssueTreePreviewAgent, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 1f9e5bf7d1..42b07e9fd2 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -788,6 +788,7 @@ export type { export type { IssueTreeControlPreview, IssueTreeHold, + ReleaseIssueTreeHoldResponse, IssueTreeHoldMember, IssueTreeHoldReleasePolicy, IssueTreePreviewAgent, diff --git a/packages/shared/src/types/issue-tree-control.ts b/packages/shared/src/types/issue-tree-control.ts index f8dacd8d7f..75489fffd4 100644 --- a/packages/shared/src/types/issue-tree-control.ts +++ b/packages/shared/src/types/issue-tree-control.ts @@ -113,3 +113,8 @@ export interface IssueTreeHold { updatedAt: Date; members?: IssueTreeHoldMember[]; } + +/** A completed release can include best-effort wake failures. */ +export interface ReleaseIssueTreeHoldResponse extends IssueTreeHold { + wakeFailures?: Array<{ issueId: string; message: string }>; +} diff --git a/server/src/__tests__/heartbeat-run-status-payload.test.ts b/server/src/__tests__/heartbeat-run-status-payload.test.ts index 6a36d1f56d..1edaddaeab 100644 --- a/server/src/__tests__/heartbeat-run-status-payload.test.ts +++ b/server/src/__tests__/heartbeat-run-status-payload.test.ts @@ -19,6 +19,11 @@ function run(status: string, resultJson: Record | null) { } describe("buildHeartbeatRunStatusLiveEventPayload", () => { + it("identifies the task after its live-run cache entry has disappeared", () => { + expect(buildHeartbeatRunStatusLiveEventPayload({ + ...run("cancelled", null), contextSnapshot: { issueId: "task-1" }, + }).issueId).toBe("task-1"); + }); it("attaches the canonical final assistant text to terminal status events", () => { expect( buildHeartbeatRunStatusLiveEventPayload( diff --git a/server/src/__tests__/issue-tree-control-routes.test.ts b/server/src/__tests__/issue-tree-control-routes.test.ts index 31df97bb7e..bea4486453 100644 --- a/server/src/__tests__/issue-tree-control-routes.test.ts +++ b/server/src/__tests__/issue-tree-control-routes.test.ts @@ -1,4 +1,5 @@ import express from "express"; +import { PgDialect } from "drizzle-orm/pg-core"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -16,6 +17,9 @@ const mockTreeControlService = vi.hoisted(() => ({ cancelUnclaimedWakeupsForTree: vi.fn(), })); +const mockReplayBlocks = vi.hoisted(() => vi.fn()); +const mockReplayWhere = vi.hoisted(() => vi.fn()); + const mockLogActivity = vi.hoisted(() => vi.fn()); const mockHeartbeatService = vi.hoisted(() => ({ cancelRun: vi.fn(), @@ -40,7 +44,13 @@ async function createApp(actor: Record) { (req as any).actor = actor; next(); }); - app.use("/api", issueTreeControlRoutes({} as any)); + const query = { + from: () => query, + innerJoin: () => query, + where: (predicate: unknown) => { mockReplayWhere(predicate); return query; }, + limit: mockReplayBlocks, + }; + app.use("/api", issueTreeControlRoutes({ select: () => query } as any)); app.use(errorHandler); return app; } @@ -48,12 +58,17 @@ async function createApp(actor: Record) { describe("issue tree control routes", () => { beforeEach(() => { vi.clearAllMocks(); + mockReplayBlocks.mockResolvedValue([]); + mockTreeControlService.getHold.mockResolvedValue(null); mockIssueService.getById.mockResolvedValue({ id: "11111111-1111-4111-8111-111111111111", companyId: "company-2", }); mockTreeControlService.cancelUnclaimedWakeupsForTree.mockResolvedValue([]); - mockTreeControlService.cancelIssueStatusesForHold.mockResolvedValue({ updatedIssueIds: [], updatedIssues: [] }); + mockTreeControlService.cancelIssueStatusesForHold.mockResolvedValue({ + updatedIssueIds: [], + updatedIssues: [], + }); mockTreeControlService.restoreIssueStatusesForHold.mockResolvedValue({ updatedIssueIds: [], updatedIssues: [], @@ -64,6 +79,173 @@ describe("issue tree control routes", () => { mockHeartbeatService.wakeup.mockResolvedValue(null); }); + it.each([false, true])( + "only wakes eligible current assignees when resume requests wakeAgents=%s", + async (wakeAgents) => { + const rootId = "11111111-1111-4111-8111-111111111111"; + const holdId = "33333333-3333-4333-8333-333333333333"; + const root = { + id: rootId, + companyId: "company-2", + status: "todo", + assigneeAgentId: "agent-parent", + }; + const issues = [ + root, + { + id: "child", + companyId: "company-2", + status: "in_progress", + assigneeAgentId: "agent-child", + }, + { + id: "backlog-task", + companyId: "company-2", + status: "backlog", + assigneeAgentId: "parked-agent", + }, + { + id: "blocked-task", + companyId: "company-2", + status: "blocked", + assigneeAgentId: "blocked-agent", + }, + { + id: "done", + companyId: "company-2", + status: "done", + assigneeAgentId: "agent-done", + }, + { + id: "cancelled", + companyId: "company-2", + status: "cancelled", + assigneeAgentId: "agent-cancelled", + }, + { + id: "foreign", + companyId: "company-3", + status: "todo", + assigneeAgentId: "agent-foreign", + }, + ]; + mockIssueService.getById.mockImplementation(async (id: string) => + issues.find((issue) => issue.id === id), + ); + mockTreeControlService.releaseHold.mockResolvedValue({ + id: holdId, + mode: "pause", + status: "released", + members: issues.map((issue) => ({ issueId: issue.id })), + }); + mockTreeControlService.getHold.mockResolvedValue({ + id: holdId, + rootIssueId: rootId, + mode: "pause", + members: issues.map((issue) => ({ issueId: issue.id })), + }); + const app = await createApp({ + type: "board", + userId: "user-1", + companyIds: ["company-2"], + source: "session", + isInstanceAdmin: false, + }); + const response = await request(app) + .post(`/api/issues/${rootId}/tree-holds/${holdId}/release`) + .send({ metadata: { wakeAgents } }); + expect(response.status).toBe(200); + expect(mockHeartbeatService.wakeup.mock.calls.map(([id]) => id)).toEqual( + wakeAgents ? ["agent-parent", "agent-child"] : [], + ); + if (wakeAgents) { + const { params } = new PgDialect().sqlToQuery(mockReplayWhere.mock.calls[0]![0]); + expect(params).toEqual(expect.arrayContaining(["todo", "in_progress", "in_review"])); + expect(params).not.toContain("blocked"); + expect(params).not.toContain("backlog"); + } + if (wakeAgents) + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + "agent-child", + expect.objectContaining({ + reason: "issue_tree_resumed", + contextSnapshot: expect.objectContaining({ + issueId: "child", + holdId, + }), + }), + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ action: "issue.tree_hold_released" }), + ); + }, + ); + + it("reports wake failures without undoing release or skipping other assignees", async () => { + const rootId = "11111111-1111-4111-8111-111111111111"; + const holdId = "33333333-3333-4333-8333-333333333333"; + mockTreeControlService.releaseHold.mockResolvedValue({ + id: holdId, + mode: "pause", + status: "released", + members: [{ issueId: rootId }, { issueId: "child" }], + }); + mockIssueService.getById.mockImplementation(async (id) => ({ + id, + companyId: "company-2", + status: "todo", + assigneeAgentId: id, + })); + mockHeartbeatService.wakeup.mockRejectedValueOnce( + new Error("Agent unavailable"), + ); + const app = await createApp({ + type: "board", + userId: "user-1", + companyIds: ["company-2"], + source: "session", + isInstanceAdmin: false, + }); + const response = await request(app) + .post(`/api/issues/${rootId}/tree-holds/${holdId}/release`) + .send({ metadata: { wakeAgents: true } }); + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + status: "released", + wakeFailures: [{ issueId: rootId, message: "Agent unavailable" }], + }); + expect( + mockHeartbeatService.wakeup.mock.calls.map(([agent]) => agent), + ).toEqual([rootId, "child"]); + }); + + it("keeps the hold active when resume requests an unsafe execution replay", async () => { + const rootId = "11111111-1111-4111-8111-111111111111"; + const holdId = "33333333-3333-4333-8333-333333333333"; + mockTreeControlService.getHold.mockResolvedValue({ + id: holdId, + rootIssueId: rootId, + mode: "pause", + members: [{ issueId: rootId }], + }); + mockReplayBlocks.mockResolvedValue([{ identifier: "TEST-1" }]); + const app = await createApp({ + type: "board", + userId: "user-1", + companyIds: ["company-2"], + source: "session", + isInstanceAdmin: false, + }); + const response = await request(app) + .post(`/api/issues/${rootId}/tree-holds/${holdId}/release`) + .send({ metadata: { wakeAgents: true } }); + expect(response.status).toBe(409); + expect(response.body.error).toContain("Resume without waking agents"); + expect(mockTreeControlService.releaseHold).not.toHaveBeenCalled(); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); + }); + it("rejects cross-company preview requests with a uniform 404 before calling the preview service", async () => { const app = await createApp({ type: "board", diff --git a/server/src/routes/issue-tree-control.ts b/server/src/routes/issue-tree-control.ts index ccd4bc61da..4df6a17e10 100644 --- a/server/src/routes/issue-tree-control.ts +++ b/server/src/routes/issue-tree-control.ts @@ -1,17 +1,30 @@ import { Router } from "express"; import type { Request } from "express"; -import type { Db } from "@paperclipai/db"; import { + issueRecoveryActions, + issues as issueRows, + type Db, +} from "@paperclipai/db"; +import { and, eq, inArray, isNotNull, or, sql } from "drizzle-orm"; +import { conflict } from "../errors.js"; +import { + EXECUTION_RECONCILIATION_CAUSES, createIssueTreeHoldSchema, isUuidLike, previewIssueTreeControlSchema, releaseIssueTreeHoldSchema, } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; -import { heartbeatService, issueService, issueTreeControlService, logActivity } from "../services/index.js"; +import { + heartbeatService, + issueService, + issueTreeControlService, + logActivity, +} from "../services/index.js"; import { assertBoard, getAccessibleResource, getActorInfo } from "./authz.js"; const TREE_RUN_CANCELLATION_RESPONSE_WAIT_MS = 1_000; +const RESUME_EXECUTABLE_STATUSES = ["todo", "in_progress", "in_review"]; function errorToMessage(error: unknown) { return error instanceof Error ? error.message : String(error); @@ -349,7 +362,12 @@ export function issueTreeControlRoutes(db: Db) { validate(releaseIssueTreeHoldSchema), async (req, res) => { assertBoard(req); - const root = await getAccessibleResource(req, res, resolveRootIssue(req), "Root issue not found"); + const root = await getAccessibleResource( + req, + res, + resolveRootIssue(req), + "Root issue not found", + ); if (!root) return; const holdId = req.params.holdId as string; @@ -358,17 +376,65 @@ export function issueTreeControlRoutes(db: Db) { return; } + // Releasing a pause does not authorize replay of uncertain provider actions. + // Check before release so a rejected wake leaves the subtree paused. + if (req.body.metadata?.wakeAgents === true) { + const activeHold = await treeControlSvc.getHold(root.companyId, holdId); + const issueIds = + activeHold?.mode === "pause" && activeHold.rootIssueId === root.id + ? (activeHold.members ?? []) + .filter((member) => !member.skipped) + .map((member) => member.issueId) + : []; + if (issueIds.length > 0) { + const [blocked] = await db + .select({ identifier: issueRows.identifier }) + .from(issueRecoveryActions) + .innerJoin( + issueRows, + and( + eq(issueRows.id, issueRecoveryActions.sourceIssueId), + eq(issueRows.companyId, root.companyId), + ), + ) + .where( + and( + eq(issueRecoveryActions.companyId, root.companyId), + inArray(issueRecoveryActions.sourceIssueId, issueIds), + inArray(issueRows.status, RESUME_EXECUTABLE_STATUSES), + isNotNull(issueRows.assigneeAgentId), + inArray(issueRecoveryActions.cause, [ + ...EXECUTION_RECONCILIATION_CAUSES, + ]), + or( + inArray(issueRecoveryActions.status, ["active", "escalated"]), + sql`${issueRecoveryActions.evidence}->'automaticRecovery'->>'replay' = 'blocked'`, + ), + ), + ) + .limit(1); + if (blocked) + throw conflict( + `Cannot wake ${blocked.identifier ?? "this task"} until its stopped execution is reconciled. Resume without waking agents, or review the stopped run first.`, + ); + } + } const actor = getActorInfo(req); - const hold = await treeControlSvc.releaseHold(root.companyId, root.id, holdId, { - ...req.body, - actor: { - actorType: actor.actorType, - actorId: actor.actorId, - agentId: actor.agentId, - userId: actor.actorType === "user" ? actor.actorId : null, - runId: actor.runId, + const hold = await treeControlSvc.releaseHold( + root.companyId, + root.id, + holdId, + { + ...req.body, + actor: { + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + runId: actor.runId, + }, }, - }); + ); await logActivity(db, { companyId: root.companyId, actorType: actor.actorType, @@ -387,7 +453,65 @@ export function issueTreeControlRoutes(db: Db) { }, }); - res.json(hold); + const wakeFailures: Array<{ issueId: string; message: string }> = []; + if (hold.mode === "pause" && req.body.metadata?.wakeAgents === true) { + for (const member of hold.members ?? []) { + if (member.skipped) continue; + try { + const issue = await issuesSvc.getById(member.issueId); + if ( + !issue || + issue.companyId !== root.companyId || + !issue.assigneeAgentId || + !RESUME_EXECUTABLE_STATUSES.includes(issue.status) + ) + continue; + await heartbeat.wakeup(issue.assigneeAgentId, { + source: "assignment", + triggerDetail: "system", + reason: "issue_tree_resumed", + payload: { + issueId: issue.id, + rootIssueId: root.id, + holdId: hold.id, + }, + requestedByActorType: actor.actorType, + requestedByActorId: actor.actorId, + contextSnapshot: { + issueId: issue.id, + taskId: issue.id, + wakeReason: "issue_tree_resumed", + source: "issue.tree_resume", + rootIssueId: root.id, + holdId: hold.id, + }, + }); + } catch (error) { + const message = errorToMessage(error); + wakeFailures.push({ issueId: member.issueId, message }); + await Promise.resolve( + logActivity(db, { + companyId: root.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + agentApiKeyId: actor.agentApiKeyId, + action: "issue.tree_resume_wake_failed", + entityType: "issue", + entityId: root.id, + details: { + holdId: hold.id, + issueId: member.issueId, + error: message, + }, + }), + ).catch(() => null); + } + } + } + + res.json({ ...hold, ...(wakeFailures.length ? { wakeFailures } : {}) }); }, ); diff --git a/server/src/services/heartbeat-run-status-payload.ts b/server/src/services/heartbeat-run-status-payload.ts index 906aad3925..86bb4a28c5 100644 --- a/server/src/services/heartbeat-run-status-payload.ts +++ b/server/src/services/heartbeat-run-status-payload.ts @@ -20,6 +20,9 @@ export function buildHeartbeatRunStatusLiveEventPayload( return { runId: run.id, agentId: run.agentId, + issueId: typeof run.contextSnapshot?.issueId === "string" + ? run.contextSnapshot.issueId + : null, status: run.status, invocationSource: run.invocationSource, triggerDetail: run.triggerDetail, diff --git a/server/src/services/legacy-execution-recovery.ts b/server/src/services/legacy-execution-recovery.ts index 7b91771cf1..cc1a2c0b94 100644 --- a/server/src/services/legacy-execution-recovery.ts +++ b/server/src/services/legacy-execution-recovery.ts @@ -1,7 +1,7 @@ import { normalizeMaxTurnStopReason } from "./heartbeat-stop-metadata.js"; import { randomUUID } from "node:crypto"; import { and, eq, inArray, sql } from "drizzle-orm"; -import { heartbeatRuns, issues, type Db } from "@paperclipai/db"; +import { heartbeatRuns, issueRecoveryActions, issues, type Db } from "@paperclipai/db"; import { issueRecoveryActionService } from "./issue-recovery-actions.js"; import { parseIssueExecutionState } from "./issue-execution-policy.js"; import { executionFailureRetryCount } from "./execution-recovery-attempt.js"; @@ -98,6 +98,16 @@ export async function terminalizeLegacyExecution(input: { (task.assigneeAgentId === run.agentId || isCurrentReviewer) && !["done", "cancelled"].includes(task.status) ) { + // Periodic stranded-work checks may revisit this terminal run before its + // reconciled continuation is dispatched. Preserve the recorded decision. + const [reconciled] = await tx.select({ id: issueRecoveryActions.id }) + .from(issueRecoveryActions).where(and( + eq(issueRecoveryActions.companyId, run.companyId), + eq(issueRecoveryActions.sourceIssueId, task.id), + eq(issueRecoveryActions.status, "resolved"), + sql`${issueRecoveryActions.evidence}->'executionReconciliation'->>'runId' = ${run.id}`, + )).limit(1); + if (reconciled) return updated; await issueRecoveryActionService(tx as unknown as Db).upsertSourceScoped({ companyId: run.companyId, sourceIssueId: task.id, diff --git a/server/src/services/native-runtime/native-safe-replacement.test.ts b/server/src/services/native-runtime/native-safe-replacement.test.ts index db2d14e1b2..5f27a76541 100644 --- a/server/src/services/native-runtime/native-safe-replacement.test.ts +++ b/server/src/services/native-runtime/native-safe-replacement.test.ts @@ -202,6 +202,19 @@ const support = await getEmbeddedPostgresTestSupport(); expect(legacyExecutionNeedsReconciliation({ ...run, status: "failed", resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } })).toBe(false); expect(legacyExecutionNeedsReconciliation({ ...run, status: "failed", scheduledRetryAttempt: 2, resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } })).toBe(true); }); + it("does not reopen a reconciled legacy run while continuation is pending", async () => { + const source = await seed(); + const [run] = await db.update(heartbeatRuns).set({ runtimeMode: "legacy", status: "cancelled" }).where(eq(heartbeatRuns.id, source.runId)).returning(); + await terminalizeLegacyExecution({ db, run, status: "cancelled" }); + const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, source.issueId)); + await markExecutionReconciliation(db, action!, { runId: source.runId, providerStopped: true, actionOutcome: "not_performed", outcomeEvidence: "The deterministic fixture has stopped and only printed output." }, "board"); + await db.update(issueRecoveryActions).set({ status: "resolved" }).where(eq(issueRecoveryActions.id, action!.id)); + await terminalizeLegacyExecution({ db, run, status: "cancelled" }); + const actions = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, source.issueId)); + expect(actions).toHaveLength(1); + expect(actions[0]).toMatchObject({ status: "resolved", evidence: { continuationDelivery: "pending", executionReconciliation: { runId: source.runId } } }); + await db.update(issueRecoveryActions).set({ evidence: { ...actions[0]!.evidence, continuationDelivery: "invalidated" } }).where(eq(issueRecoveryActions.id, action!.id)); + }); it("surfaces a failed current reviewer without transferring the original assignment", async () => { const source = await seed(); const reviewerId = randomUUID(); diff --git a/tests/e2e/composer-stop.spec.ts b/tests/e2e/composer-stop.spec.ts new file mode 100644 index 0000000000..5b8bfcce14 --- /dev/null +++ b/tests/e2e/composer-stop.spec.ts @@ -0,0 +1,405 @@ +import { readFile } from "node:fs/promises"; +import { + test, + expect, + type APIRequestContext, + type APIResponse, + type Page, +} from "@playwright/test"; + +async function json(response: APIResponse) { + const text = await response.text(); + expect(response.ok(), `${response.url()}: ${response.status()} ${text}`).toBe( + true, + ); + return JSON.parse(text); +} +async function task( + request: APIRequestContext, + companyId: string, + data: Record, +) { + return json( + await request.post(`/api/companies/${companyId}/issues`, { + data: { title: "Composer stop acceptance", status: "backlog", ...data }, + }), + ); +} +async function running( + request: APIRequestContext, + issueId: string, + adapter: "process" | "paperclip_runner", +) { + let run: + | { id: string; status: string; runtimeMode?: string; processPid?: number } + | undefined; + await expect + .poll( + async () => { + const runs = await json( + await request.get(`/api/issues/${issueId}/live-runs`), + ); + run = runs.find( + (candidate: { status: string }) => candidate.status === "running", + ); + return !!run; + }, + { timeout: 30_000 }, + ) + .toBe(true); + let fullRun = await json(await request.get(`/api/heartbeat-runs/${run!.id}`)); + await expect + .poll( + async () => { + fullRun = await json( + await request.get(`/api/heartbeat-runs/${run!.id}`), + ); + return fullRun.runtimeMode; + }, + { timeout: 15_000 }, + ) + .toBe(adapter === "process" ? "legacy" : "native"); + if (adapter === "process") { + await expect + .poll( + async () => { + fullRun = await json( + await request.get(`/api/heartbeat-runs/${run!.id}`), + ); + return fullRun.processPid; + }, + { timeout: 15_000 }, + ) + .toBeTruthy(); + } + return fullRun; +} +async function reconcileDemoExecution( + request: APIRequestContext, + issueId: string, + runId: string, +) { + // These deterministic fixtures only print output. No external action occurred. + // Master requires recorded outcomes before a cancelled provider can restart. + const activity = await json( + await request.get(`/api/issues/${issueId}/activity`), + ); + const settled = activity.find( + (entry: { action: string; runId: string }) => + entry.action === "issue.execution_recovery_settled" && + entry.runId === runId, + ); + const recovery = await json( + await request.get(`/api/issues/${issueId}/recovery-actions`), + ); + const actionId = recovery.active?.id ?? settled?.details?.recoveryActionId; + expect(actionId).toBeTruthy(); + await json( + await request.post(`/api/issues/${issueId}/recovery-actions/resolve`, { + data: { + actionId, + outcome: "restored", + sourceIssueStatus: "todo", + executionReconciliation: { + runId, + providerStopped: true, + actionOutcome: "not_performed", + outcomeEvidence: + "The deterministic acceptance fixture only emits console/protocol output. The verified stopped process performed no external actions.", + }, + }, + }), + ); +} + +async function menu(page: Page, action: string) { + await page + .getByRole("button", { name: "More task actions", exact: true }) + .click(); + await page + .locator('[data-slot="popover-content"]') + .getByRole("button", { name: action, exact: true }) + .click(); +} +function processAlive(pid: number) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +test.setTimeout(120_000); + +for (const adapter of ["process", "paperclip_runner"] as const) { + test(`${adapter}: queue, composer Stop, subtree pause/cancel, and resume`, async ({ + page, + request, + }, testInfo) => { + test.skip( + adapter === "paperclip_runner" && !process.env.PAPERCLIP_STOP_FAKE_CODEX, + "Set PAPERCLIP_STOP_FAKE_CODEX and PAPERCLIP_RUNNER_BINARY for real runnerd with the deterministic provider.", + ); + const company = await json( + await request.post("/api/companies", { + data: { name: `Composer Stop ${adapter} ${Date.now()}` }, + }), + ); + const originalSettings = await json( + await request.get("/api/instance/settings/experimental"), + ); + try { + await json( + await request.patch("/api/instance/settings/experimental", { + data: { enableClassicTaskInterface: false, enableNativeRunner: true }, + }), + ); + async function agent(name: string) { + return json( + await request.post(`/api/companies/${company.id}/agents`, { + data: { + name, + role: "engineer", + adapterType: adapter, + adapterConfig: + adapter === "process" + ? { + command: process.execPath, + args: [ + "-e", + "console.log('stop fixture ready'); setInterval(() => console.log('working'), 200);", + ], + graceSec: 1, + } + : { provider: "codex", model: "gpt-5.1-codex-mini" }, + runtimeConfig: { + heartbeat: { enabled: false, wakeOnDemand: true }, + }, + }, + }), + ); + } + const owner = await agent("Stop fixture parent"); + const childOwner = await agent("Stop fixture child"); + const otherOwner = await agent("Stop fixture unrelated"); + const parent = await task(request, company.id, { + assigneeAgentId: owner.id, + }); + const child = await task(request, company.id, { + title: "Child work", + parentId: parent.id, + assigneeAgentId: childOwner.id, + }); + const completed = await task(request, company.id, { + title: "Finished child", + parentId: parent.id, + status: "done", + }); + const other = await task(request, company.id, { + title: "Unrelated work", + assigneeAgentId: otherOwner.id, + }); + for (const issue of [parent, child, other]) + await json( + await request.patch(`/api/issues/${issue.id}`, { + data: { status: "todo" }, + }), + ); + const parentRun = await running(request, parent.id, adapter); + const childRun = await running(request, child.id, adapter); + const otherRun = await running(request, other.id, adapter); + if (adapter === "paperclip_runner") { + // A run row becomes live before its provider turn starts. Prove that the + // deterministic provider is active before attempting interruption. + await expect + .poll( + async () => { + const calls = await readFile( + process.env.PAPERCLIP_STOP_CODEX_LOG!, + "utf8", + ).catch(() => ""); + return calls.split("turn/start").length - 1; + }, + { timeout: 30_000 }, + ) + .toBeGreaterThanOrEqual(3); + } + expect(parentRun.runtimeMode).toBe( + adapter === "process" ? "legacy" : "native", + ); + await page.goto(`/${company.issuePrefix}/issues/${parent.identifier}`); + const stop = page.getByRole("button", { name: "Stop", exact: true }); + await expect(stop).toBeVisible({ timeout: 30_000 }); + const editor = page.getByRole("textbox", { name: "editable markdown" }); + await editor.fill("Please check mobile too."); + await expect(stop).toHaveCount(0); + await page.getByRole("button", { name: "Send", exact: true }).click(); + await expect(stop).toBeVisible(); + const comments = await json( + await request.get(`/api/issues/${parent.id}/comments`), + ); + expect(JSON.stringify(comments)).toContain("Please check mobile too."); + const queue = await json( + await request.get(`/api/issues/${parent.id}/queued-comments`), + ); + expect(JSON.stringify(queue.entries)).toContain( + "Please check mobile too.", + ); + + let dispatchedAt = 0; + page.on("request", (req) => { + if (req.method() === "POST" && req.url().endsWith("/tree-holds")) + dispatchedAt = Date.now(); + }); + const clickedAt = Date.now(); + await stop.click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect( + page.getByRole("button", { name: "Dismiss notification" }), + ).toHaveCount(0); + for (const run of [parentRun, childRun]) { + await expect + .poll( + async () => + (await json(await request.get(`/api/heartbeat-runs/${run.id}`))) + .status, + { timeout: 35_000 }, + ) + .toBe("cancelled"); + } + const stoppedAt = Date.now(); + expect(dispatchedAt - clickedAt).toBeLessThan(2000); + expect(dispatchedAt).toBeGreaterThan(0); + if (adapter === "process") { + expect(parentRun.processPid).toBeTruthy(); + await expect + .poll(() => processAlive(parentRun.processPid), { timeout: 3000 }) + .toBe(false); + await expect + .poll(() => processAlive(childRun.processPid), { timeout: 3000 }) + .toBe(false); + } else { + const finalRun = await json( + await request.get(`/api/heartbeat-runs/${parentRun.id}`), + ); + expect(finalRun.resultJson?.nativeCancellation?.dispatchState).toBe( + "acknowledged", + ); + expect( + await readFile(process.env.PAPERCLIP_STOP_CODEX_LOG!, "utf8"), + ).toContain("turn/interrupt"); + } + await testInfo.attach(`${adapter}-timing`, { + body: JSON.stringify({ + clickToRequestMs: dispatchedAt - clickedAt, + requestToStoppedMs: stoppedAt - dispatchedAt, + }), + contentType: "application/json", + }); + expect( + ( + await json( + await request.get(`/api/issues/${parent.id}/tree-control/state`), + ) + ).activePauseHold, + ).toBeTruthy(); + expect( + (await json(await request.get(`/api/heartbeat-runs/${otherRun.id}`))) + .status, + ).toBe("running"); + await expect( + page.getByText("Subtree is paused.", { exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Dismiss notification" }), + ).toHaveCount(0); + if (adapter === "paperclip_runner") { + await expect( + page.getByRole("button", { name: /^Run cancelled/ }), + ).toHaveClass(/text-muted-foreground/); + } + await page.reload(); + await expect( + page.getByText("Subtree is paused.", { exact: true }), + ).toBeVisible(); + // Cross the isolated server's ten-second scheduler interval repeatedly. + for (let i = 0; i < 3; i++) { + await new Promise((resolve) => setTimeout(resolve, 10_000)); + expect( + await json(await request.get(`/api/issues/${parent.id}/live-runs`)), + ).toEqual([]); + expect( + await json(await request.get(`/api/issues/${child.id}/live-runs`)), + ).toEqual([]); + } + await menu(page, "Resume subtree"); + await page.getByRole("dialog").getByRole("checkbox").check(); + await page + .getByRole("dialog") + .getByRole("button", { name: "Resume subtree", exact: true }) + .click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + // The recovery policy parks these stopped tasks. Releasing the hold must + // leave them parked, even with Wake agents selected; no implicit replay. + expect(await json(await request.get(`/api/issues/${parent.id}/live-runs`))).toEqual([]); + expect(await json(await request.get(`/api/issues/${child.id}/live-runs`))).toEqual([]); + await reconcileDemoExecution(request, parent.id, parentRun.id); + await reconcileDemoExecution(request, child.id, childRun.id); + await running(request, parent.id, adapter); + await running(request, child.id, adapter); + await menu(page, "Pause subtree"); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect( + page.getByText("Subtree is paused.", { exact: true }), + ).toBeVisible(); + await menu(page, "Cancel subtree..."); + const dialog = page.getByRole("dialog"); + await expect( + dialog.getByRole("heading", { name: "Cancel subtree?" }), + ).toBeVisible(); + await expect( + dialog.locator('textarea, input[type="checkbox"]'), + ).toHaveCount(0); + await dialog.getByRole("button", { name: "Keep tasks" }).click(); + expect( + (await json(await request.get(`/api/issues/${parent.id}`))).status, + ).not.toBe("cancelled"); + await menu(page, "Cancel subtree..."); + await dialog + .getByRole("button", { name: "Cancel 2 tasks", exact: true }) + .click(); + await expect + .poll( + async () => + (await json(await request.get(`/api/issues/${child.id}`))).status, + ) + .toBe("cancelled"); + expect( + (await json(await request.get(`/api/issues/${parent.id}`))).status, + ).toBe("cancelled"); + expect( + (await json(await request.get(`/api/issues/${completed.id}`))).status, + ).toBe("done"); + expect( + (await json(await request.get(`/api/heartbeat-runs/${otherRun.id}`))) + .status, + ).toBe("running"); + await page.screenshot({ + path: testInfo.outputPath(`${adapter}-cancelled.png`), + }); + } finally { + // The company is disposable and scoped to this test invocation. + await request.patch(`/api/companies/${company.id}`, { + data: { status: "archived" }, + }); + await request.patch("/api/instance/settings/experimental", { + data: { + enableClassicTaskInterface: + originalSettings.enableClassicTaskInterface, + enableNativeRunner: originalSettings.enableNativeRunner, + }, + }); + } + }); +} diff --git a/tests/e2e/playwright-composer-stop.config.ts b/tests/e2e/playwright-composer-stop.config.ts new file mode 100644 index 0000000000..41d0319468 --- /dev/null +++ b/tests/e2e/playwright-composer-stop.config.ts @@ -0,0 +1,42 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { defineConfig } from "@playwright/test"; +import base from "./playwright.config"; + +// Opt-in native coverage uses real runnerd with the repo's deterministic Codex +// protocol fixture. Never let this suite fall through to a logged-in real Codex. +const fixture = process.env.PAPERCLIP_STOP_FAKE_CODEX; +const fixtureDir = fs.mkdtempSync( + path.join(os.tmpdir(), "composer-stop-provider-"), +); +const logPath = + process.env.PAPERCLIP_STOP_CODEX_LOG ?? + path.join(fixtureDir, "codex-calls.log"); +process.env.PAPERCLIP_STOP_CODEX_LOG = logPath; +if (fixture) { + if (!path.isAbsolute(fixture) || !fs.existsSync(fixture)) + throw new Error( + "PAPERCLIP_STOP_FAKE_CODEX must name the built fake-codex-app-server binary", + ); + const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + fs.writeFileSync( + path.join(fixtureDir, "codex"), + `#!/bin/sh\nexec ${quote(fixture)} --state-file ${quote(fixtureDir)}/state-$$.json --hold-turn --call-log ${quote(logPath)} "$@"\n`, + { mode: 0o755 }, + ); +} +const server = base.webServer as Exclude; +export default defineConfig({ + ...base, + testMatch: "composer-stop.spec.ts", + timeout: 90_000, + webServer: { + ...server, + env: { + ...server?.env, + HEARTBEAT_SCHEDULER_INTERVAL_MS: "10000", + PATH: `${fixtureDir}${path.delimiter}${process.env.PATH ?? ""}`, + }, + }, +}); diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index f2394b8c54..15a88a28d8 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -31,6 +31,7 @@ import type { RunnerGoalProjection, PreviewIssueTreeControl, ReleaseIssueTreeHold, + ReleaseIssueTreeHoldResponse, UpsertIssueWatchdog, UpsertIssueDocument, } from "@paperclipai/shared"; @@ -223,7 +224,7 @@ export const issuesApi = { } | null; }>(`/issues/${id}/tree-control/state`), releaseTreeHold: (id: string, holdId: string, data: ReleaseIssueTreeHold) => - api.post(`/issues/${id}/tree-holds/${holdId}/release`, data), + api.post(`/issues/${id}/tree-holds/${holdId}/release`, data), checkMonitorNow: (id: string) => api.post<{ ok: true }>(`/issues/${id}/monitor/check-now`, {}), retryScheduledRetryNow: (id: string) => api.post(`/issues/${id}/scheduled-retry/retry-now`, {}), diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 25c7847b6a..541ba39d85 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -176,6 +176,7 @@ import { summarizeToolResult, } from "../lib/transcriptPresentation"; import { buildAgentMentionHref } from "@paperclipai/shared"; +import { useComposerStop } from "@/hooks/useComposerStop"; import { cn, formatDateTime, formatShortDate } from "../lib/utils"; import { liveBlueBadge } from "../lib/status-colors"; import { nextWorkMode, titleForPendingWorkMode, workModeMetaFor, workModeMetaList } from "../lib/work-mode-meta"; @@ -418,6 +419,9 @@ export interface IssueChatComposerHandle { } interface IssueChatComposerProps { + onStop?: () => Promise; + stopPending?: boolean; + stopScope?: "leaf" | "subtree"; onImageUpload?: (file: File) => Promise; onAttachImage?: (file: File) => Promise; draftKey?: string; @@ -510,6 +514,8 @@ interface IssueChatThreadProps { ) => Promise; onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise; onCancelRun?: () => Promise; + stopPending?: boolean; + stopScope?: "leaf" | "subtree"; onStopRun?: (runId: string) => Promise; stopRunLabel?: string; stoppingRunLabel?: string; @@ -3862,6 +3868,9 @@ function areIssueChatMessageRowPropsEqual( } const IssueChatComposer = forwardRef(function IssueChatComposer({ + onStop, + stopPending, + stopScope = "leaf", onImageUpload, onAttachImage, draftKey, @@ -3881,6 +3890,7 @@ const IssueChatComposer = forwardRef ) : null} - + {showStop ? ( + + ) : ( + + )} + {stopControl.error ? ( +

{stopControl.error}

+ ) : null} + {/* No-assignee warning modal (PAP-128 C): replaces the old press-Send-again toast. */} { ); expect(container.textContent).toContain("Work was in progress."); + expect(container.querySelector('[data-testid="task-chat-collapsible-marker"] button')?.classList).toContain("text-muted-foreground"); + expect(container.querySelector('[data-testid="task-chat-collapsible-marker"] .text-destructive')).toBeNull(); expect( container.querySelector('[data-testid="task-chat-collapsible-marker"]') ?.textContent, @@ -2985,3 +2987,25 @@ describe("TaskChatThread live transcript", () => { expect(container.textContent).toContain("Worked"); }); }); + +describe("TaskChatThread composer execution controls", () => { + it.each(["process", "paperclip_runner"])("passes the task's stop action through for %s execution", async (adapterType) => { + const onStop = vi.fn(async () => {}); + const run = { + id: "task-run", status: "running", runtimeMode: adapterType === "process" ? "legacy" as const : "native" as const, + invocationSource: "issue", triggerDetail: null, startedAt: "2026-09-09T12:00:00Z", finishedAt: null, + createdAt: "2026-09-09T12:00:00Z", agentId: "agent-1", agentName: "Alex", adapterType, + }; + render( {}} issueStatus="in_progress" activeRun={run} onCancelRun={onStop} stopScope="subtree" />); + const button = container.querySelector('[data-testid="task-chat-composer-stop"]')!; + expect(button.title).toBe("Stop and pause subtree"); + await act(async () => { button.click(); }); + expect(onStop).toHaveBeenCalledOnce(); + render( {}} issueStatus="in_progress" activeRun={run} onCancelRun={onStop} stopPending />); + expect(container.querySelector('[data-testid="task-chat-composer-stop"]')?.disabled).toBe(true); + }); + it("does not offer Stop for settled work even when a callback is available", () => { + render( {}} issueStatus="todo" onCancelRun={vi.fn()} />); + expect(container.querySelector('[data-testid="task-chat-composer-stop"]')).toBeNull(); + }); +}); diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index d08d0ebca3..b4c14c94f1 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -478,6 +478,9 @@ export function TaskChatThread(props: TaskChatThreadProps) { userProfileMap, currentUserId, onAdd, + onCancelRun, + stopPending, + stopScope, issueWorkMode = "standard", onWorkModeChange, composerAccessory, @@ -1455,6 +1458,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { id, kind: "marker", variant: "interrupted", + tone: source.status === "cancelled" ? "neutral" : "error", label, detail, collapsible: true, @@ -2736,6 +2740,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
void; + onResume: () => void; + onCancel: () => void; + onRestore: () => void; +}) { + const itemClass = + "flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 disabled:opacity-50 disabled:pointer-events-none"; + return ( + <> + {canPause ? ( + + ) : null} + {canResume ? ( + + ) : null} + {canCancel ? ( + + ) : null} + {canRestore ? ( + + ) : null} + + ); +} + +export function TaskTreeControlDialog({ + open, + onOpenChange, + mode, + scope, + affectedCount, + affectedAgentCount, + loading, + error, + pending, + valid, + wakeAgents, + onWakeAgentsChange, + onRetry, + onApply, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + mode: "resume" | "cancel" | "restore"; + scope: "leaf" | "subtree"; + affectedCount: number; + affectedAgentCount: number; + loading: boolean; + error?: string | null; + pending: boolean; + valid: boolean; + wakeAgents: boolean; + onWakeAgentsChange: (wake: boolean) => void; + onRetry: () => void; + onApply: () => void; +}) { + const cancel = mode === "cancel"; + const tasks = `${affectedCount} task${affectedCount === 1 ? "" : "s"}`; + const title = cancel + ? "Cancel subtree?" + : mode === "restore" + ? "Restore subtree" + : scope === "leaf" + ? "Resume work" + : "Resume subtree"; + return ( + { + if (!pending) onOpenChange(next); + }} + > + + + {title} + + {loading + ? "Loading…" + : cancel + ? `${tasks} will be cancelled.` + : `${tasks} will ${mode === "restore" ? "be restored" : "resume"}.`} + + + {error ? ( +
+

+ {error} +

+ +
+ ) : null} + {!cancel ? ( + + ) : null} + + + + +
+
+ ); +} + +/** Quiet, persistent pause feedback shared by the task page and previews. */ +export function TaskPauseNotice({ + scope, + onResume, + pending, + className, + resumeLink, +}: { + scope: "leaf" | "subtree"; + onResume?: () => void; + pending?: boolean; + className?: string; + resumeLink?: ReactNode; +}) { + return ( +
+ + {scope === "subtree" ? "Subtree is paused." : "Task is paused."} + + {resumeLink ?? + (onResume ? ( + + ) : null)} +
+ ); +} diff --git a/ui/src/components/task-chat/TaskChatComposer.test.tsx b/ui/src/components/task-chat/TaskChatComposer.test.tsx index 8dd230c71a..ebefbc27af 100644 --- a/ui/src/components/task-chat/TaskChatComposer.test.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.test.tsx @@ -2051,3 +2051,84 @@ describe("TaskChatComposer", () => { }); }); }); + +describe("composer Stop", () => { + function stopButton() { return container.querySelector('[data-testid="task-chat-composer-stop"]'); } + + it("switches Stop to Send with text, whitespace back to Stop, without interrupting on keyboard submit", async () => { + const onStop = vi.fn(async () => {}); + const onAdd = vi.fn(async () => {}); + render(); + expect(stopButton()?.title).toBe("Stop and pause subtree"); + pressKey("Enter", { metaKey: true }); + expect(onStop).not.toHaveBeenCalled(); + expect(onAdd).not.toHaveBeenCalled(); + typeText("Check mobile too."); + expect(stopButton()).toBeNull(); + expect(sendButton().disabled).toBe(false); + flushSync(() => sendButton().click()); + await flushAsync(); + expect(onAdd).toHaveBeenCalledWith("Check mobile too.", undefined, undefined); + expect(onStop).not.toHaveBeenCalled(); + typeText(" \n "); + expect(stopButton()?.disabled).toBe(false); + }); + + it("blocks duplicate stops and preserves text typed while stopping", async () => { + let resolve!: () => void; + const onStop = vi.fn(() => new Promise((done) => { resolve = done; })); + render(); + const stop = stopButton()!; + flushSync(() => { stop.click(); stop.click(); }); + expect(onStop).toHaveBeenCalledTimes(1); + expect(stopButton()?.disabled).toBe(true); + expect(stopButton()?.getAttribute("aria-label")).toBe("Stopping…"); + typeText("Keep this draft."); + expect(sendButton().disabled).toBe(false); + resolve(); + await flushAsync(); + expect(editable().textContent).toBe("Keep this draft."); + }); + + it("reports failure without discarding the draft and permits retry", async () => { + const onStop = vi.fn().mockRejectedValueOnce(new Error("Unable to stop. Try again.")).mockResolvedValue(undefined); + render(); + flushSync(() => stopButton()!.click()); + await flushAsync(); + expect(container.querySelector('[role="alert"]')?.textContent).toBe("Unable to stop. Try again."); + flushSync(() => stopButton()!.click()); + await flushAsync(); + expect(onStop).toHaveBeenCalledTimes(2); + expect(container.querySelector('[role="alert"]')).toBeNull(); + }); + + it("retains disabled Send when idle or when stop permission is absent", () => { + render(); + expect(stopButton()).toBeNull(); + expect(sendButton().disabled).toBe(true); + render(); + expect(stopButton()?.disabled).toBe(true); + }); + + it("never turns a queued edit's save action into Stop", () => { + render(); + expect(stopButton()).toBeNull(); + expect(sendButton().getAttribute("aria-label")).toBe("Save queued message"); + }); + + it.each([false, true])("keeps attachments in send mode after upload (failed=%s)", async (failed) => { + let resolve!: (value: never) => void; + let reject!: (error: Error) => void; + const onAttachImage = vi.fn(() => new Promise((done, fail) => { resolve = done; reject = fail; })); + render(); + pasteFiles([new File(["notes"], "notes.txt", { type: "text/plain" })]); + await flushAsync(); + expect(stopButton()).toBeNull(); + expect(sendButton().disabled).toBe(true); + if (failed) reject(new Error("Upload failed")); + else resolve({ id: "attachment", contentPath: "/notes.txt", originalFilename: "notes.txt" } as never); + await flushAsync(); + expect(stopButton()).toBeNull(); + expect(sendButton().disabled).toBe(failed); + }); +}); diff --git a/ui/src/components/task-chat/TaskChatComposer.tsx b/ui/src/components/task-chat/TaskChatComposer.tsx index 31395d39e9..c224338c4d 100644 --- a/ui/src/components/task-chat/TaskChatComposer.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.tsx @@ -8,6 +8,7 @@ import { type ReactNode, } from "react"; import { cn } from "@/lib/utils"; +import { useComposerStop } from "@/hooks/useComposerStop"; import { useStreamlinedTaskChatPresentation } from "./presentation-mode"; import { DRAFT_DEBOUNCE_MS, @@ -17,6 +18,7 @@ import { } from "@/lib/composer-draft"; import { ArrowUp, + Square, Check, ChevronDown, CircleHelp, @@ -91,6 +93,9 @@ interface TaskChatComposerProps { reopen?: boolean, reassignment?: CommentReassignment, ) => Promise | void; + onStop?: () => Promise; + stopPending?: boolean; + stopScope?: "leaf" | "subtree"; workMode: IssueWorkMode; onWorkModeChange?: (mode: IssueWorkMode) => Promise | void; disabled?: boolean; @@ -346,6 +351,9 @@ function escapeMarkdownLabel(name: string): string { */ export function TaskChatComposer({ onAdd, + onStop, + stopPending = false, + stopScope = "leaf", workMode, onWorkModeChange, disabled = false, @@ -373,6 +381,7 @@ export function TaskChatComposer({ onRunnerGoalReassign, }: TaskChatComposerProps) { const streamlined = useStreamlinedTaskChatPresentation(); + const stopControl = useComposerStop(onStop, stopPending); const [body, setBody] = useState(() => (draftKey ? loadDraft(draftKey) : "")); const [submitting, setSubmitting] = useState(false); const [takeoverBusy, setTakeoverBusy] = useState(false); @@ -641,6 +650,12 @@ export function TaskChatComposer({ // Sending mid-upload would silently drop the pending file from the comment; // sending past a failed chip would discard the file the user selected and // clear its error state, so both hold submission until resolved or removed. + const showStop = + !queuedEdit && + !submitting && + body.trim().length === 0 && + attachments.length === 0 && + Boolean(onStop || stopControl.stopping); const uploadPending = attachments.some((item) => item.status === "uploading"); const uploadFailed = attachments.some((item) => item.status === "error"); const takeoverVisible = Boolean( @@ -1194,31 +1209,43 @@ export function TaskChatComposer({
+ {stopControl.error ? ( +

+ {stopControl.error} +

+ ) : null} )} diff --git a/ui/src/components/task-chat/TaskChatMarker.test.tsx b/ui/src/components/task-chat/TaskChatMarker.test.tsx index 79e4834e6f..bc64028414 100644 --- a/ui/src/components/task-chat/TaskChatMarker.test.tsx +++ b/ui/src/components/task-chat/TaskChatMarker.test.tsx @@ -42,7 +42,8 @@ describe("TaskChatMarker", () => { kind: "marker", variant: "interrupted", label: "Run failed", - detail: "The runner stopped before returning an answer (runner_exited).", + detail: + "The runner stopped before returning an answer (runner_exited).", collapsible: true, createdAtIso: "2026-09-01T12:00:00.000Z", runHref: "/agents/codex/runs/run-1", @@ -70,10 +71,39 @@ describe("TaskChatMarker", () => { expect(toggle.getAttribute("aria-expanded")).toBe("true"); expect(container.textContent).toContain("runner_exited"); expect( - container.querySelector('a[href="/agents/codex/runs/run-1"]')?.textContent, + container.querySelector('a[href="/agents/codex/runs/run-1"]') + ?.textContent, ).toBe("View run"); }); + it("keeps expected cancellation neutral when collapsed and expanded", () => { + flushSync(() => + root!.render( + + + , + ), + ); + const toggle = container.querySelector( + "button[aria-expanded]", + )!; + expect(toggle.classList).toContain("text-muted-foreground"); + expect(container.querySelector(".text-destructive")).toBeNull(); + flushSync(() => toggle.click()); + expect(container.textContent).toContain("Cancelled by you."); + expect(container.querySelector(".text-destructive")).toBeNull(); + }); + it("keeps Try again available without retry instructions in the detail", async () => { const onTryAgain = vi.fn(); flushSync(() => @@ -97,7 +127,9 @@ describe("TaskChatMarker", () => { const retry = container.querySelector( '[data-testid="task-chat-run-failed-try-again"]', )!; - expect(container.textContent).not.toContain("You can retry this message now"); + expect(container.textContent).not.toContain( + "You can retry this message now", + ); flushSync(() => retry.click()); await Promise.resolve(); expect(onTryAgain).toHaveBeenCalledTimes(1); diff --git a/ui/src/components/task-chat/TaskChatMarker.tsx b/ui/src/components/task-chat/TaskChatMarker.tsx index 635158b1b6..2163df9887 100644 --- a/ui/src/components/task-chat/TaskChatMarker.tsx +++ b/ui/src/components/task-chat/TaskChatMarker.tsx @@ -1,6 +1,6 @@ import { useId, useState } from "react"; import { cn } from "@/lib/utils"; -import { ChevronDown, CircleDot, OctagonX, Flag } from "lucide-react"; +import { ChevronDown, CircleDot, OctagonX, Square, Flag } from "lucide-react"; import { useStreamlinedTaskChatPresentation } from "./presentation-mode"; import type { TaskChatMarkerItem } from "./task-chat-model"; import { Button } from "@/components/ui/button"; @@ -30,8 +30,8 @@ export function TaskChatMarker({ const [open, setOpen] = useState(false); const detailsId = useId(); const streamlined = useStreamlinedTaskChatPresentation(); - const Icon = VARIANT_ICON[item.variant]; - const interrupted = item.variant === "interrupted"; + const Icon = item.tone === "neutral" ? Square : VARIANT_ICON[item.variant]; + const interrupted = item.variant === "interrupted" && item.tone !== "neutral"; const relative = item.createdAtIso ? timeAgo(item.createdAtIso) : undefined; const handleTryAgain = () => { void Promise.resolve() @@ -53,14 +53,17 @@ export function TaskChatMarker({ aria-expanded={open} aria-controls={detailsId} className={cn( - "flex min-w-0 items-center gap-1.5 rounded-md px-2 py-0.5 text-destructive", + "flex min-w-0 items-center gap-1.5 rounded-md px-2 py-0.5", + interrupted ? "text-destructive" : "text-muted-foreground", "hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50", )} > {item.label} {relative ? ( - · {relative} + + · {relative} + ) : null} {item.detail ? ( -
{item.detail}
+
+ {item.detail} +
) : null} {item.runHref || onTryAgain ? (
@@ -124,11 +129,25 @@ export function TaskChatMarker({ role={streamlined ? "separator" : undefined} aria-label={streamlined ? item.label : undefined} > - - + + {item.label} - {item.detail ? · {item.detail} : null} + {item.detail ? ( + · {item.detail} + ) : null} {onTryAgain ? (
); } diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index a92adbf7aa..1ba71a53a8 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -243,6 +243,8 @@ export interface TaskChatMarkerItem { detail?: string; /** Renders the marker as a quiet disclosure row with detail beneath it. */ collapsible?: boolean; + /** Expected cancellation is neutral; unexpected failures remain destructive. */ + tone?: "neutral" | "error"; runId?: string; createdAtIso?: string; runHref?: string; diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index b3194cd165..b6e3c48662 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -1046,6 +1046,7 @@ describe("LiveUpdatesProvider visible issue toast suppression", () => { it("suppresses run and agent status toasts for the assignee of the visible issue", () => { const queryClient = { getQueryData: (key: unknown) => { + if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.activeRun("PAP-759"))) return { id: "run-1" }; if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.detail("PAP-759"))) { return { id: "issue-1", @@ -1399,3 +1400,30 @@ describe("dispatchLiveEventToSubscribers", () => { expect(received).toEqual(["still-called"]); }); }); + +describe("task subtree notification context", () => { + const root = { id: "root", companyId: "company", identifier: "PAP-204", assigneeAgentId: "parent-agent" }; + const descendants = [{ id: "child", identifier: "PAP-205", assigneeAgentId: "child-agent", executionRunId: "child-run" }]; + const queryClient = { + getQueryData: (key: unknown) => { + if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.detail("PAP-204"))) return root; + if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.listByDescendantRoot("company", "root"))) return descendants; + }, + }; + it("suppresses descendant cancellation and activity on the visible subtree", () => { + expect(__liveUpdatesTestUtils.shouldSuppressRunStatusToastForVisibleIssue(queryClient as never, "/PAP/issues/PAP-204", { runId: "child-run", agentId: "child-agent", status: "cancelled" }, { isForegrounded: true })).toBe(true); + expect(__liveUpdatesTestUtils.shouldSuppressActivityToastForVisibleIssue(queryClient as never, "/PAP/issues/PAP-204", { entityType: "issue", entityId: "child" }, { isForegrounded: true })).toBe(true); + }); + it("uses the event task when a terminal run has already left the cache", () => { + expect(__liveUpdatesTestUtils.shouldSuppressRunStatusToastForVisibleIssue(queryClient as never, "/PAP/issues/PAP-204", { issueId: "root", runId: "finished-run" }, { isForegrounded: true })).toBe(true); + expect(__liveUpdatesTestUtils.shouldSuppressRunStatusToastForVisibleIssue(queryClient as never, "/PAP/issues/PAP-204", { issueId: "unrelated", runId: "child-run", agentId: "child-agent" }, { isForegrounded: true })).toBe(false); + }); + it("retains notifications for unrelated tasks and background pages", () => { + for (const [agentId, foregrounded] of [["other-agent", true], ["child-agent", true], ["child-agent", false]] as const) { + expect(__liveUpdatesTestUtils.shouldSuppressRunStatusToastForVisibleIssue(queryClient as never, "/PAP/issues/PAP-204", { agentId, runId: "another-run", status: "cancelled" }, { isForegrounded: foregrounded })).toBe(false); + } + }); + it("suppresses the run shown on its own run detail page", () => { + expect(__liveUpdatesTestUtils.shouldSuppressRunStatusToastForVisibleIssue(queryClient as never, "/PAP/agents/alex/runs/child-run", { runId: "child-run" }, { isForegrounded: true })).toBe(true); + }); +}); diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 729c258471..05e9584808 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -163,6 +163,9 @@ interface VisibleIssueRouteContext { issueRefs: Set; assigneeAgentId: string | null; runIds: Set; + subtreeIssueRefs: Set; + subtreeAgentIds: Set; + subtreeRunIds: Set; } function resolveIssueQueryRefs( @@ -265,11 +268,37 @@ function resolveVisibleIssueRouteContext( if (run.runId) runIds.add(run.runId); } + // Notifications about descendants belong to the subtree already on screen. + // Keep these separate from issueRefs, which also drives cache invalidation. + const subtreeIssueRefs = new Set(issueRefs); + const subtreeAgentIds = new Set(); + const subtreeRunIds = new Set(runIds); + if (issue?.companyId && issue.id) { + const descendants = queryClient.getQueryData( + queryKeys.issues.listByDescendantRoot(issue.companyId, issue.id), + ) ?? []; + for (const descendant of descendants) { + subtreeIssueRefs.add(descendant.id); + if (descendant.identifier) subtreeIssueRefs.add(descendant.identifier); + if (descendant.assigneeAgentId) subtreeAgentIds.add(descendant.assigneeAgentId); + if (descendant.executionRunId) subtreeRunIds.add(descendant.executionRunId); + } + } + + if (issue?.companyId) { + const companyRuns = queryClient.getQueryData(queryKeys.liveRuns(issue.companyId)) ?? []; + for (const run of companyRuns) { + if (run.issueId && subtreeIssueRefs.has(run.issueId)) subtreeRunIds.add(run.id); + } + } return { routeIssueRef: issueRef, issueRefs, assigneeAgentId: issue?.assigneeAgentId ?? null, runIds, + subtreeIssueRefs, + subtreeAgentIds, + subtreeRunIds, }; } @@ -300,7 +329,7 @@ function shouldSuppressActivityToastForVisibleIssue( const context = resolveVisibleIssueRouteContext(queryClient, pathname, options); if (!context) return false; - return overlaps(context.issueRefs, buildIssueRefsForPayload(entityId, readRecord(payload.details))); + return overlaps(context.subtreeIssueRefs, buildIssueRefsForPayload(entityId, readRecord(payload.details))); } function shouldSuppressRunStatusToastForVisibleIssue( @@ -309,14 +338,19 @@ function shouldSuppressRunStatusToastForVisibleIssue( payload: Record, options?: VisibleRouteOptions, ): boolean { + if (!(options?.isForegrounded ?? isPageForegrounded())) return false; + const visibleRunId = toCompanyRelativePath(pathname).match(/^\/agents\/[^/]+\/runs\/([^/]+)/)?.[1]; + if (visibleRunId && decodeURIComponent(visibleRunId) === readString(payload.runId)) return true; const context = resolveVisibleIssueRouteContext(queryClient, pathname, options); if (!context) return false; + const issueId = readString(payload.issueId); + if (issueId) return context.subtreeIssueRefs.has(issueId); const runId = readString(payload.runId); - if (runId && context.runIds.has(runId)) return true; + if (runId) return context.subtreeRunIds.has(runId); const agentId = readString(payload.agentId); - return !!agentId && !!context.assigneeAgentId && agentId === context.assigneeAgentId; + return !!agentId && (agentId === context.assigneeAgentId || context.subtreeAgentIds.has(agentId)); } function invalidateVisibleIssueRunQueries( @@ -528,10 +562,10 @@ function shouldSuppressAgentStatusToastForVisibleIssue( options?: VisibleRouteOptions, ): boolean { const context = resolveVisibleIssueRouteContext(queryClient, pathname, options); - if (!context?.assigneeAgentId) return false; + if (!context) return false; const agentId = readString(payload.agentId); - return !!agentId && agentId === context.assigneeAgentId; + return !!agentId && (agentId === context.assigneeAgentId || context.subtreeAgentIds.has(agentId)); } function shouldDeferIssueRefetchForVisibleAgentActivity( @@ -828,7 +862,7 @@ function buildRunStatusToast( const error = readString(payload.error); const triggerDetail = readString(payload.triggerDetail); const name = nameOf(agentId) ?? `Agent ${shortId(agentId)}`; - const tone = status === "succeeded" ? "success" : status === "cancelled" ? "warn" : "error"; + const tone = status === "succeeded" ? "success" : status === "cancelled" ? "info" : "error"; const statusLabel = status === "succeeded" ? "succeeded" : status === "failed" ? "failed" @@ -1198,6 +1232,8 @@ function handleLiveEvent( const nameOf = (id: string) => resolveAgentName(queryClient, expectedCompanyId, id); const payload = event.payload ?? {}; + // Resolve membership before terminal lifecycle patches remove live-run rows. + const suppressRunToast = event.type === "heartbeat.run.status" && shouldSuppressRunStatusToastForVisibleIssue(queryClient, pathname, payload); const liveStatusPatch = readRunLiveStatusPatchFromPayload(payload, event.createdAt, event.type); if (liveStatusPatch) { applyRunLiveStatusPatchToCaches(queryClient, expectedCompanyId, pathname, liveStatusPatch); @@ -1221,7 +1257,7 @@ function handleLiveEvent( const toast = buildRunStatusToast(payload, nameOf); if ( toast && - !shouldSuppressRunStatusToastForVisibleIssue(queryClient, pathname, payload) + !suppressRunToast ) { gatedPushToast(gate, pushToast, "run-status", toast); } diff --git a/ui/src/hooks/useComposerStop.ts b/ui/src/hooks/useComposerStop.ts new file mode 100644 index 0000000000..76ed6883d1 --- /dev/null +++ b/ui/src/hooks/useComposerStop.ts @@ -0,0 +1,27 @@ +import { useRef, useState } from "react"; + +/** Keeps stop requests independent of draft submission and prevents double clicks. */ +export function useComposerStop(onStop?: () => Promise, pending = false) { + const inFlight = useRef(false); + const [stopping, setStopping] = useState(false); + const [error, setError] = useState(null); + + async function stop() { + if (!onStop || inFlight.current || pending) return; + inFlight.current = true; + setStopping(true); + setError(null); + try { + await onStop(); + } catch (err) { + setError( + err instanceof Error ? err.message : "Unable to stop. Try again.", + ); + } finally { + inFlight.current = false; + setStopping(false); + } + } + + return { stop, stopping: stopping || pending, error }; +} diff --git a/ui/src/lib/wait-for-stopped-runs.test.ts b/ui/src/lib/wait-for-stopped-runs.test.ts new file mode 100644 index 0000000000..ba2c6ed55b --- /dev/null +++ b/ui/src/lib/wait-for-stopped-runs.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { HeartbeatRun } from "@paperclipai/shared"; +import { waitForStoppedRuns } from "./wait-for-stopped-runs"; + +function run(id: string, status: HeartbeatRun["status"]) { + return { id, status } as HeartbeatRun; +} +afterEach(() => vi.useRealTimers()); +describe("stop confirmation", () => { + it("waits for native cancellation acknowledgment after the run becomes terminal", async () => { + vi.useFakeTimers(); + const native = { ...run("native", "cancelled"), runtimeMode: "native" }; + const getRun = vi + .fn() + .mockResolvedValueOnce({ + ...native, + resultJson: { nativeCancellation: { dispatchState: "pending" } }, + }) + .mockResolvedValueOnce({ + ...native, + resultJson: { nativeCancellation: { dispatchState: "acknowledged" } }, + }); + const finished = vi.fn(); + const result = waitForStoppedRuns(["native"], { getRun }).then(finished); + await vi.advanceTimersByTimeAsync(0); + expect(finished).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(500); + await result; + expect(finished).toHaveBeenCalledOnce(); + }); + it("waits for every affected run, including scheduled retries", async () => { + vi.useFakeTimers(); + const getRun = vi + .fn() + .mockResolvedValueOnce(run("native", "running")) + .mockResolvedValueOnce(run("legacy", "scheduled_retry")) + .mockResolvedValueOnce(run("native", "cancelled")) + .mockResolvedValueOnce(run("legacy", "running")) + .mockResolvedValueOnce(run("legacy", "cancelled")); + const finished = vi.fn(); + const result = waitForStoppedRuns(["native", "legacy", "native"], { + getRun, + }).then(finished); + await vi.advanceTimersByTimeAsync(500); + expect(finished).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(500); + await result; + expect(finished).toHaveBeenCalledOnce(); + expect(getRun).toHaveBeenCalledTimes(5); + }); + it("does not claim termination when a paused run continues", async () => { + vi.useFakeTimers(); + const result = waitForStoppedRuns(["active"], { + getRun: vi.fn().mockResolvedValue(run("active", "running")), + timeoutMs: 1000, + }); + const assertion = expect(result).rejects.toThrow( + "pause was saved, but work is still stopping", + ); + await vi.advanceTimersByTimeAsync(1000); + await assertion; + }); + it("distinguishes a verification error from a failed pause", async () => { + await expect( + waitForStoppedRuns(["active"], { + getRun: vi.fn().mockRejectedValue(new Error("offline")), + }), + ).rejects.toThrow("pause was saved, but stopping could not be verified"); + }); +}); + +it("bounds a hung status request instead of leaving Stop pending forever", async () => { + vi.useFakeTimers(); + const result = waitForStoppedRuns(["active"], { + getRun: () => new Promise(() => {}), + timeoutMs: 1000, + }); + const assertion = expect(result).rejects.toThrow( + "stopping could not be verified", + ); + await vi.advanceTimersByTimeAsync(1000); + await assertion; +}); diff --git a/ui/src/lib/wait-for-stopped-runs.ts b/ui/src/lib/wait-for-stopped-runs.ts new file mode 100644 index 0000000000..c3e2bb5373 --- /dev/null +++ b/ui/src/lib/wait-for-stopped-runs.ts @@ -0,0 +1,61 @@ +import { heartbeatsApi } from "../api/heartbeats"; + +const LIVE_STATUSES = new Set(["queued", "running", "scheduled_retry"]); + +/** A tree hold response acknowledges the hold, not necessarily runner termination. */ +export async function waitForStoppedRuns( + runIds: string[], + options: { + getRun?: typeof heartbeatsApi.get; + timeoutMs?: number; + intervalMs?: number; + } = {}, +) { + const getRun = options.getRun ?? heartbeatsApi.get; + const deadline = Date.now() + (options.timeoutMs ?? 30_000); + let remaining = [...new Set(runIds)]; + while (remaining.length > 0) { + let states; + let timeout: ReturnType | undefined; + try { + states = await Promise.race([ + Promise.all(remaining.map((id) => getRun(id))), + new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error("Stop verification timed out")), + Math.max(0, deadline - Date.now()), + ); + }), + ]); + } catch { + throw new Error( + "The pause was saved, but stopping could not be verified. Refresh and try Stop again if work is still running.", + ); + } finally { + clearTimeout(timeout); + } + remaining = states + .filter((run) => { + if (LIVE_STATUSES.has(run.status)) return true; + if (!("runtimeMode" in run) || run.runtimeMode !== "native" || run.status !== "cancelled") + return false; + const cancellation = run.resultJson?.nativeCancellation; + return ( + !cancellation || + typeof cancellation !== "object" || + !("dispatchState" in cancellation) || + cancellation.dispatchState !== "acknowledged" + ); + }) + .map((run) => run.id); + if (remaining.length === 0) return; + if (Date.now() >= deadline) { + throw new Error( + "The pause was saved, but work is still stopping. Try Stop again if it continues.", + ); + } + await new Promise((resolve) => + setTimeout(resolve, options.intervalMs ?? 500), + ); + } +} diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx index 33e412861c..cf4f63a54d 100644 --- a/ui/src/pages/DesignGuide.tsx +++ b/ui/src/pages/DesignGuide.tsx @@ -1,4 +1,7 @@ import { RepositoryEditor } from "@/components/RepositoryEditor"; +import { TaskChatMarker } from "@/components/task-chat/TaskChatMarker"; +import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer"; +import { TaskPauseNotice, TaskTreeControlDialog, TaskTreeControlMenuItems } from "@/components/TaskTreeControls"; import { useState } from "react"; import { ServicesList } from "./apps/app-detail/ServicesPanel"; import { ComposioProvenanceChip } from "./apps/ComposioProvenanceChip"; @@ -448,6 +451,27 @@ function Swatch({ name, cssVar }: { name: string; cssVar: string }) { /* Page */ /* ------------------------------------------------------------------ */ +function TaskExecutionControlsExample() { + const [running, setRunning] = useState(true); + const [dialogMode, setDialogMode] = useState<"resume" | "cancel" | "restore" | null>(null); + const [wake, setWake] = useState(true); + return
+
+ setRunning(false)} onResume={() => setDialogMode("resume")} + onCancel={() => setDialogMode("cancel")} onRestore={() => setDialogMode("restore")} /> +
+

{running ? "Running: type to switch Stop to Send." : "Paused: resume from the menu."}

+ {!running ? setDialogMode("resume")} /> : null} + {!running ? : null} + {}} workMode="standard" stopScope="subtree" onStop={running ? async () => setRunning(false) : undefined} /> + { if (!open) setDialogMode(null); }} + mode={dialogMode ?? "cancel"} scope="subtree" affectedCount={3} affectedAgentCount={2} loading={false} pending={false} valid + wakeAgents={wake} onWakeAgentsChange={setWake} onRetry={() => {}} + onApply={() => { setRunning(dialogMode !== "cancel" && wake); setDialogMode(null); }} /> +
; +} + export function DesignGuide() { const [status, setStatus] = useState("todo"); const [priority, setPriority] = useState("medium"); @@ -519,6 +543,10 @@ export function DesignGuide() { +
+ +
+

CollectionToolbar owns shared geometry while each page owns its state and behavior. diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index ea4b7e8278..e34aa59cf9 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -4014,7 +4014,7 @@ describe("IssueDetail", () => { ).toBe("blocked"); }); - it("refreshes subtree pause state after resuming a hold", async () => { + it.each([false, true])("refreshes a released pause and shows partial wake failure=%s inline", async (wakeFailed) => { const childIssue = createIssue({ id: "child-1", parentId: "issue-1", @@ -4067,7 +4067,7 @@ describe("IssueDetail", () => { mockAgentsApi.list.mockResolvedValue([createAgent()]); mockIssuesApi.releaseTreeHold.mockImplementation(() => { activePauseHoldState = null; - return Promise.resolve(releasedHold); + return Promise.resolve({ ...releasedHold, ...(wakeFailed ? { wakeFailures: [{ issueId: "child-1", message: "Agent unavailable" }] } : {}) }); }); mockAuthApi.getSession.mockResolvedValue({ session: { userId: "user-1" }, @@ -4085,11 +4085,11 @@ describe("IssueDetail", () => { await flushReact(); await waitForAssertion(() => { - expect(container.textContent).toContain("Subtree pause is active."); + expect(container.textContent).toContain("Subtree is paused."); }); const pauseBannerTitle = Array.from(container.querySelectorAll("span")).find( - (element) => element.textContent?.trim() === "Subtree pause is active.", + (element) => element.textContent?.trim() === "Subtree is paused.", ); expect(pauseBannerTitle?.closest(".rounded-md")?.classList).toContain( "mt-3", @@ -4114,7 +4114,7 @@ describe("IssueDetail", () => { .filter((button) => button.textContent?.trim() === "Resume subtree") .at(-1); expect(applyResumeButton).toBeTruthy(); - expect(container.textContent).toContain("CodexCoder"); + expect(container.textContent).toContain("Wake affected agents (1)"); await act(async () => { applyResumeButton!.click(); @@ -4133,18 +4133,20 @@ describe("IssueDetail", () => { expect( mockIssuesApi.getTreeControlState.mock.calls.length, ).toBeGreaterThanOrEqual(2); - expect(mockPushToast).toHaveBeenCalledWith( + expect(mockPushToast).not.toHaveBeenCalledWith( expect.objectContaining({ title: "Subtree resumed", - body: "Ready to continue", }), ); await waitForAssertion(() => { - expect(container.textContent).not.toContain("Subtree pause is active."); + expect(container.textContent).not.toContain("Subtree is paused."); }); + if (wakeFailed) expect(container.querySelector('[role="alert"]')?.textContent).toContain("Pause released"); + else expect(container.querySelector('[role="alert"]')).toBeNull(); }); - it("uses simplified full-subtree pause controls", async () => { + it("pauses the subtree immediately without preview or confirmation", async () => { + mockIssuesApi.previewTreeControl.mockClear(); const childIssue = createIssue({ id: "child-1", parentId: "issue-1", @@ -4202,7 +4204,7 @@ describe("IssueDetail", () => { const pauseMenuButton = Array.from( container.querySelectorAll("button"), - ).find((button) => button.textContent?.trim() === "Pause subtree..."); + ).find((button) => button.textContent?.trim() === "Pause subtree"); expect(pauseMenuButton).toBeTruthy(); await act(async () => { @@ -4211,28 +4213,8 @@ describe("IssueDetail", () => { 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"); - expect(container.textContent).toContain("Completed child"); - expect(container.textContent).toContain("Complete"); - - 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.previewTreeControl).not.toHaveBeenCalled(); + expect(container.querySelector('[data-slot="dialog-content"]')).toBeNull(); expect(mockIssuesApi.createTreeHold).toHaveBeenCalledWith("PAP-1", { mode: "pause", reason: null, @@ -4240,7 +4222,7 @@ describe("IssueDetail", () => { }); }); - it("exposes leaf pause controls and routes issue active-run stop through Pause work", async () => { + it.each(["active-run", "composer"])("routes %s Stop and the menu through the same pause operation", async (control) => { const pausePreview = createPausePreview(); pausePreview.totals = { ...pausePreview.totals, @@ -4254,7 +4236,7 @@ describe("IssueDetail", () => { const pauseHold = createPauseHold({ id: "leaf-pause-hold-1", mode: "pause", - reason: "Paused from active run controls.", + reason: null, releasePolicy: { strategy: "manual", note: "leaf_pause" }, members: [], }); @@ -4272,6 +4254,10 @@ describe("IssueDetail", () => { preview: pausePreview, }); mockAgentsApi.list.mockResolvedValue([createAgent()]); + mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([{ + id: "run-active-1", agentId: "agent-1", status: "running", runtimeMode: "legacy", + issueId: "issue-1", adapterType: "process", + }]); mockAuthApi.getSession.mockResolvedValue({ session: { userId: "user-1" }, user: { id: "user-1" }, @@ -4295,17 +4281,21 @@ describe("IssueDetail", () => { const chatPauseButton = Array.from( container.querySelectorAll("button"), - ).find((button) => button.textContent?.trim() === "Pause work"); + ).filter((button) => button.textContent?.trim() === "Pause work").at(-1); expect(chatPauseButton).toBeTruthy(); await act(async () => { - chatPauseButton!.click(); + if (control === "composer") { + const stop = mockIssueChatThreadRender.mock.calls.at(-1)?.[0].onCancelRun; + expect(stop).toBeTypeOf("function"); + await stop(); + } else chatPauseButton!.click(); }); await flushReact(); expect(mockIssuesApi.createTreeHold).toHaveBeenCalledWith("PAP-1", { mode: "pause", - reason: "Paused from active run controls.", + reason: null, releasePolicy: { strategy: "manual", note: "leaf_pause" }, metadata: { source: "issue_active_run_control", runId: "run-active-1" }, }); @@ -4323,8 +4313,22 @@ describe("IssueDetail", () => { const pauseMenuButton = Array.from( container.querySelectorAll("button"), - ).find((button) => button.textContent?.trim() === "Pause work..."); + ).find((button) => button.textContent?.trim() === "Pause work"); expect(pauseMenuButton).toBeTruthy(); + await act(async () => { pauseMenuButton!.click(); }); + await flushReact(); + expect(mockIssuesApi.createTreeHold).toHaveBeenLastCalledWith("PAP-1", { + mode: "pause", reason: null, + releasePolicy: { strategy: "manual", note: "leaf_pause" }, + }); + expect(mockPushToast).not.toHaveBeenCalled(); + mockIssuesApi.createTreeHold.mockRejectedValueOnce(new Error("Unable to pause. Try again.")); + await act(async () => { + await mockIssueChatThreadRender.mock.calls.at(-1)?.[0].onStopRun("run-active-1"); + }); + await flushReact(); + expect(container.querySelector('[role="alert"]')?.textContent).toContain("Unable to pause. Try again."); + expect(mockPushToast).not.toHaveBeenCalled(); }); it("routes live-run finalization actions through run cancellation before issue status update", async () => { @@ -4870,7 +4874,7 @@ describe("IssueDetail", () => { localStorage.removeItem("paperclip:issue-comment-draft:issue-1"); }); - it("renders Paused by board distinctly and defaults leaf resume to wake the assignee", async () => { + it("renders a quiet task pause notice and defaults leaf resume to wake the assignee", async () => { const activeHold = createPauseHold(); const releasedHold = createPauseHold({ status: "released", @@ -4918,9 +4922,9 @@ describe("IssueDetail", () => { await flushReact(); await waitForAssertion(() => { - expect(container.textContent).toContain("Paused by board."); + expect(container.textContent).toContain("Task is paused."); expect(container.textContent).toContain("in_review"); - expect(container.textContent).not.toContain("Subtree pause is active."); + expect(container.textContent).not.toContain("Subtree is paused."); }); const resumeButton = Array.from(container.querySelectorAll("button")).find( @@ -5044,14 +5048,11 @@ describe("IssueDetail", () => { mode: "restore", releasePolicy: { strategy: "manual" }, }); - expect(container.textContent).toContain( - "Restore tasks cancelled by this subtree operation so work can resume.", - ); - expect(container.textContent).toContain("Cancelled child"); + expect(container.textContent).toContain("1 task will be restored."); const restoreApplyButton = Array.from( container.querySelectorAll("button"), - ).find((button) => button.textContent?.trim() === "Restore 1 tasks"); + ).find((button) => button.textContent?.trim() === "Restore 1 task"); expect(restoreApplyButton).toBeTruthy(); await act(async () => { @@ -5067,7 +5068,8 @@ describe("IssueDetail", () => { }); }); - it("bounds the subtree control dialog with an internal scroll body", async () => { + it("confirms cancellation once without a reason, checkbox, or task inventory", async () => { + mockIssuesApi.createTreeHold.mockClear(); const childIssue = createIssue({ id: "child-1", parentId: "issue-1", @@ -5099,6 +5101,9 @@ describe("IssueDetail", () => { await flushReact(); await flushReact(); + const moreButton = container.querySelector('button[aria-label="More task actions"]')!; + await act(async () => { moreButton.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); }); + await flushReact(); const cancelMenuButton = Array.from( container.querySelectorAll("button"), ).find((button) => button.textContent?.trim() === "Cancel subtree..."); @@ -5119,45 +5124,21 @@ describe("IssueDetail", () => { '[data-slot="dialog-content"]', ) as HTMLDivElement | null; expect(dialogContent).toBeTruthy(); - expect(dialogContent!.className).toContain("max-h-(--sz-calc-18)"); - 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 (optional)"), - ); - expect(bodyScrollRegion?.className).toContain("min-h-0"); - expect(bodyScrollRegion?.className).toContain("overscroll-contain"); - - const cancelApplyButton = Array.from( - dialogContent!.querySelectorAll("button"), - ).find((button) => button.textContent?.trim() === "Cancel 24 tasks") as - HTMLButtonElement | undefined; - expect(cancelApplyButton).toBeTruthy(); - expect(cancelApplyButton!.disabled).toBe(true); - - const confirmationCheckbox = dialogContent!.querySelector( - 'input[type="checkbox"]', - ) as HTMLInputElement | null; - expect(confirmationCheckbox).toBeTruthy(); - await act(async () => { - confirmationCheckbox!.click(); - }); + expect(dialogContent!.textContent).toContain("Cancel subtree?"); + expect(dialogContent!.textContent).toContain("24 tasks will be cancelled."); + expect(dialogContent!.textContent).toContain("Keep tasks"); + expect(dialogContent!.querySelector('textarea, input[type="checkbox"]')).toBeNull(); + expect(dialogContent!.textContent).not.toContain("Cancellable child"); + expect(mockIssuesApi.createTreeHold).not.toHaveBeenCalled(); + const cancelApplyButton = Array.from(dialogContent!.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "Cancel 24 tasks")!; + expect(cancelApplyButton.disabled).toBe(false); + mockIssuesApi.createTreeHold.mockResolvedValue({ hold: { ...createPauseHold(), mode: "cancel" }, preview: createCancelPreview(24) }); + await act(async () => { cancelApplyButton.click(); }); await flushReact(); - expect(cancelApplyButton!.disabled).toBe(false); - - 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"); + expect(mockIssuesApi.createTreeHold).toHaveBeenCalledWith("PAP-1", { + mode: "cancel", reason: null, releasePolicy: { strategy: "manual" }, + }); }); it("keeps the authoritative Paperclip queue mounted after handoff promotion", async () => { diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 91ada40769..100e5a317f 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -203,8 +203,12 @@ import { } from "../components/IssueProperties"; import { TaskSidePanel } from "../components/task-side-panel"; import { SidePanelToggleButton } from "../components/side-panel"; -import { PauseAffectsSummaryView } from "../components/interrupt-handoff/InterruptHandoffViews"; -import { computePauseAffectsSummary } from "../lib/interrupt-handoff"; +import { + TaskPauseNotice, + TaskTreeControlDialog, + TaskTreeControlMenuItems, +} from "../components/TaskTreeControls"; +import { waitForStoppedRuns } from "../lib/wait-for-stopped-runs"; import { useIssueExternalObjects } from "../hooks/useIssueExternalObjects"; import { IssueGalleryContext } from "../context/IssueGalleryContext"; import { useIssuePlanDocument } from "../hooks/useIssuePlanDocument"; @@ -307,13 +311,10 @@ import { MessageSquare, MoreHorizontal, MoreVertical, - PauseCircle, Paperclip, - PlayCircle, Plus, Repeat, SlidersHorizontal, - XCircle, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { @@ -382,7 +383,10 @@ type ActionableIssueThreadInteraction = | RequestConfirmationInteraction | RequestCheckboxConfirmationInteraction; type ResolveRecoveryActionOutcome = - "restored" | "false_positive" | "blocked" | "cancelled"; + | "restored" + | "false_positive" + | "blocked" + | "cancelled"; type IssueDetailComment = (IssueComment | OptimisticIssueComment) & { runId?: string | null; runAgentId?: string | null; @@ -419,52 +423,6 @@ const FEEDBACK_TERMS_URL = "https://paperclip.ing/tos"; const ISSUE_COMMENT_AUTOLOAD_LIMIT = ISSUE_COMMENT_PAGE_SIZE * 3; const JUMP_TO_LATEST_MAX_COMMENT_PAGES = 10; -const TREE_CONTROL_MODE_LABEL: Record = { - pause: "Pause subtree", - resume: "Resume subtree", - cancel: "Cancel subtree", - restore: "Restore subtree", -}; -const LEAF_WORK_CONTROL_MODE_LABEL: Partial< - Record -> = { - pause: "Pause work", - resume: "Resume work", -}; -const TREE_CONTROL_MODE_HELP_TEXT: Record = { - pause: - "Pause active execution in this task subtree until an explicit resume.", - resume: "Release the active subtree pause hold so held work can continue.", - cancel: - "Cancel non-terminal tasks in this subtree and stop queued/running work where possible.", - restore: - "Restore tasks cancelled by this subtree operation so work can resume.", -}; -const LEAF_WORK_CONTROL_MODE_HELP_TEXT: Partial< - Record -> = { - pause: "Pause active execution on this task until an explicit resume.", - resume: "Release the active pause hold so this task can continue.", -}; -function issueTreeControlLabel( - mode: IssueTreeControlMode, - scope: "leaf" | "subtree", -) { - return scope === "leaf" - ? (LEAF_WORK_CONTROL_MODE_LABEL[mode] ?? TREE_CONTROL_MODE_LABEL[mode]) - : TREE_CONTROL_MODE_LABEL[mode]; -} - -function issueTreeControlHelpText( - mode: IssueTreeControlMode, - scope: "leaf" | "subtree", -) { - return scope === "leaf" - ? (LEAF_WORK_CONTROL_MODE_HELP_TEXT[mode] ?? - TREE_CONTROL_MODE_HELP_TEXT[mode]) - : TREE_CONTROL_MODE_HELP_TEXT[mode]; -} - function treeControlPreviewErrorCopy(error: unknown): string { if (error instanceof ApiError) { if (error.status === 403) @@ -1311,7 +1269,9 @@ type IssueDetailChatTabProps = { onAttachImage: (file: File) => Promise; onInterruptQueued: (runId: string) => Promise; onDeleteComment?: (commentId: string) => Promise | void; - onPauseWorkRun?: (runId: string) => Promise; + onPauseWorkRun?: (runId: string, feedback?: "composer") => Promise; + pauseWorkPending?: boolean; + pauseWorkScope?: "leaf" | "subtree"; runFinalizationActions?: readonly IssueChatRunFinalizationAction[]; onCancelQueued: (commentId: string) => void; interruptingQueuedRunId: string | null; @@ -1421,6 +1381,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ onInterruptQueued, onDeleteComment, onPauseWorkRun, + pauseWorkPending, + pauseWorkScope, runFinalizationActions, onCancelQueued, interruptingQueuedRunId, @@ -1485,7 +1447,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ ); const assigneeUsesPaperclipRunner = Boolean( issueAssigneeAgentId && - agentMap.get(issueAssigneeAgentId)?.adapterType === "paperclip_runner", + agentMap.get(issueAssigneeAgentId)?.adapterType === "paperclip_runner", ); const liveRuntimeRun = resolvedActiveRun ?? @@ -1755,29 +1717,29 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ : Number.NaN; const submittedDuringSourceRun = Boolean( targetRun?.contextIssueId === issueId && - Number.isFinite(targetStartedAtMs) && - Number.isFinite(submittedAtMs) && - resolvedLinkedRuns.some((run) => { - if ( - run.runId === targetRun.runId || - run.agentId !== targetRun.agentId || - run.contextIssueId !== issueId || - !run.finishedAt - ) { - return false; - } - const startedAtMs = new Date( - run.startedAt ?? run.createdAt, - ).getTime(); - const finishedAtMs = new Date(run.finishedAt).getTime(); - return ( - Number.isFinite(startedAtMs) && - Number.isFinite(finishedAtMs) && - startedAtMs <= submittedAtMs && - submittedAtMs <= finishedAtMs && - finishedAtMs <= targetStartedAtMs - ); - }), + Number.isFinite(targetStartedAtMs) && + Number.isFinite(submittedAtMs) && + resolvedLinkedRuns.some((run) => { + if ( + run.runId === targetRun.runId || + run.agentId !== targetRun.agentId || + run.contextIssueId !== issueId || + !run.finishedAt + ) { + return false; + } + const startedAtMs = new Date( + run.startedAt ?? run.createdAt, + ).getTime(); + const finishedAtMs = new Date(run.finishedAt).getTime(); + return ( + Number.isFinite(startedAtMs) && + Number.isFinite(finishedAtMs) && + startedAtMs <= submittedAtMs && + submittedAtMs <= finishedAtMs && + finishedAtMs <= targetStartedAtMs + ); + }), ); const nextComment: IssueDetailComment = { ...comment, @@ -2374,8 +2336,16 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ onDeleteComment={onDeleteComment} onCancelQueued={onCancelQueued} interruptingQueuedRunId={interruptingQueuedRunId} - stoppingRunId={pausingWorkRunId} - onStopRun={onPauseWorkRun} + stoppingRunId={ + pauseWorkPending + ? (pausingWorkRunId ?? interruptibleIssueRun?.id ?? null) + : pausingWorkRunId + } + onStopRun={ + onPauseWorkRun + ? (runId) => onPauseWorkRun(runId).catch(() => undefined) + : undefined + } stopRunLabel="Pause work" stoppingRunLabel="Pausing..." stopRunVariant="pause" @@ -2390,10 +2360,12 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ onSubmitInteractionVerdicts={onSubmitInteractionVerdicts} issueWorkMode={issueWorkMode} onWorkModeChange={onWorkModeChange} + stopPending={pauseWorkPending} + stopScope={pauseWorkScope} onCancelRun={ interruptibleIssueRun && onPauseWorkRun ? async () => { - await onPauseWorkRun(interruptibleIssueRun.id); + await onPauseWorkRun(interruptibleIssueRun.id, "composer"); } : undefined } @@ -2842,13 +2814,11 @@ export function IssueDetail() { const [galleryOpen, setGalleryOpen] = useState(false); const [galleryIndex, setGalleryIndex] = useState(0); const [treeControlOpen, setTreeControlOpen] = useState(false); + const [treeControlWakeWarning, setTreeControlWakeWarning] = useState(null); const [treeControlMode, setTreeControlMode] = - useState("pause"); - const [treeControlReason, setTreeControlReason] = useState(""); + useState>("resume"); const [treeControlWakeAgentsOnResume, setTreeControlWakeAgentsOnResume] = useState(false); - const [treeControlCancelConfirmed, setTreeControlCancelConfirmed] = - useState(false); const [optimisticComments, setOptimisticComments] = useState< OptimisticIssueComment[] >([]); @@ -2918,7 +2888,7 @@ export function IssueDetail() { () => Boolean( issue?.currentExecutionWorkspace && - isClosedIsolatedExecutionWorkspace(issue.currentExecutionWorkspace), + isClosedIsolatedExecutionWorkspace(issue.currentExecutionWorkspace), ), [issue?.currentExecutionWorkspace], ); @@ -3872,8 +3842,20 @@ export function IssueDetail() { }, }); const executeTreeControl = useMutation({ - mutationFn: async () => { - if (treeControlMode === "resume") { + onMutate: () => setTreeControlWakeWarning(null), + mutationFn: async ({ + mode, + scope, + runId, + wakeAgents = false, + }: { + mode: IssueTreeControlMode; + scope: "leaf" | "subtree"; + runId?: string; + wakeAgents?: boolean; + feedback?: "composer"; + }) => { + if (mode === "resume") { const pauseHoldId = treeControlState?.activePauseHold?.holdId; if (!pauseHoldId) { throw new Error( @@ -3884,70 +3866,53 @@ export function IssueDetail() { issueId!, pauseHoldId, { - reason: treeControlReason.trim() || null, + reason: null, metadata: { - wakeAgents: treeControlWakeAgentsOnResume, + wakeAgents, }, }, ); return { kind: "release" as const, hold: releasedHold }; } const created = await issuesApi.createTreeHold(issueId!, { - mode: treeControlMode, - reason: treeControlReason.trim() || null, + mode, + reason: null, releasePolicy: { strategy: "manual", - ...(treeControlMode === "pause" + ...(mode === "pause" ? { - note: treeControlScope === "leaf" ? "leaf_pause" : "full_pause", + note: scope === "leaf" ? "leaf_pause" : "full_pause", } : {}), }, - ...(treeControlMode === "restore" - ? { metadata: { wakeAgents: treeControlWakeAgentsOnResume } } + ...(runId + ? { metadata: { source: "issue_active_run_control", runId } } : {}), + ...(mode === "restore" ? { metadata: { wakeAgents } } : {}), }); + if (mode === "pause") { + // Show the hold promptly; keep Stop pending until termination is verified. + void queryClient.invalidateQueries({ + queryKey: ["issues", "tree-control-state", issueId ?? "pending"], + }); + await waitForStoppedRuns( + created.preview.activeRuns.map((run) => run.id), + ); + } return { kind: "create" as const, hold: created.hold, preview: created.preview, }; }, - onSuccess: async (result) => { - const modeLabel = issueTreeControlLabel( - result.hold.mode, - treeControlScope, - ); - const cancelCount = result.preview?.totals.activeRuns ?? 0; - pushToast({ - title: - result.kind === "release" - ? treeControlScope === "leaf" - ? "Work resumed" - : "Subtree resumed" - : result.hold.mode === "pause" - ? treeControlScope === "leaf" - ? "Work paused" - : "Subtree paused" - : `${modeLabel} applied`, - body: - result.kind === "release" - ? result.hold.releaseReason?.trim() || - (treeControlScope === "leaf" - ? "Active task pause released." - : "Active subtree pause released.") - : result.hold.mode === "pause" - ? treeControlScope === "leaf" - ? `Work paused. ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.` - : `Subtree paused. ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.` - : result.hold.reason?.trim() - ? result.hold.reason - : "Subtree control applied.", - }); + onSuccess: (result) => { + if (result.kind === "release" && result.hold.wakeFailures?.length) { + setTreeControlWakeWarning(`Pause released, but ${result.hold.wakeFailures.length} ${result.hold.wakeFailures.length === 1 ? "task" : "tasks"} could not start. ${result.hold.wakeFailures[0].message} Check the affected agents and try starting them again.`); + } setTreeControlOpen(false); - setTreeControlReason(""); setTreeControlWakeAgentsOnResume(false); - setTreeControlCancelConfirmed(false); + }, + onSettled: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!), @@ -3998,78 +3963,7 @@ export function IssueDetail() { ]); } }, - onError: (err) => { - pushToast({ - title: "Unable to apply subtree control", - body: err instanceof Error ? err.message : "Please try again.", - tone: "error", - }); - }, - }); - const pauseIssueWorkRun = useMutation({ - mutationFn: async ({ - runId, - scope, - }: { - runId: string; - scope: "leaf" | "subtree"; - }) => { - const created = await issuesApi.createTreeHold(issueId!, { - mode: "pause", - reason: "Paused from active run controls.", - releasePolicy: { - strategy: "manual", - note: scope === "leaf" ? "leaf_pause" : "full_pause", - }, - metadata: { source: "issue_active_run_control", runId }, - }); - return created; - }, - onSuccess: async (result) => { - const cancelCount = result.preview?.totals.activeRuns ?? 0; - pushToast({ - title: "Work paused", - body: - cancelCount > 0 - ? `Work paused. ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.` - : "Work paused. This task is held until resume.", - tone: "success", - }); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: queryKeys.issues.detail(issueId!), - }), - queryClient.invalidateQueries({ - queryKey: queryKeys.issues.activity(issueId!), - }), - queryClient.invalidateQueries({ - queryKey: queryKeys.issues.liveRuns(issueId!), - }), - queryClient.invalidateQueries({ - queryKey: queryKeys.issues.activeRun(issueId!), - }), - queryClient.invalidateQueries({ - queryKey: queryKeys.issues.runs(issueId!), - }), - queryClient.invalidateQueries({ - queryKey: ["issues", "tree-control-state", issueId ?? "pending"], - }), - queryClient.invalidateQueries({ - queryKey: ["issues", "tree-holds", issueId ?? "pending"], - }), - queryClient.invalidateQueries({ - queryKey: ["issues", "tree-control-preview", issueId ?? "pending"], - }), - ]); - invalidateIssueCollections(); - }, - onError: (err) => { - pushToast({ - title: "Unable to pause work", - body: err instanceof Error ? err.message : "Please try again.", - tone: "error", - }); - }, + }); const stopAndFinalizeRun = useMutation({ mutationFn: async ({ @@ -5994,7 +5888,8 @@ export function IssueDetail() { const loaded = await loadRemainingIssueCommentPages({ pages: refreshed.data?.pages, pageParams: refreshed.data?.pageParams as - Array | undefined, + | Array + | undefined, pageSize: ISSUE_COMMENT_PAGE_SIZE, maxPages: JUMP_TO_LATEST_MAX_COMMENT_PAGES, fetchPage: (afterCommentId) => @@ -6472,21 +6367,6 @@ export function IssueDetail() { ), [treeControlPreview], ); - // "What this affects" buckets for the pause/hold dialog (design surface 4). - const pauseAffectsSummary = useMemo( - () => computePauseAffectsSummary(treeControlPreview?.issues ?? []), - [treeControlPreview], - ); - const treePreviewDisplayIssues = useMemo(() => { - const previewIssues = treeControlPreview?.issues ?? []; - if (treeControlMode !== "pause") { - return previewIssues.filter((candidate) => !candidate.skipped); - } - return previewIssues.filter( - (candidate) => - !candidate.skipped || candidate.skipReason === "terminal_status", - ); - }, [treeControlMode, treeControlPreview]); const activePauseHold = treeControlState?.activePauseHold ?? null; const activeRootPauseHoldsForDisplay = useMemo( () => (activePauseHold?.isRoot === true ? activeRootPauseHolds : []), @@ -6526,13 +6406,7 @@ export function IssueDetail() { ) ?? null ); }, [activePauseHold, issue]); - const activeRootPauseHold = useMemo( - () => - activeRootPauseHoldsForDisplay.find( - (hold) => hold.id === activePauseHold?.holdId, - ) ?? null, - [activePauseHold?.holdId, activeRootPauseHoldsForDisplay], - ); + if (isLoading) return ; @@ -6587,11 +6461,6 @@ export function IssueDetail() { }; const hasAttachments = attachmentList.length > 0; - const treePreviewWarnings = treeControlPreview?.warnings ?? []; - 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; @@ -6615,44 +6484,6 @@ export function IssueDetail() { const previewAffectedIssueCount = treePreviewAffectedIssues.length; const previewAffectedAgentCount = treeControlPreview?.totals.affectedAgents ?? 0; - const treeControlPrimaryButtonLabel = - treeControlMode === "pause" - ? treeControlScope === "leaf" - ? "Pause work" - : "Pause and stop work" - : treeControlMode === "cancel" - ? `Cancel ${previewAffectedIssueCount} tasks` - : treeControlMode === "restore" - ? `Restore ${previewAffectedIssueCount} tasks` - : treeControlScope === "leaf" - ? "Resume work" - : "Resume subtree"; - const treePreviewAffectedIssueRows = treePreviewDisplayIssues.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"} for triage while the subtree remains paused.` @@ -6668,7 +6499,7 @@ export function IssueDetail() { const canApplyTreeControl = Boolean(treeControlPreview) && !treeControlPreviewLoading && - (treeControlMode !== "cancel" || treeControlCancelConfirmed); + !treeControlPreviewError; const attachmentUploadButton = ( <> ) : null} - {canPauseLeafWork ? ( - - ) : null} - {canResumeLeafWork ? ( - - ) : null} - {canShowSubtreeControls ? ( - <> - - {canResumeSubtree ? ( - - ) : null} - - {canRestoreSubtree ? ( - - ) : null} - - ) : null} + { + executeTreeControl.mutate({ + mode: "pause", + scope: treeControlScope, + }); + setMoreOpen(false); + }} + onResume={() => { + executeTreeControl.reset(); + setTreeControlMode("resume"); + setTreeControlWakeAgentsOnResume( + isAgentOwnedNonTerminalIssue || canShowSubtreeControls, + ); + setTreeControlOpen(true); + setMoreOpen(false); + }} + onCancel={() => { + executeTreeControl.reset(); + setTreeControlMode("cancel"); + setTreeControlOpen(true); + setMoreOpen(false); + }} + onRestore={() => { + executeTreeControl.reset(); + setTreeControlMode("restore"); + setTreeControlWakeAgentsOnResume(false); + setTreeControlOpen(true); + setMoreOpen(false); + }} + /> - - {canShowSubtreeControls ? ( - - ) : null} - - ) : null} - - ) : ( -

- This task is paused by ancestor{" "} - {activePauseHoldRoot?.identifier ? ( - - {activePauseHoldRoot.identifier} - - ) : ( - activePauseHold.rootIssueId.slice(0, 8) - )} - . Resume from the root task to deliver deferred work. -
- )} - + { + executeTreeControl.reset(); + setTreeControlMode("resume"); + setTreeControlWakeAgentsOnResume(isAgentOwnedNonTerminalIssue || canShowSubtreeControls); + setTreeControlOpen(true); + } : undefined} + resumeLink={!activePauseHold.isRoot ? : undefined} + /> + )} + {treeControlWakeWarning ?

{treeControlWakeWarning}

: null} + {executeTreeControl.error && !treeControlOpen && executeTreeControl.variables?.feedback !== "composer" && ( +

{executeTreeControl.error.message}

)} {taskChatShellEnabled ? null : issueHeaderBlock} @@ -7837,11 +7544,21 @@ export function IssueDetail() { onDeleteComment={(commentId) => deleteComment.mutateAsync({ commentId }).then(() => undefined) } + pauseWorkPending={ + executeTreeControl.isPending && + executeTreeControl.variables?.mode === "pause" + } + pauseWorkScope={treeControlScope} onPauseWorkRun={ canManageTreeControl - ? (runId) => - pauseIssueWorkRun - .mutateAsync({ runId, scope: treeControlScope }) + ? (runId, feedback) => + executeTreeControl + .mutateAsync({ + mode: "pause", + feedback, + runId, + scope: treeControlScope, + }) .then(() => undefined) : undefined } @@ -7861,8 +7578,9 @@ export function IssueDetail() { : null } pausingWorkRunId={ - pauseIssueWorkRun.isPending - ? (pauseIssueWorkRun.variables?.runId ?? null) + executeTreeControl.isPending && + executeTreeControl.variables?.mode === "pause" + ? (executeTreeControl.variables?.runId ?? null) : null } onImageClick={handleChatImageClick} @@ -7975,217 +7693,41 @@ export function IssueDetail() { )} - - - - - {issueTreeControlLabel(treeControlMode, treeControlScope)} - - - {issueTreeControlHelpText(treeControlMode, treeControlScope)} - - -
- {treeControlMode === "cancel" ? ( -
- Cancelling a subtree is destructive. Non-terminal tasks will - be marked cancelled, and running or queued work will be - interrupted where possible. -
- ) : null} - -
- -