From 0c6961a03e5f7784af246baa4da868b85b9c6aa5 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:02:45 -0500 Subject: [PATCH 1/5] Normalize escaped multiline issue and approval text (#4444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The board and agent APIs accept multiline issue, approval, interaction, and document text > - Some callers accidentally send literal escaped newline sequences like `\n` instead of JSON-decoded line breaks > - That makes comments, descriptions, documents, and approval notes render as flattened text instead of readable markdown > - This pull request centralizes multiline text normalization in shared validators > - The benefit is newline-preserving API behavior across issue and approval workflows without route-specific fixes ## What Changed - Added a shared `multilineTextSchema` helper that normalizes escaped `\n`, `\r\n`, and `\r` sequences to real line breaks. - Applied the helper to issue descriptions, issue update comments, issue comment bodies, suggested task descriptions, interaction summaries, issue documents, approval comments, and approval decision notes. - Added shared validator regressions for issue and approval multiline inputs. ## Verification - `pnpm exec vitest run --project @paperclipai/shared packages/shared/src/validators/approval.test.ts packages/shared/src/validators/issue.test.ts` - `pnpm --filter @paperclipai/shared typecheck` ## Risks - Low risk. This only changes text fields that are explicitly multiline user/operator content. - If a caller intentionally wanted literal backslash-n text in these fields, it will now render as a real line break. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-enabled with shell/GitHub/Paperclip API access. Context window was not reported by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../shared/src/validators/approval.test.ts | 31 ++++++++ packages/shared/src/validators/approval.ts | 7 +- packages/shared/src/validators/issue.test.ts | 78 +++++++++++++++++++ packages/shared/src/validators/issue.ts | 13 ++-- packages/shared/src/validators/text.ts | 10 +++ 5 files changed, 130 insertions(+), 9 deletions(-) create mode 100644 packages/shared/src/validators/approval.test.ts create mode 100644 packages/shared/src/validators/issue.test.ts create mode 100644 packages/shared/src/validators/text.ts diff --git a/packages/shared/src/validators/approval.test.ts b/packages/shared/src/validators/approval.test.ts new file mode 100644 index 00000000000..051d29b2740 --- /dev/null +++ b/packages/shared/src/validators/approval.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { + addApprovalCommentSchema, + requestApprovalRevisionSchema, + resolveApprovalSchema, +} from "./approval.js"; + +describe("approval validators", () => { + it("passes real line breaks through unchanged", () => { + expect(addApprovalCommentSchema.parse({ body: "Looks good\n\nApproved." }).body) + .toBe("Looks good\n\nApproved."); + expect(resolveApprovalSchema.parse({ decisionNote: "Decision\n\nApproved." }).decisionNote) + .toBe("Decision\n\nApproved."); + }); + + it("accepts null and omitted optional decision notes", () => { + expect(resolveApprovalSchema.parse({ decisionNote: null }).decisionNote).toBeNull(); + expect(resolveApprovalSchema.parse({}).decisionNote).toBeUndefined(); + expect(requestApprovalRevisionSchema.parse({ decisionNote: null }).decisionNote).toBeNull(); + expect(requestApprovalRevisionSchema.parse({}).decisionNote).toBeUndefined(); + }); + + it("normalizes escaped line breaks in approval comments and decision notes", () => { + expect(addApprovalCommentSchema.parse({ body: "Looks good\\n\\nApproved." }).body) + .toBe("Looks good\n\nApproved."); + expect(resolveApprovalSchema.parse({ decisionNote: "Decision\\n\\nApproved." }).decisionNote) + .toBe("Decision\n\nApproved."); + expect(requestApprovalRevisionSchema.parse({ decisionNote: "Decision\\r\\nRevise." }).decisionNote) + .toBe("Decision\nRevise."); + }); +}); diff --git a/packages/shared/src/validators/approval.ts b/packages/shared/src/validators/approval.ts index ae6b2eb3b30..ca704795bb2 100644 --- a/packages/shared/src/validators/approval.ts +++ b/packages/shared/src/validators/approval.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { APPROVAL_TYPES } from "../constants.js"; +import { multilineTextSchema } from "./text.js"; export const createApprovalSchema = z.object({ type: z.enum(APPROVAL_TYPES), @@ -11,13 +12,13 @@ export const createApprovalSchema = z.object({ export type CreateApproval = z.infer; export const resolveApprovalSchema = z.object({ - decisionNote: z.string().optional().nullable(), + decisionNote: multilineTextSchema.optional().nullable(), }); export type ResolveApproval = z.infer; export const requestApprovalRevisionSchema = z.object({ - decisionNote: z.string().optional().nullable(), + decisionNote: multilineTextSchema.optional().nullable(), }); export type RequestApprovalRevision = z.infer; @@ -29,7 +30,7 @@ export const resubmitApprovalSchema = z.object({ export type ResubmitApproval = z.infer; export const addApprovalCommentSchema = z.object({ - body: z.string().min(1), + body: multilineTextSchema.pipe(z.string().min(1)), }); export type AddApprovalComment = z.infer; diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts new file mode 100644 index 00000000000..4fa908e40cb --- /dev/null +++ b/packages/shared/src/validators/issue.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + addIssueCommentSchema, + createIssueSchema, + respondIssueThreadInteractionSchema, + suggestedTaskDraftSchema, + updateIssueSchema, + upsertIssueDocumentSchema, +} from "./issue.js"; + +describe("issue validators", () => { + it("passes real line breaks through unchanged", () => { + const parsed = createIssueSchema.parse({ + title: "Follow up PR", + description: "Line 1\n\nLine 2", + }); + + expect(parsed.description).toBe("Line 1\n\nLine 2"); + }); + + it("accepts null and omitted optional multiline issue fields", () => { + expect(createIssueSchema.parse({ title: "Follow up PR", description: null }).description) + .toBeNull(); + expect(createIssueSchema.parse({ title: "Follow up PR" }).description) + .toBeUndefined(); + expect(updateIssueSchema.parse({ comment: undefined }).comment) + .toBeUndefined(); + }); + + it("normalizes JSON-escaped line breaks in issue descriptions", () => { + const parsed = createIssueSchema.parse({ + title: "Follow up PR", + description: "PR: https://example.com/pr/1\\n\\nShip the follow-up.", + }); + + expect(parsed.description).toBe("PR: https://example.com/pr/1\n\nShip the follow-up."); + }); + + it("normalizes escaped line breaks in issue update comments", () => { + const parsed = updateIssueSchema.parse({ + comment: "Done\\n\\n- Verified the route", + }); + + expect(parsed.comment).toBe("Done\n\n- Verified the route"); + }); + + it("normalizes escaped line breaks in issue comment bodies", () => { + const parsed = addIssueCommentSchema.parse({ + body: "Progress update\\r\\n\\r\\nNext action.", + }); + + expect(parsed.body).toBe("Progress update\n\nNext action."); + }); + + it("normalizes escaped line breaks in generated task drafts", () => { + const parsed = suggestedTaskDraftSchema.parse({ + clientKey: "task-1", + title: "Follow up", + description: "Line 1\\n\\nLine 2", + }); + + expect(parsed.description).toBe("Line 1\n\nLine 2"); + }); + + it("normalizes escaped line breaks in thread summaries and documents", () => { + const response = respondIssueThreadInteractionSchema.parse({ + answers: [], + summaryMarkdown: "Summary\\n\\nNext action", + }); + const document = upsertIssueDocumentSchema.parse({ + format: "markdown", + body: "# Plan\\n\\nShip it", + }); + + expect(response.summaryMarkdown).toBe("Summary\n\nNext action"); + expect(document.body).toBe("# Plan\n\nShip it"); + }); +}); diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index b4451cf9aff..d8f58a831b6 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -10,6 +10,7 @@ import { ISSUE_THREAD_INTERACTION_KINDS, ISSUE_THREAD_INTERACTION_STATUSES, } from "../constants.js"; +import { multilineTextSchema } from "./text.js"; export const ISSUE_EXECUTION_WORKSPACE_PREFERENCES = [ "inherit", @@ -130,7 +131,7 @@ export const createIssueSchema = z.object({ blockedByIssueIds: z.array(z.string().uuid()).optional(), inheritExecutionWorkspaceFromIssueId: z.string().uuid().optional().nullable(), title: z.string().min(1), - description: z.string().optional().nullable(), + description: multilineTextSchema.optional().nullable(), status: z.enum(ISSUE_STATUSES).optional().default("backlog"), priority: z.enum(ISSUE_PRIORITIES).optional().default("medium"), assigneeAgentId: z.string().uuid().optional().nullable(), @@ -168,7 +169,7 @@ export type CreateIssueLabel = z.infer; export const updateIssueSchema = createIssueSchema.partial().extend({ assigneeAgentId: z.string().trim().min(1).optional().nullable(), - comment: z.string().min(1).optional(), + comment: multilineTextSchema.pipe(z.string().min(1)).optional(), reviewRequest: issueReviewRequestSchema.optional().nullable(), reopen: z.boolean().optional(), resume: z.boolean().optional(), @@ -187,7 +188,7 @@ export const checkoutIssueSchema = z.object({ export type CheckoutIssue = z.infer; export const addIssueCommentSchema = z.object({ - body: z.string().min(1), + body: multilineTextSchema.pipe(z.string().min(1)), reopen: z.boolean().optional(), resume: z.boolean().optional(), interrupt: z.boolean().optional(), @@ -213,7 +214,7 @@ export const suggestedTaskDraftSchema = z.object({ parentClientKey: z.string().trim().min(1).max(120).nullable().optional(), parentId: z.string().uuid().nullable().optional(), title: z.string().trim().min(1).max(240), - description: z.string().trim().max(20000).nullable().optional(), + description: multilineTextSchema.pipe(z.string().trim().max(20000)).nullable().optional(), priority: z.enum(ISSUE_PRIORITIES).nullable().optional(), assigneeAgentId: z.string().uuid().nullable().optional(), assigneeUserId: z.string().trim().min(1).nullable().optional(), @@ -439,7 +440,7 @@ export type RejectIssueThreadInteraction = z.infer; @@ -462,7 +463,7 @@ export const issueDocumentFormatSchema = z.enum(ISSUE_DOCUMENT_FORMATS); export const upsertIssueDocumentSchema = z.object({ title: z.string().trim().max(200).nullable().optional(), format: issueDocumentFormatSchema, - body: z.string().max(524288), + body: multilineTextSchema.pipe(z.string().max(524288)), changeSummary: z.string().trim().max(500).nullable().optional(), baseRevisionId: z.string().uuid().nullable().optional(), }); diff --git a/packages/shared/src/validators/text.ts b/packages/shared/src/validators/text.ts new file mode 100644 index 00000000000..322597a9005 --- /dev/null +++ b/packages/shared/src/validators/text.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export function normalizeEscapedLineBreaks(value: string): string { + return value + .replace(/\\r\\n/g, "\n") + .replace(/\\n/g, "\n") + .replace(/\\r/g, "\n"); +} + +export const multilineTextSchema = z.string().transform(normalizeEscapedLineBreaks); From 6916e30f8ed440a0948255c96dc11251a098fb8d Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:24:13 -0500 Subject: [PATCH 2/5] Cancel stale retries when issue ownership changes (#4445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Issue execution is guarded by run locks and bounded retry scheduling > - A failed run can schedule a retry, but the issue may be reassigned before that retry becomes due > - The old assignee's scheduled retry should not continue to hold or reclaim execution for the issue > - This pull request cancels stale scheduled retries when ownership changes and cancels live work when an issue is explicitly cancelled > - The benefit is cleaner issue handoff semantics and fewer stranded or incorrect execution locks ## What Changed - Cancel scheduled retry runs when their issue has been reassigned before the retry is promoted. - Clear stale issue execution locks and cancel the associated wakeup request when a stale retry is cancelled. - Avoid deferring a new assignee behind a previous assignee's scheduled retry. - Cancel an active run when an issue status is explicitly changed to `cancelled`, while leaving `done` transitions alone. - Added route and heartbeat regressions for reassignment and cancellation behavior. ## Verification - `pnpm exec vitest run --project @paperclipai/server server/src/__tests__/heartbeat-retry-scheduling.test.ts server/src/__tests__/issue-comment-reopen-routes.test.ts --pool=forks --poolOptions.forks.isolate=true` - `issue-comment-reopen-routes.test.ts`: 28 passed. - `heartbeat-retry-scheduling.test.ts`: skipped by the existing embedded Postgres host guard (`Postgres init script exited with code null`). - `pnpm --filter @paperclipai/server typecheck` ## Risks - Medium risk because this changes heartbeat retry lifecycle behavior. - The cancellation path is scoped to scheduled retries whose issue assignee no longer matches the retrying agent, and logs a lifecycle event for auditability. - No migrations. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-enabled with shell/GitHub/Paperclip API access. Context window was not reported by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../heartbeat-retry-scheduling.test.ts | 376 ++++++++++++++++++ .../issue-comment-reopen-routes.test.ts | 67 ++++ server/src/routes/issues.ts | 42 ++ server/src/services/heartbeat.ts | 206 +++++++++- 4 files changed, 676 insertions(+), 15 deletions(-) diff --git a/server/src/__tests__/heartbeat-retry-scheduling.test.ts b/server/src/__tests__/heartbeat-retry-scheduling.test.ts index 162ea539ed4..9999c05e523 100644 --- a/server/src/__tests__/heartbeat-retry-scheduling.test.ts +++ b/server/src/__tests__/heartbeat-retry-scheduling.test.ts @@ -3,11 +3,14 @@ import { eq, sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { agents, + agentRuntimeState, agentWakeupRequests, companies, createDb, + environmentLeases, heartbeatRunEvents, heartbeatRuns, + issues, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, @@ -40,8 +43,11 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => { afterEach(async () => { await db.delete(heartbeatRunEvents); + await db.delete(environmentLeases); + await db.delete(issues); await db.delete(heartbeatRuns); await db.delete(agentWakeupRequests); + await db.delete(agentRuntimeState); await db.delete(agents); await db.delete(companies); }); @@ -212,6 +218,376 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => { expect(promotedRun?.status).toBe("queued"); }); + it("does not defer a new assignee behind the previous assignee's scheduled retry", async () => { + const companyId = randomUUID(); + const oldAgentId = randomUUID(); + const newAgentId = randomUUID(); + const issueId = randomUUID(); + const sourceRunId = randomUUID(); + const now = new Date("2026-04-20T13:00:00.000Z"); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values([ + { + id: oldAgentId, + companyId, + name: "ClaudeCoder", + role: "engineer", + status: "active", + adapterType: "claude_local", + adapterConfig: {}, + runtimeConfig: { + heartbeat: { + wakeOnDemand: true, + maxConcurrentRuns: 1, + }, + }, + permissions: {}, + }, + { + id: newAgentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: { + heartbeat: { + wakeOnDemand: true, + maxConcurrentRuns: 1, + }, + }, + permissions: {}, + }, + ]); + + await db.insert(heartbeatRuns).values({ + id: sourceRunId, + companyId, + agentId: oldAgentId, + invocationSource: "assignment", + triggerDetail: "system", + status: "failed", + error: "upstream overload", + errorCode: "adapter_failed", + finishedAt: now, + contextSnapshot: { + issueId, + wakeReason: "issue_assigned", + }, + updatedAt: now, + createdAt: now, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Retry reassignment", + status: "todo", + priority: "medium", + assigneeAgentId: oldAgentId, + executionRunId: sourceRunId, + executionAgentNameKey: "claudecoder", + executionLockedAt: now, + issueNumber: 1, + identifier: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}-1`, + }); + + const scheduled = await heartbeat.scheduleBoundedRetry(sourceRunId, { + now, + random: () => 0.5, + }); + expect(scheduled.outcome).toBe("scheduled"); + if (scheduled.outcome !== "scheduled") return; + + await db.update(issues).set({ + assigneeAgentId: newAgentId, + updatedAt: now, + }).where(eq(issues.id, issueId)); + + // Keep the new agent's queue from auto-claiming/executing during this unit test. + await db.insert(heartbeatRuns).values( + Array.from({ length: 5 }, () => ({ + id: randomUUID(), + companyId, + agentId: newAgentId, + invocationSource: "automation", + triggerDetail: "system", + status: "running", + contextSnapshot: { + wakeReason: "test_busy_slot", + }, + startedAt: now, + updatedAt: now, + createdAt: now, + })), + ); + + const newAssigneeRun = await heartbeat.wakeup(newAgentId, { + source: "assignment", + triggerDetail: "system", + reason: "issue_assigned", + payload: { + issueId, + mutation: "update", + }, + contextSnapshot: { + issueId, + source: "issue.update", + }, + requestedByActorType: "user", + requestedByActorId: "local-board", + }); + + expect(newAssigneeRun).not.toBeNull(); + expect(newAssigneeRun?.agentId).toBe(newAgentId); + expect(newAssigneeRun?.status).toBe("queued"); + + const oldRetry = await db + .select({ + status: heartbeatRuns.status, + errorCode: heartbeatRuns.errorCode, + }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, scheduled.run.id)) + .then((rows) => rows[0] ?? null); + expect(oldRetry).toEqual({ + status: "cancelled", + errorCode: "issue_reassigned", + }); + + const deferredWakeups = await db + .select({ count: sql`count(*)::int` }) + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.status, "deferred_issue_execution")) + .then((rows) => rows[0]?.count ?? 0); + expect(deferredWakeups).toBe(0); + }); + + it("does not promote a scheduled retry after issue ownership changes", async () => { + const companyId = randomUUID(); + const oldAgentId = randomUUID(); + const newAgentId = randomUUID(); + const issueId = randomUUID(); + const sourceRunId = randomUUID(); + const now = new Date("2026-04-20T14:00:00.000Z"); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values([ + { + id: oldAgentId, + companyId, + name: "ClaudeCoder", + role: "engineer", + status: "active", + adapterType: "claude_local", + adapterConfig: {}, + runtimeConfig: { + heartbeat: { + wakeOnDemand: true, + maxConcurrentRuns: 1, + }, + }, + permissions: {}, + }, + { + id: newAgentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: { + heartbeat: { + wakeOnDemand: true, + maxConcurrentRuns: 1, + }, + }, + permissions: {}, + }, + ]); + + await db.insert(heartbeatRuns).values({ + id: sourceRunId, + companyId, + agentId: oldAgentId, + invocationSource: "assignment", + triggerDetail: "system", + status: "failed", + error: "upstream overload", + errorCode: "adapter_failed", + finishedAt: now, + contextSnapshot: { + issueId, + wakeReason: "issue_assigned", + }, + updatedAt: now, + createdAt: now, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Retry promotion reassignment", + status: "todo", + priority: "medium", + assigneeAgentId: oldAgentId, + executionRunId: sourceRunId, + executionAgentNameKey: "claudecoder", + executionLockedAt: now, + issueNumber: 1, + identifier: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}-2`, + }); + + const scheduled = await heartbeat.scheduleBoundedRetry(sourceRunId, { + now, + random: () => 0.5, + }); + expect(scheduled.outcome).toBe("scheduled"); + if (scheduled.outcome !== "scheduled") return; + + await db.update(issues).set({ + assigneeAgentId: newAgentId, + updatedAt: now, + }).where(eq(issues.id, issueId)); + + const promotion = await heartbeat.promoteDueScheduledRetries(scheduled.dueAt); + expect(promotion).toEqual({ promoted: 0, runIds: [] }); + + const oldRetry = await db + .select({ + status: heartbeatRuns.status, + errorCode: heartbeatRuns.errorCode, + }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, scheduled.run.id)) + .then((rows) => rows[0] ?? null); + expect(oldRetry).toEqual({ + status: "cancelled", + errorCode: "issue_reassigned", + }); + + const issue = await db + .select({ executionRunId: issues.executionRunId }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + expect(issue?.executionRunId).toBeNull(); + }); + + it("does not promote a scheduled retry after the issue is cancelled", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const sourceRunId = randomUUID(); + const now = new Date("2026-04-20T15:00:00.000Z"); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: { + heartbeat: { + wakeOnDemand: true, + maxConcurrentRuns: 1, + }, + }, + permissions: {}, + }); + + await db.insert(heartbeatRuns).values({ + id: sourceRunId, + companyId, + agentId, + invocationSource: "assignment", + triggerDetail: "system", + status: "failed", + error: "upstream overload", + errorCode: "adapter_failed", + finishedAt: now, + contextSnapshot: { + issueId, + wakeReason: "issue_assigned", + }, + updatedAt: now, + createdAt: now, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Retry promotion cancellation", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + executionRunId: sourceRunId, + executionAgentNameKey: "codexcoder", + executionLockedAt: now, + issueNumber: 1, + identifier: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}-3`, + }); + + const scheduled = await heartbeat.scheduleBoundedRetry(sourceRunId, { + now, + random: () => 0.5, + }); + expect(scheduled.outcome).toBe("scheduled"); + if (scheduled.outcome !== "scheduled") return; + + await db.update(issues).set({ + status: "cancelled", + updatedAt: now, + }).where(eq(issues.id, issueId)); + + const promotion = await heartbeat.promoteDueScheduledRetries(scheduled.dueAt); + expect(promotion).toEqual({ promoted: 0, runIds: [] }); + + const oldRetry = await db + .select({ + status: heartbeatRuns.status, + errorCode: heartbeatRuns.errorCode, + }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, scheduled.run.id)) + .then((rows) => rows[0] ?? null); + expect(oldRetry).toEqual({ + status: "cancelled", + errorCode: "issue_cancelled", + }); + + const issue = await db + .select({ executionRunId: issues.executionRunId }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + expect(issue?.executionRunId).toBeNull(); + }); + it("exhausts bounded retries after the hard cap", async () => { const companyId = randomUUID(); const agentId = randomUUID(); diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 7709274be5e..3cca0371fb6 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -957,6 +957,73 @@ describe.sequential("issue comment reopen routes", () => { ); }); + it("cancels an active run when an issue is marked cancelled", async () => { + const issue = { + ...makeIssue("in_progress"), + executionRunId: "run-1", + }; + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...issue, + ...patch, + })); + mockHeartbeatService.getRun.mockResolvedValue({ + id: "run-1", + companyId: "company-1", + agentId: "22222222-2222-4222-8222-222222222222", + status: "running", + }); + mockHeartbeatService.cancelRun.mockResolvedValue({ + id: "run-1", + companyId: "company-1", + agentId: "22222222-2222-4222-8222-222222222222", + status: "cancelled", + }); + + const res = await request(await installActor(createApp())) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ status: "cancelled" }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.getRun).toHaveBeenCalledWith("run-1"); + expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("run-1"); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "heartbeat.cancelled", + details: expect.objectContaining({ + source: "issue_status_cancelled", + issueId: "11111111-1111-4111-8111-111111111111", + }), + }), + ); + }); + + it("does not cancel active runs when an issue is marked done", async () => { + const issue = { + ...makeIssue("in_progress"), + executionRunId: "run-1", + }; + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...issue, + ...patch, + })); + mockHeartbeatService.getRun.mockResolvedValue({ + id: "run-1", + companyId: "company-1", + agentId: "22222222-2222-4222-8222-222222222222", + status: "running", + }); + + const res = await request(await installActor(createApp())) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ status: "done" }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); + }); + it("writes decision ids into executionState and inserts the decision inside the transaction", async () => { const policy = await normalizePolicy({ stages: [ diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 15432c1ac5f..4458f382ca8 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1910,6 +1910,8 @@ export function issueRoutes( hiddenAt: hiddenAtRaw, ...updateFields } = req.body; + const shouldCancelActiveRunForCancelledStatus = + existing.status !== "cancelled" && updateFields.status === "cancelled"; if (resumeRequested === true && !commentBody) { res.status(400).json({ error: "Follow-up intent requires a comment" }); return; @@ -1982,6 +1984,10 @@ export function issueRoutes( } } + const runToCancelForCancelledStatus = shouldCancelActiveRunForCancelledStatus + ? await resolveActiveIssueRun(existing) + : null; + if (hiddenAtRaw !== undefined) { updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null; } @@ -2134,6 +2140,41 @@ export function issueRoutes( res.status(404).json({ error: "Issue not found" }); return; } + + let cancelledStatusRunId: string | null = null; + if (runToCancelForCancelledStatus) { + try { + const cancelled = await heartbeat.cancelRun(runToCancelForCancelledStatus.id); + if (cancelled) { + cancelledStatusRunId = cancelled.id; + await logActivity(db, { + companyId: cancelled.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "heartbeat.cancelled", + entityType: "heartbeat_run", + entityId: cancelled.id, + details: { agentId: cancelled.agentId, source: "issue_status_cancelled", issueId: existing.id }, + }); + } + } catch (err) { + logger.warn({ err, issueId: existing.id, runId: runToCancelForCancelledStatus.id }, "failed to cancel run for cancelled issue"); + await logActivity(db, { + companyId: existing.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "heartbeat.cancel_failed", + entityType: "heartbeat_run", + entityId: runToCancelForCancelledStatus.id, + details: { source: "issue_status_cancelled", issueId: existing.id }, + }); + } + } + if (titleOrDescriptionChanged) { await issueReferencesSvc.syncIssue(issue.id); } @@ -2200,6 +2241,7 @@ export function issueRoutes( ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}), ...(interruptedRunId ? { interruptedRunId } : {}), + ...(cancelledStatusRunId ? { cancelledStatusRunId } : {}), _previous: hasFieldChanges ? previous : undefined, ...summarizeIssueReferenceActivityDetails( updateReferenceDiff diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 57a4f62f77f..0d2c715f4da 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -3545,6 +3545,90 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const promotedRunIds: string[] = []; for (const dueRun of dueRuns) { + const dueRunIssueId = readNonEmptyString(parseObject(dueRun.contextSnapshot).issueId); + if (dueRunIssueId) { + const issue = await db + .select({ + id: issues.id, + status: issues.status, + assigneeAgentId: issues.assigneeAgentId, + executionRunId: issues.executionRunId, + }) + .from(issues) + .where(and(eq(issues.id, dueRunIssueId), eq(issues.companyId, dueRun.companyId))) + .then((rows) => rows[0] ?? null); + + if (issue && (issue.assigneeAgentId !== dueRun.agentId || issue.status === "cancelled")) { + const issueCancelled = issue.status === "cancelled"; + const reason = issueCancelled + ? "Cancelled because the issue was cancelled before the scheduled retry became due" + : "Cancelled because the issue was reassigned before the scheduled retry became due"; + const cancelled = await db + .update(heartbeatRuns) + .set({ + status: "cancelled", + finishedAt: now, + error: reason, + errorCode: issueCancelled ? "issue_cancelled" : "issue_reassigned", + updatedAt: now, + }) + .where( + and( + eq(heartbeatRuns.id, dueRun.id), + eq(heartbeatRuns.status, "scheduled_retry"), + lte(heartbeatRuns.scheduledRetryAt, now), + ), + ) + .returning() + .then((rows) => rows[0] ?? null); + + if (!cancelled) continue; + + if (cancelled.wakeupRequestId) { + await db + .update(agentWakeupRequests) + .set({ + status: "cancelled", + finishedAt: now, + error: reason, + updatedAt: now, + }) + .where(eq(agentWakeupRequests.id, cancelled.wakeupRequestId)); + } + + if (issue.executionRunId === cancelled.id) { + await db + .update(issues) + .set({ + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + updatedAt: now, + }) + .where(and(eq(issues.id, issue.id), eq(issues.executionRunId, cancelled.id))); + } + + await appendRunEvent(cancelled, await nextRunEventSeq(cancelled.id), { + eventType: "lifecycle", + stream: "system", + level: "warn", + message: issueCancelled + ? "Scheduled retry cancelled because issue was cancelled before it became due" + : "Scheduled retry cancelled because issue ownership changed before it became due", + payload: { + issueId: issue.id, + issueStatus: issue.status, + scheduledRetryAttempt: cancelled.scheduledRetryAttempt, + scheduledRetryAt: cancelled.scheduledRetryAt ? new Date(cancelled.scheduledRetryAt).toISOString() : null, + scheduledRetryReason: cancelled.scheduledRetryReason, + previousRetryAgentId: cancelled.agentId, + currentAssigneeAgentId: issue.assigneeAgentId, + }, + }); + continue; + } + } + const promoted = await db .update(heartbeatRuns) .set({ @@ -6228,6 +6312,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .select({ id: issues.id, companyId: issues.companyId, + status: issues.status, + assigneeAgentId: issues.assigneeAgentId, executionRunId: issues.executionRunId, executionAgentNameKey: issues.executionAgentNameKey, }) @@ -6252,6 +6338,88 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) return { kind: "skipped" as const }; } + const cancelStaleScheduledRetry = async (scheduledRun: typeof heartbeatRuns.$inferSelect) => { + const issueCancelled = issue.status === "cancelled"; + if ( + scheduledRun.status !== "scheduled_retry" || + (scheduledRun.agentId === issue.assigneeAgentId && !issueCancelled) + ) { + return false; + } + + const now = new Date(); + const reason = issueCancelled + ? "Cancelled because the issue was cancelled before the scheduled retry became due" + : "Cancelled because the issue was reassigned before the scheduled retry became due"; + const cancelled = await tx + .update(heartbeatRuns) + .set({ + status: "cancelled", + finishedAt: now, + error: reason, + errorCode: issueCancelled ? "issue_cancelled" : "issue_reassigned", + updatedAt: now, + }) + .where(and(eq(heartbeatRuns.id, scheduledRun.id), eq(heartbeatRuns.status, "scheduled_retry"))) + .returning() + .then((rows) => rows[0] ?? null); + + if (!cancelled) return false; + + if (scheduledRun.wakeupRequestId) { + await tx + .update(agentWakeupRequests) + .set({ + status: "cancelled", + finishedAt: now, + error: reason, + updatedAt: now, + }) + .where(eq(agentWakeupRequests.id, scheduledRun.wakeupRequestId)); + } + + if (issue.executionRunId === scheduledRun.id) { + await tx + .update(issues) + .set({ + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + updatedAt: now, + }) + .where(and(eq(issues.id, issue.id), eq(issues.executionRunId, scheduledRun.id))); + } + + const [eventSeq] = await tx + .select({ maxSeq: sql`max(${heartbeatRunEvents.seq})` }) + .from(heartbeatRunEvents) + .where(eq(heartbeatRunEvents.runId, cancelled.id)); + + await tx.insert(heartbeatRunEvents).values({ + companyId: cancelled.companyId, + runId: cancelled.id, + agentId: cancelled.agentId, + seq: Number(eventSeq?.maxSeq ?? 0) + 1, + eventType: "lifecycle", + stream: "system", + level: "warn", + message: issueCancelled + ? "Scheduled retry cancelled because issue was cancelled before it became due" + : "Scheduled retry cancelled because issue ownership changed before it became due", + payload: { + issueId: issue.id, + issueStatus: issue.status, + scheduledRetryAttempt: cancelled.scheduledRetryAttempt, + scheduledRetryAt: cancelled.scheduledRetryAt ? new Date(cancelled.scheduledRetryAt).toISOString() : null, + scheduledRetryReason: cancelled.scheduledRetryReason, + previousRetryAgentId: cancelled.agentId, + currentAssigneeAgentId: issue.assigneeAgentId, + }, + }); + + return true; + }; + let activeExecutionRun = issue.executionRunId ? await tx .select() @@ -6269,6 +6437,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) activeExecutionRun = null; } + if (activeExecutionRun && await cancelStaleScheduledRetry(activeExecutionRun)) { + activeExecutionRun = null; + } + if (!activeExecutionRun && issue.executionRunId) { await tx .update(issues) @@ -6300,21 +6472,25 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .then((rows) => rows[0] ?? null); if (legacyRun) { - activeExecutionRun = legacyRun; - const legacyAgent = await tx - .select({ name: agents.name }) - .from(agents) - .where(eq(agents.id, legacyRun.agentId)) - .then((rows) => rows[0] ?? null); - await tx - .update(issues) - .set({ - executionRunId: legacyRun.id, - executionAgentNameKey: normalizeAgentNameKey(legacyAgent?.name), - executionLockedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(issues.id, issue.id)); + if (await cancelStaleScheduledRetry(legacyRun)) { + activeExecutionRun = null; + } else { + activeExecutionRun = legacyRun; + const legacyAgent = await tx + .select({ name: agents.name }) + .from(agents) + .where(eq(agents.id, legacyRun.agentId)) + .then((rows) => rows[0] ?? null); + await tx + .update(issues) + .set({ + executionRunId: legacyRun.id, + executionAgentNameKey: normalizeAgentNameKey(legacyAgent?.name), + executionLockedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(issues.id, issue.id)); + } } } From 73fbdf36db15f7e1ef5eea0c9834804b0b54fd43 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:25:23 -0500 Subject: [PATCH 3/5] Gate stale-run watchdog decisions by board access (#4446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The run ledger surfaces stale-run watchdog evaluation issues and recovery actions > - Viewer-level board users should be able to inspect status without getting controls that the server will reject > - The UI also needs enough board-access context to know when to hide those decision actions > - This pull request exposes board memberships in the current board access snapshot and gates watchdog action controls for known viewer contexts > - The benefit is clearer least-privilege UI behavior around recovery controls ## What Changed - Included memberships in `/api/cli-auth/me` so the board UI can distinguish active viewer memberships from operator/admin access. - Added the stale-run evaluation issue assignee to output silence summaries. - Hid stale-run watchdog decision buttons for known non-owner viewer contexts. - Surfaced watchdog decision failures through toast and inline error text. - Threaded `companyId` through the issue activity run ledger so access checks are company-scoped. - Added IssueRunLedger coverage for non-owner viewers. ## Verification - `pnpm exec vitest run --project @paperclipai/ui ui/src/components/IssueRunLedger.test.tsx` - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/ui typecheck` ## Risks - Medium-low risk. This is a UI gating change backed by existing server authorization. - Local implicit and instance-admin board contexts continue to show watchdog decision controls. - No migrations. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-enabled with shell/GitHub/Paperclip API access. Context window was not reported by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- packages/shared/src/types/heartbeat.ts | 1 + server/src/routes/access.ts | 1 + server/src/services/recovery/service.ts | 2 + ui/src/api/access.ts | 5 ++ ui/src/components/IssueRunLedger.test.tsx | 21 ++++++++ ui/src/components/IssueRunLedger.tsx | 65 ++++++++++++++++++++++- ui/src/pages/IssueDetail.tsx | 4 ++ 7 files changed, 97 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/types/heartbeat.ts b/packages/shared/src/types/heartbeat.ts index 5aee87af5e7..973d5d6c65a 100644 --- a/packages/shared/src/types/heartbeat.ts +++ b/packages/shared/src/types/heartbeat.ts @@ -77,6 +77,7 @@ export interface HeartbeatRunOutputSilence { snoozedUntil: Date | string | null; evaluationIssueId: string | null; evaluationIssueIdentifier: string | null; + evaluationIssueAssigneeAgentId: string | null; } export interface AgentWakeupSkipped { diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 3df49fd6dbf..44acab90dfb 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -2617,6 +2617,7 @@ export function accessRoutes( userId: req.actor.userId, isInstanceAdmin: accessSnapshot.isInstanceAdmin, companyIds: accessSnapshot.companyIds, + memberships: accessSnapshot.memberships, source: req.actor.source ?? "none", keyId: req.actor.source === "board_key" ? req.actor.keyId ?? null : null, }); diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index f2c2f58d79f..f20dd05915f 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -82,6 +82,7 @@ export type RunOutputSilenceSummary = { snoozedUntil: Date | null; evaluationIssueId: string | null; evaluationIssueIdentifier: string | null; + evaluationIssueAssigneeAgentId: string | null; }; function readNonEmptyString(value: unknown): string | null { @@ -590,6 +591,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) snoozedUntil: quietUntilDecision?.snoozedUntil ?? null, evaluationIssueId: evaluation?.id ?? null, evaluationIssueIdentifier: evaluation?.identifier ?? null, + evaluationIssueAssigneeAgentId: evaluation?.assigneeAgentId ?? null, }; } diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index c8f83134e71..b5b68946c08 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -238,6 +238,11 @@ export type CurrentBoardAccess = { userId: string; isInstanceAdmin: boolean; companyIds: string[]; + memberships?: Array<{ + companyId: string; + membershipRole: HumanCompanyRole | "member" | null; + status: "pending" | "active" | "suspended" | "archived"; + }>; source: string; keyId: string | null; }; diff --git a/ui/src/components/IssueRunLedger.test.tsx b/ui/src/components/IssueRunLedger.test.tsx index adefcf4fd00..7ab4bf1c84b 100644 --- a/ui/src/components/IssueRunLedger.test.tsx +++ b/ui/src/components/IssueRunLedger.test.tsx @@ -124,6 +124,7 @@ function createActiveRun(overrides: Partial = {}): ActiveRunF snoozedUntil: null, evaluationIssueId: "issue-eval-1", evaluationIssueIdentifier: "PAP-404", + evaluationIssueAssigneeAgentId: "agent-owner", }, ...overrides, }; @@ -139,6 +140,8 @@ function renderLedger(props: Partial, ); @@ -366,4 +369,22 @@ describe("IssueRunLedger", () => { evaluationIssueId: "issue-eval-1", }); }); + + it("hides watchdog decision actions for known non-owner viewers", () => { + const onWatchdogDecision = vi.fn(); + renderLedger({ + runs: [createRun({ runId: "run-live-1", status: "running", finishedAt: null })], + activeRun: createActiveRun(), + canRecordWatchdogDecisions: false, + onWatchdogDecision, + }); + + expect(container.textContent).toContain("Stale-run watchdog alert"); + expect(container.textContent).toContain("PAP-404"); + expect(container.textContent).not.toContain("Continue monitoring"); + expect(container.textContent).not.toContain("Snooze 1h"); + expect(container.textContent).not.toContain("Mark false positive"); + expect(container.querySelectorAll("button")).toHaveLength(0); + expect(onWatchdogDecision).not.toHaveBeenCalled(); + }); }); diff --git a/ui/src/components/IssueRunLedger.tsx b/ui/src/components/IssueRunLedger.tsx index 54ceaf0a798..45f9bc2671f 100644 --- a/ui/src/components/IssueRunLedger.tsx +++ b/ui/src/components/IssueRunLedger.tsx @@ -1,14 +1,17 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import type { Issue, Agent } from "@paperclipai/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link } from "@/lib/router"; +import { accessApi, type CurrentBoardAccess } from "../api/access"; import { activityApi, type RunForIssue, type RunLivenessState } from "../api/activity"; +import { ApiError } from "../api/client"; import { heartbeatsApi, type ActiveRunForIssue, type LiveRunForIssue, type WatchdogDecisionInput, } from "../api/heartbeats"; +import { useToastActions } from "../context/ToastContext"; import { cn, relativeTime } from "../lib/utils"; import { queryKeys } from "../lib/queryKeys"; import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data"; @@ -16,6 +19,7 @@ import { describeRunRetryState } from "../lib/runRetryState"; type IssueRunLedgerProps = { issueId: string; + companyId: string; issueStatus: Issue["status"]; childIssues: Issue[]; agentMap: ReadonlyMap; @@ -30,6 +34,8 @@ type IssueRunLedgerContentProps = { childIssues: Issue[]; agentMap: ReadonlyMap>; pendingWatchdogDecision?: WatchdogDecisionInput["decision"] | null; + canRecordWatchdogDecisions?: boolean; + watchdogDecisionError?: string | null; onWatchdogDecision?: (input: WatchdogDecisionInput) => void; }; @@ -309,14 +315,45 @@ function formatSilenceAge(ms: number | null | undefined) { return `${hours}h ${minutes}m`; } +function canBoardRecordWatchdogDecision( + companyId: string, + boardAccess: CurrentBoardAccess | undefined, +) { + if (!boardAccess) return false; + if (boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin) return true; + + const membership = boardAccess.memberships?.find( + (item) => item.companyId === companyId && item.status === "active", + ); + if (!membership) return boardAccess.companyIds.includes(companyId) && !boardAccess.memberships; + return membership.membershipRole !== "viewer" && membership.membershipRole !== null; +} + +function watchdogDecisionErrorMessage(error: unknown) { + if (error instanceof ApiError && error.status === 403) { + return "Only the board or the assigned recovery owner can record watchdog decisions"; + } + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : "Paperclip could not record the watchdog decision."; +} + export function IssueRunLedger({ issueId, + companyId, issueStatus, childIssues, agentMap, hasLiveRuns, }: IssueRunLedgerProps) { const queryClient = useQueryClient(); + const { pushToast } = useToastActions(); + const [watchdogDecisionError, setWatchdogDecisionError] = useState(null); + const { data: boardAccess } = useQuery({ + queryKey: queryKeys.access.currentBoardAccess, + queryFn: () => accessApi.getCurrentBoardAccess(), + retry: false, + }); const { data: runs } = useQuery({ queryKey: queryKeys.issues.runs(issueId), queryFn: () => activityApi.runsForIssue(issueId), @@ -339,10 +376,25 @@ export function IssueRunLedger({ }); const watchdogDecision = useMutation({ mutationFn: (input: WatchdogDecisionInput) => heartbeatsApi.recordWatchdogDecision(input), + onMutate: () => { + setWatchdogDecisionError(null); + }, onSuccess: () => { + setWatchdogDecisionError(null); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId) }); }, + onError: (error) => { + const message = watchdogDecisionErrorMessage(error); + const dedupeSuffix = error instanceof ApiError ? String(error.status) : "error"; + setWatchdogDecisionError(message); + pushToast({ + title: "Watchdog decision not recorded", + body: message, + tone: "error", + dedupeKey: `watchdog-decision:${issueId}:${dedupeSuffix}`, + }); + }, }); return ( @@ -354,6 +406,8 @@ export function IssueRunLedger({ childIssues={childIssues} agentMap={agentMap} pendingWatchdogDecision={watchdogDecision.variables?.decision ?? null} + canRecordWatchdogDecisions={canBoardRecordWatchdogDecision(companyId, boardAccess)} + watchdogDecisionError={watchdogDecisionError} onWatchdogDecision={(input) => watchdogDecision.mutate(input)} /> ); @@ -367,6 +421,8 @@ export function IssueRunLedgerContent({ childIssues, agentMap, pendingWatchdogDecision, + canRecordWatchdogDecisions = true, + watchdogDecisionError, onWatchdogDecision, }: IssueRunLedgerContentProps) { const ledgerRuns = useMemo(() => mergeRuns(runs, liveRuns, activeRun), [activeRun, liveRuns, runs]); @@ -468,7 +524,7 @@ export function IssueRunLedgerContent({ ) : null}

- {onWatchdogDecision ? ( + {onWatchdogDecision && canRecordWatchdogDecisions ? (
) : null} + {watchdogDecisionError ? ( +

+ {watchdogDecisionError} +

+ ) : null} ) : null} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index f35fa22b503..319cb7b75d6 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -837,6 +837,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ type IssueDetailActivityTabProps = { issueId: string; + companyId: string; issueStatus: Issue["status"]; childIssues: Issue[]; agentMap: Map; @@ -850,6 +851,7 @@ type IssueDetailActivityTabProps = { function IssueDetailActivityTab({ issueId, + companyId, issueStatus, childIssues, agentMap, @@ -941,6 +943,7 @@ function IssueDetailActivityTab({
Date: Fri, 24 Apr 2026 19:26:13 -0500 Subject: [PATCH 4/5] Polish markdown external link wrapping (#4447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The board UI renders agent comments, PR links, issue links, and operational markdown throughout issue threads > - Long GitHub and external links can wrap awkwardly, leaving icons orphaned from the text they describe > - Small inbox visual polish also helps repeated board scanning without changing behavior > - This pull request glues markdown link icons to adjacent link characters and removes a redundant inbox list border > - The benefit is cleaner, more stable markdown and inbox rendering for day-to-day operator review ## What Changed - Added an external-link indicator for external markdown links. - Kept the GitHub icon attached to the first link character so it does not wrap onto a separate line. - Kept the external-link icon attached to the final link character so it does not wrap away from the URL/text. - Added markdown rendering regressions for GitHub and external link icon wrapping. - Removed the extra border around the inbox list card. ## Verification - `pnpm exec vitest run --project @paperclipai/ui ui/src/components/MarkdownBody.test.tsx` - `pnpm --filter @paperclipai/ui typecheck` ## Risks - Low risk. The markdown change is limited to link child rendering and preserves existing href/target/rel behavior. - Visual-only inbox polish. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-enabled with shell/GitHub/Paperclip API access. Context window was not reported by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- ui/src/components/MarkdownBody.test.tsx | 24 +++++++++- ui/src/components/MarkdownBody.tsx | 61 +++++++++++++++++++++++-- ui/src/pages/Inbox.tsx | 2 +- 3 files changed, 81 insertions(+), 6 deletions(-) diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 63e92cb5810..8f64bf8f6fa 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -316,12 +316,16 @@ describe("MarkdownBody", () => { expect(html).toContain('rel="noreferrer"'); }); - it("prefixes GitHub markdown links with the GitHub icon", () => { + it("prefixes GitHub markdown links with the GitHub icon glued to the first character", () => { const html = renderMarkdown("[https://github.com/paperclipai/paperclip/pull/4099](https://github.com/paperclipai/paperclip/pull/4099)"); expect(html).toContain('https://github.com/paperclipai/paperclip/pull/4099"); + // The icon and first character "h" must sit in a no-wrap span so the + // icon can never be orphaned on the previous line from the URL text. + expect(html).toMatch(/.*lucide-github.*?<\/svg>h<\/span>/); + expect(html).toContain("ttps://github.com/paperclipai/paperclip/pull/4099"); + expect(html).not.toContain("lucide-external-link"); }); it("prefixes GitHub autolinks with the GitHub icon", () => { @@ -338,6 +342,22 @@ describe("MarkdownBody", () => { expect(html).not.toContain("lucide-github"); }); + it("suffixes external links with a new-tab icon glued to the last character", () => { + const html = renderMarkdown("[docs](https://example.com/docs)"); + + expect(html).toContain('target="_blank"'); + expect(html).toContain("lucide-external-link"); + // Last character "s" must sit in a no-wrap span with the icon so the + // indicator never wraps away from the link text. + expect(html).toMatch(/s]*lucide-external-link/); + }); + + it("does not render the new-tab icon on internal links", () => { + const html = renderMarkdown("[settings](/company/settings)"); + + expect(html).not.toContain("lucide-external-link"); + }); + it("keeps fenced code blocks width-bounded and horizontally scrollable", () => { const html = renderMarkdown("```text\nGET /heartbeat-runs/ca5d23fc-c15b-4826-8ff1-2b6dd11be096/log?offset=2062357&limitBytes=256000\n```"); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 6ab4ff3768e..b155032f788 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -1,6 +1,6 @@ import { isValidElement, useEffect, useId, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; -import { Github } from "lucide-react"; +import { ExternalLink, Github } from "lucide-react"; import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown"; import remarkGfm from "remark-gfm"; import { cn } from "../lib/utils"; @@ -133,6 +133,56 @@ function isExternalHttpUrl(href: string | null | undefined): boolean { } } +function renderLinkBody( + children: ReactNode, + leadingIcon: ReactNode, + trailingIcon: ReactNode, +): ReactNode { + if (!leadingIcon && !trailingIcon) return children; + + // React-markdown can pass arrays/elements for styled link text; the nowrap + // splitting below is intentionally limited to plain text links. + if (typeof children === "string" && children.length > 0) { + if (children.length === 1) { + return ( + + {leadingIcon} + {children} + {trailingIcon} + + ); + } + const first = children[0]; + const last = children[children.length - 1]; + const middle = children.slice(1, -1); + return ( + <> + {leadingIcon ? ( + + {leadingIcon} + {first} + + ) : first} + {middle} + {trailingIcon ? ( + + {last} + {trailingIcon} + + ) : last} + + ); + } + + return ( + <> + {leadingIcon} + {children} + {trailingIcon} + + ); +} + function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: boolean }) { const renderId = useId().replace(/[^a-zA-Z0-9_-]/g, ""); const [svg, setSvg] = useState(null); @@ -281,6 +331,12 @@ export function MarkdownBody({ } const isGitHubLink = isGitHubUrl(href); const isExternal = isExternalHttpUrl(href); + const leadingIcon = isGitHubLink ? ( +