From 32745f4e84b95957f26bfe84d1f58656529bdb5b Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sat, 10 Oct 2026 00:40:08 -0700 Subject: [PATCH 1/6] feat(issues): keep follow-up parents and show the creation source (#15762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents delegate work by creating follow-up tasks through the issue API, the CLI, and the chat `create_task` tool > - An agent can omit `parentId` when it creates a follow-up from an ordinary execution task. The API accepts the task as a top-level task. The task hierarchy does not show where the task came from, even though the run provenance is recorded > - The operator must read run history to learn why the task exists. This is slow and it hides delegated work from the parent task > - This pull request keeps the parent link for delegated follow-ups by default, keeps intentional standalone tasks possible, and shows an access-checked "Created from by " note on the task page from the recorded run provenance > - The benefit is that each agent-created task shows its source without log inspection, while structural parentage, provenance, and blockers stay separate concepts ## Linked Issues or Issue Description Refs #13241 (shows tasks created from a task on the source task page; this PR shows the reverse direction on the created task) Refs #9727 (open proposal for creation provenance tracking) **What existing behavior does this improve?** Issue creation by agent runs, and the task detail page. **Subsystem affected** Server (`POST /api/companies/:companyId/issues`, `GET /api/issues/:id`), shared types, UI task detail page, CLI `issue create`, Paperclip skill docs. **Current behavior** When an agent run bound to an execution task creates a task and omits `parentId`, the task is created with no parent. The task page shows no source. The operator must inspect the run log to find the task that the agent was working on. The source run and the privacy parent are recorded, but the UI does not use them. **Proposed behavior** - When an agent run bound to an ordinary execution task omits `parentId`, the server sets `parentId` to that task. The `issue.created` activity records `parentDefaultedFromRunIssue: true`. - An explicit `parentId: null` keeps the task standalone. Conversation runs (chat handoffs), board or user creates, watchdog follow-ups, a run task the agent may not mutate, a default parent that would cause a delegation cycle, and a default parent the agent may not create a child under (for example a protected assignment policy without a grant) also stay standalone. The server runs the same `tasks:assign` decision with the defaulted parent in scope before it accepts the default. No request that succeeded before now fails. An explicit `parentId` is still checked as before. - `GET /api/issues/:id` returns `createdFrom` with the source task, the run, and the agent. The server resolves it from `origin_run_id`, or from the recorded creation activity for historical rows. It returns `null` when the viewer may not read the source task or the run, when the run is from another company, or when provenance is missing. - The task page shows "Created from by " in the classic breadcrumb area and in the streamlined Tasks tab. The note is not a blocker and it is shown with or without a parent. - The CLI `issue create` command gets `--standalone`, which sends `parentId: null`. **Reason and benefit** Operators see why a task exists on the task itself. Delegated follow-ups stay inside the parent task tree, so parent completion and child wake-ups keep working. Private and cross-company sources stay hidden. **Breaking changes** None for the API schema. Agents that create tasks from a run and relied on the omitted `parentId` to mean "top-level" must now send `parentId: null` (CLI: `--standalone`). The skill and the spec document this. ## What Changed - `server/src/services/issue-creation-source.ts` (new): resolves a task's creation source from the origin run or the creation activity, scoped to the company, and loads the run-bound task by UUID or identifier. - `server/src/routes/issues.ts`: `POST /companies/:companyId/issues` defaults `parentId` to the run's task when the body omits it (with the exclusions above) and records the default in the activity log. `GET /issues/:id` adds the access-checked `createdFrom` field. - `packages/shared/src/types/issue.ts` and index exports: new `IssueCreationSource` type and the optional `createdFrom` field on `Issue`. - `ui/src/components/task-detail/IssueCreatedFromNote.tsx` (new), `TaskDetailTasksPanel.tsx`, and `IssueDetail.tsx`: show the creation source in both task page layouts and count it in the Tasks tab badge. - `cli/src/commands/client/issue.ts`: `issue create --standalone`. - Docs: `doc/SPEC-implementation.md`, `doc/CLI.md`, `skills/paperclip/SKILL.md`. - Tests: `server/src/__tests__/issue-creation-source-routes.test.ts` (new), `TaskDetailTasksPanel.test.tsx`, `IssueDetail.test.tsx`. ## Verification Run the new server route test (embedded Postgres): ```sh cd server && pnpm exec vitest run src/__tests__/issue-creation-source-routes.test.ts ``` It covers: ordinary delegation gets the parent and `createdFrom`; explicit `parentId: null` stays standalone but still shows the source; conversation handoff and board create stay standalone; a would-be delegation cycle falls back to standalone; a protected source task without a grant falls back to standalone while an explicit parent still returns 403; an explicit cycle still returns 409; historical provenance from the creation activity; missing provenance, a private source for an outsider, and a cross-company run all return `null`. Related existing suites that passed locally (11 files, 313 tests): `issue-created-from-routes`, `issue-create-deduplication-routes`, `issue-agent-mutation-ownership-routes`, `chat-project-tools`, `issue-assignee-invokability-routes`, `issue-watchdogs-routes`, `issue-dependency-wakeups-routes`, `public-mcp`, `native-runtime/task-creation-backlog`, `issue-access-grants-routes`, `issue-list-assignee-filter-routes`. UI: ```sh cd ui && pnpm exec vitest run src/components/task-detail/TaskDetailTasksPanel.test.tsx src/pages/IssueDetail.test.tsx pnpm check:token-gates ``` Manual check: as an agent run bound to a task, `POST /api/companies/:companyId/issues` with only a title. The response has `parentId` set to the run's task. Open the new task; the page shows "Created from by ". The related server suites pass locally (`issue-created-from`, `issue-create-deduplication`, `issue-agent-mutation-ownership`, `chat-project-tools`, `issue-watchdogs`, `public-mcp`, `task-creation-backlog`, `issue-access-grants`, `permissions-upgrade-boundary`). The full `pnpm -r typecheck`, `pnpm test:run`, and `pnpm build` were not run locally because the sandbox has a 4 GB memory limit and the TypeScript compiler is killed on the server project; the CI gates on this PR cover them. ## Risks - Behavior shift for agent clients: a run-created task without `parentId` now becomes a child of the run's task. Clients that want a top-level task must send `parentId: null`. The watchdog, conversation, and board paths are excluded, and the default never adds a blocker. - The default parent inherits the parent's execution workspace, which run-created tasks already inherited from the run's task. - `createdFrom` adds one run lookup, one issue lookup, one access decision, and one agent lookup to the task detail request. It is skipped when the task has no origin run and no creation activity with a run. - Privacy: the field returns `null` unless the viewer can read the source task and the run, so a private source is indistinguishable from missing provenance. ## Model Used Claude Fable 5.1 (`claude-fable-5-1`), Anthropic, run through Claude Code in a Paperclip-managed execution workspace with tool use, shell execution, and a local embedded Postgres for tests. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- cli/src/commands/client/issue.ts | 10 +- doc/CLI.md | 4 +- doc/SPEC-implementation.md | 2 + doc/observability.md | 5 +- packages/shared/src/index.ts | 1 + packages/shared/src/types/index.ts | 1 + packages/shared/src/types/issue.ts | 18 ++ .../issue-creation-source-routes.test.ts | 217 ++++++++++++++++++ server/src/routes/issues.ts | 156 ++++++++++++- server/src/services/issue-creation-source.ts | 111 +++++++++ server/src/services/issue-read-timing.ts | 2 +- skills/paperclip/SKILL.md | 4 +- .../task-detail/IssueCreatedFromNote.tsx | 45 ++++ .../task-detail/TaskDetailTasksPanel.test.tsx | 26 +++ .../task-detail/TaskDetailTasksPanel.tsx | 12 +- ui/src/pages/IssueDetail.test.tsx | 12 +- ui/src/pages/IssueDetail.tsx | 22 +- 17 files changed, 628 insertions(+), 20 deletions(-) create mode 100644 server/src/__tests__/issue-creation-source-routes.test.ts create mode 100644 server/src/services/issue-creation-source.ts create mode 100644 ui/src/components/task-detail/IssueCreatedFromNote.tsx diff --git a/cli/src/commands/client/issue.ts b/cli/src/commands/client/issue.ts index 2e4cdf6ddc0..e2c067a1593 100644 --- a/cli/src/commands/client/issue.ts +++ b/cli/src/commands/client/issue.ts @@ -59,6 +59,7 @@ interface IssueCreateOptions extends BaseClientOptions { projectId?: string; goalId?: string; parentId?: string; + standalone?: boolean; requestDepth?: string; billingCode?: string; } @@ -286,10 +287,17 @@ export function registerIssueCommands(program: Command): void { .option("--project-id ", "Project ID") .option("--goal-id ", "Goal ID") .option("--parent-id ", "Parent issue ID") + .option( + "--standalone", + "Create a top-level issue. Without --parent-id, an agent run's issue otherwise becomes the parent.", + ) .option("--request-depth ", "Request depth integer") .option("--billing-code ", "Billing code") .action(async (opts: IssueCreateOptions) => { try { + if (opts.standalone && opts.parentId) { + throw new Error("--standalone cannot be combined with --parent-id"); + } const ctx = resolveCommandContext(opts, { requireCompany: true }); const payload = createIssueSchema.parse({ title: opts.title, @@ -299,7 +307,7 @@ export function registerIssueCommands(program: Command): void { assigneeAgentId: opts.assigneeAgentId, projectId: opts.projectId, goalId: opts.goalId, - parentId: opts.parentId, + parentId: opts.standalone ? null : opts.parentId, requestDepth: parseOptionalInt(opts.requestDepth), billingCode: opts.billingCode, }); diff --git a/doc/CLI.md b/doc/CLI.md index c1a6da5659b..7efb1eb5801 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -424,7 +424,7 @@ Notes: ```sh npx paperclipai issue list --company-id [--status todo,in_progress] [--assignee-agent-id ] [--match text] npx paperclipai issue get -npx paperclipai issue create --company-id --title "..." [--description "..."] [--status todo] [--priority high] +npx paperclipai issue create --company-id --title "..." [--description "..."] [--status todo] [--priority high] [--parent-id | --standalone] npx paperclipai issue update [--status in_progress] [--comment "..."] npx paperclipai issue delete --yes npx paperclipai issue comment --body "..." [--attachment-id ] [--reopen] @@ -440,6 +440,8 @@ npx paperclipai issue release npx paperclipai issue force-release ``` +`issue create` sent from an agent run bound to an ordinary execution task is parented under that task when `--parent-id` is omitted; pass `--standalone` to create intentionally top-level work (`parentId: null`). The two flags are mutually exclusive. + Issue subresources are exposed as Paperclip API wrappers. Commands that map to broad server schemas accept JSON payloads and validate them with shared schemas before sending. ```sh diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index d5c9334627e..ce2344ddeb4 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -277,6 +277,8 @@ retain their titles and default to no generation request. - identifier fields: `issue_number`, `identifier` - origin fields: `origin_kind`, `origin_id`, `origin_run_id`, `origin_fingerprint` - Creation stores the actor run in `origin_run_id` unless an explicit origin run is supplied. `GET /api/companies/:companyId/issues?createdFromIssueId=` selects tasks created by runs bound to that source task, using native run issue identity or persisted legacy task context. Historical rows without an origin run may use their recorded creation activity; comments and shared creators do not establish provenance. Source, run, activity and result are company-scoped. +- Delegated follow-ups keep their structural parent. When an agent run bound to an ordinary execution task creates an issue and omits `parentId`, the server defaults `parentId` to that run's task and records `parentDefaultedFromRunIssue: true` on the `issue.created` activity. An explicit `parentId: null` keeps the task standalone. The default never applies to conversation runs (chat handoffs stay top-level), to watchdog follow-ups, to board/user creates, to a run task the agent may not mutate, when the default parent would create a delegation cycle with the requested assignee, or when the agent may not create a child under the default parent (for example a protected assignment policy without a grant); those requests stay standalone instead of failing. An explicitly requested parent is still subject to the ordinary assignment check. Structural parentage, creation provenance and blockers stay distinct: the default adds no blocker. +- `GET /api/issues/:id` returns `createdFrom` (`{ issue: { id, identifier, title, status }, run: { id, agentId }, agent: { id, name } | null } | null`): the task the origin run was executing when it created this issue, resolved from `origin_run_id` or, for historical rows, the run on the recorded creation activity. It is present whether or not `parentId` is set, and only when the viewer may read the source task and its run; a private, hidden-from-viewer or cross-company source yields `null`, indistinguishable from missing provenance. - Relation lists can use `sortField=id&sortDir=asc&afterId=` for stable pagination. The cursor excludes earlier IDs and cannot be combined with an offset or activity-based order. - The streamlined task page's Tasks tab keeps two independent memberships: the existing subtask tree, and created tasks grouped by their current project (or No project). A created subtask appears in both. Only Subtasks has completion progress; groups collapse independently and unfinished tasks sort above finished tasks. - `request_depth` int not null default 0 diff --git a/doc/observability.md b/doc/observability.md index 56c550161bc..021c26ec87c 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -48,8 +48,9 @@ There are no custom attributes, identifiers, content, or exception messages. The closed phase names in `server/src/services/issue-read-timing.ts` are: `lookup`, `authorization`, `project_goal`, `ancestors`, `mentions`, `documents`, `relations`, `blockers`, `review`, `references`, `handoff`, `retry`, `recovery`, -`cases`, `inbox`, `channel`, `workspace`, `work_products`, `execution_blocker`, -`relation_recovery`, `revalidate_recovery`, and `mentioned_projects`. +`cases`, `inbox`, `channel`, `workspace`, `work_products`, `created_from`, +`execution_blocker`, `relation_recovery`, `revalidate_recovery`, and +`mentioned_projects`. Without an OTLP endpoint, spans remain no-ops. This adds no first-party Telemetry events or run-log events. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 110381b71a3..9dba828880f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1245,6 +1245,7 @@ export type { DocumentFormat, LegacyPlanDocument, IssueAttachment, + IssueCreationSource, IssueLabel, IssueTreeControlPreview, IssueTreeHold, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index ec590e8e140..be561795fda 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -804,6 +804,7 @@ export type { IssueAncestorProject, IssueAncestorGoal, IssueAttachment, + IssueCreationSource, IssueLabel, IssueWatchdog, IssueWatchdogStatus, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 31a9468a3f5..78d60aafbbd 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -128,6 +128,22 @@ export interface IssueLabel { updatedAt: Date; } +/** + * The task a run was executing when it created this issue, from recorded run + * provenance. Independent of the structural parent and of blockers. Only + * present when the viewer may read the source task and its run. + */ +export interface IssueCreationSource { + issue: { + id: string; + identifier: string | null; + title: string; + status: string; + }; + run: { id: string; agentId: string }; + agent: { id: string; name: string } | null; +} + export interface IssueAssigneeAdapterOverrides { adapterConfig?: Record; useProjectWorkspace?: boolean; @@ -839,6 +855,8 @@ export interface Issue { /** Immediate task from which private access flows downward, including chat handoffs. */ privacyParentIssueId?: string | null; ancestors?: IssueAncestor[]; + /** Detail responses only: access-checked creation provenance, or null. */ + createdFrom?: IssueCreationSource | null; title: string; description: string | null; descriptionTruncated?: boolean; diff --git a/server/src/__tests__/issue-creation-source-routes.test.ts b/server/src/__tests__/issue-creation-source-routes.test.ts new file mode 100644 index 00000000000..1d259b374e1 --- /dev/null +++ b/server/src/__tests__/issue-creation-source-routes.test.ts @@ -0,0 +1,217 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { activityLog, agents, companies, createDb, heartbeatRuns, issues } from "@paperclipai/db"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { issueRoutes } from "../routes/issues.js"; +import { errorHandler } from "../middleware/index.js"; + +const support = await getEmbeddedPostgresTestSupport(); +const describePostgres = support.supported ? describe : describe.skip; + +describePostgres("agent-created follow-ups: parent links and creation source", () => { + let db: ReturnType; + let database: Awaited>; + const companyId = randomUUID(); + const otherCompanyId = randomUUID(); + const qaAgentId = randomUUID(); + const engineerAgentId = randomUUID(); + const foreignAgentId = randomUUID(); + const sourceId = randomUUID(); + const conversationId = randomUUID(); + const privateSourceId = randomUUID(); + const protectedSourceId = randomUUID(); + const foreignSourceId = randomUUID(); + const runOnSource = randomUUID(); + const runOnConversation = randomUUID(); + const runOnPrivate = randomUUID(); + const runOnProtected = randomUUID(); + const foreignRun = randomUUID(); + const legacyRun = randomUUID(); + + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase("paperclip-creation-source-"); + db = createDb(database.connectionString); + await db.insert(companies).values([ + { id: companyId, name: "Paperclip", issuePrefix: "PAP", defaultResponsibleUserId: "board-user", requireBoardApprovalForNewAgents: false }, + { id: otherCompanyId, name: "Other", issuePrefix: "OTH", defaultResponsibleUserId: "board-user", requireBoardApprovalForNewAgents: false }, + ]); + await db.insert(agents).values([ + { id: qaAgentId, companyId, name: "Paperclip QA", role: "qa", adapterType: "codex_local", status: "active" }, + { id: engineerAgentId, companyId, name: "Engineer", role: "engineer", adapterType: "codex_local", status: "active" }, + { id: foreignAgentId, companyId: otherCompanyId, name: "Other QA", role: "qa", adapterType: "codex_local", status: "active" }, + ]); + await db.insert(issues).values([ + { id: sourceId, companyId, title: "Verify Tailscale fix", identifier: "PAP-168", issueNumber: 168, status: "in_progress", assigneeAgentId: qaAgentId, createdByAgentId: engineerAgentId }, + { id: conversationId, companyId, title: "Chat with QA", identifier: "PAP-170", issueNumber: 170, status: "in_review", assigneeAgentId: qaAgentId, conversationAgentId: qaAgentId, conversationUserId: "board-user", conversationState: "waiting" }, + { id: privateSourceId, companyId, title: "Private finding", identifier: "PAP-171", issueNumber: 171, status: "in_progress", visibility: "private", privacyRootIssueId: privateSourceId, assigneeAgentId: qaAgentId }, + { id: protectedSourceId, companyId, title: "Protected task", identifier: "PAP-172", issueNumber: 172, status: "in_progress", assigneeAgentId: qaAgentId, executionPolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }, + { id: foreignSourceId, companyId: otherCompanyId, title: "Foreign source", identifier: "OTH-1", issueNumber: 1 }, + ]); + await db.insert(heartbeatRuns).values([ + { id: runOnSource, companyId, agentId: qaAgentId, scopeKind: "issue", issueId: sourceId, nativeIssueId: sourceId, status: "running", contextSnapshot: { issueId: sourceId } }, + { id: runOnConversation, companyId, agentId: qaAgentId, scopeKind: "issue", issueId: conversationId, status: "running", contextSnapshot: { issueId: conversationId } }, + { id: runOnPrivate, companyId, agentId: qaAgentId, scopeKind: "issue", issueId: privateSourceId, status: "running", contextSnapshot: { issueId: privateSourceId } }, + { id: runOnProtected, companyId, agentId: qaAgentId, scopeKind: "issue", issueId: protectedSourceId, status: "running", contextSnapshot: { issueId: protectedSourceId } }, + { id: foreignRun, companyId: otherCompanyId, agentId: foreignAgentId, scopeKind: "issue", issueId: foreignSourceId, status: "running" }, + { id: legacyRun, companyId, agentId: qaAgentId, status: "succeeded", contextSnapshot: { taskKey: "PAP-168" } }, + ]); + }, 30_000); + afterAll(async () => { await database?.cleanup(); }); + + const boardActor = { type: "board", userId: "board-user", source: "local_implicit", isInstanceAdmin: true } as Express.Request["actor"]; + function agentActor(agentId: string, runId?: string): Express.Request["actor"] { + return { type: "agent", companyId, agentId, runId, source: "agent_jwt", onBehalfOfUserId: null, onBehalfOfMemberships: [] } as Express.Request["actor"]; + } + function app(actor: Express.Request["actor"]) { + const instance = express(); + instance.use(express.json()); + instance.use((req, _res, next) => { req.actor = actor; next(); }); + instance.use("/api", issueRoutes(db, {} as never)); + instance.use(errorHandler); + return instance; + } + async function createdActivity(issueId: string) { + const [row] = await db.select().from(activityLog) + .where(and(eq(activityLog.entityId, issueId), eq(activityLog.action, "issue.created"))); + return row ?? null; + } + + it("parents an ordinary delegated follow-up under the run's task when parentId is omitted", async () => { + const created = await request(app(agentActor(qaAgentId, runOnSource))) + .post(`/api/companies/${companyId}/issues`) + .send({ title: "Fix regression found during verification" }); + expect(created.status, JSON.stringify(created.body)).toBe(201); + expect(created.body.parentId).toBe(sourceId); + expect(created.body.originRunId).toBe(runOnSource); + expect(created.body.blockedBy ?? []).toEqual([]); + const activity = await createdActivity(created.body.id); + expect(activity?.details).toMatchObject({ parentId: sourceId, parentDefaultedFromRunIssue: true }); + + const detail = await request(app(boardActor)).get(`/api/issues/${created.body.id}`); + expect(detail.status).toBe(200); + expect(detail.body.createdFrom).toEqual({ + issue: { id: sourceId, identifier: "PAP-168", title: "Verify Tailscale fix", status: "in_progress" }, + run: { id: runOnSource, agentId: qaAgentId }, + agent: { id: qaAgentId, name: "Paperclip QA" }, + }); + }); + + it("keeps an explicit parentId: null standalone but still exposes the source task", async () => { + const created = await request(app(agentActor(qaAgentId, runOnSource))) + .post(`/api/companies/${companyId}/issues`) + .send({ title: "Intentional top-level follow-up", parentId: null }); + expect(created.status, JSON.stringify(created.body)).toBe(201); + expect(created.body.parentId).toBeNull(); + const activity = await createdActivity(created.body.id); + expect(activity?.details).not.toHaveProperty("parentDefaultedFromRunIssue"); + + const detail = await request(app(boardActor)).get(`/api/issues/${created.body.id}`); + expect(detail.status).toBe(200); + expect(detail.body.parentId).toBeNull(); + expect(detail.body.createdFrom).toMatchObject({ + issue: { id: sourceId, identifier: "PAP-168" }, + agent: { name: "Paperclip QA" }, + }); + }); + + it("never parents conversation handoffs or board-created tasks", async () => { + const handoff = await request(app(agentActor(qaAgentId, runOnConversation))) + .post(`/api/companies/${companyId}/issues`) + .send({ title: "Execution task from chat" }); + expect(handoff.status, JSON.stringify(handoff.body)).toBe(201); + expect(handoff.body.parentId).toBeNull(); + + const manual = await request(app(boardActor)) + .post(`/api/companies/${companyId}/issues`) + .send({ title: "Board task" }); + expect(manual.status, JSON.stringify(manual.body)).toBe(201); + expect(manual.body.parentId).toBeNull(); + const detail = await request(app(boardActor)).get(`/api/issues/${manual.body.id}`); + expect(detail.body.createdFrom).toBeNull(); + }); + + it("stays standalone instead of failing when the default parent would be a delegation cycle", async () => { + const created = await request(app(agentActor(qaAgentId, runOnSource))) + .post(`/api/companies/${companyId}/issues`) + .send({ title: "Bug for the engineer who delegated to QA", assigneeAgentId: engineerAgentId }); + expect(created.status, JSON.stringify(created.body)).toBe(201); + expect(created.body.parentId).toBeNull(); + expect(created.body.assigneeAgentId).toBe(engineerAgentId); + const detail = await request(app(boardActor)).get(`/api/issues/${created.body.id}`); + expect(detail.body.createdFrom).toMatchObject({ issue: { id: sourceId } }); + }); + + it("stays standalone instead of failing when the default parent is protected without a grant", async () => { + // The agent runs the protected task, so it may mutate it, but a protected + // assignment policy denies child creation without an explicit grant. + const created = await request(app(agentActor(qaAgentId, runOnProtected))) + .post(`/api/companies/${companyId}/issues`) + .send({ title: "Follow-up from a protected task" }); + expect(created.status, JSON.stringify(created.body)).toBe(201); + expect(created.body.parentId).toBeNull(); + const activity = await createdActivity(created.body.id); + expect(activity?.details).not.toHaveProperty("parentDefaultedFromRunIssue"); + const detail = await request(app(boardActor)).get(`/api/issues/${created.body.id}`); + expect(detail.body.createdFrom).toMatchObject({ issue: { id: protectedSourceId, identifier: "PAP-172" } }); + + const explicit = await request(app(agentActor(qaAgentId, runOnProtected))) + .post(`/api/companies/${companyId}/issues`) + .send({ title: "Explicit child of a protected task", parentId: protectedSourceId }); + expect(explicit.status, JSON.stringify(explicit.body)).toBe(403); + }); + + it("still honors an explicit parent and rejects an explicit delegation cycle", async () => { + const [other] = await db.insert(issues).values({ companyId, title: "Another parent", status: "todo" }).returning(); + const created = await request(app(agentActor(qaAgentId, runOnSource))) + .post(`/api/companies/${companyId}/issues`) + .send({ title: "Explicit child", parentId: other!.id }); + expect(created.status, JSON.stringify(created.body)).toBe(201); + expect(created.body.parentId).toBe(other!.id); + const activity = await createdActivity(created.body.id); + expect(activity?.details).not.toHaveProperty("parentDefaultedFromRunIssue"); + + const cycle = await request(app(agentActor(qaAgentId, runOnSource))) + .post(`/api/companies/${companyId}/issues`) + .send({ title: "Explicit cycle", parentId: sourceId, assigneeAgentId: engineerAgentId }); + expect(cycle.status).toBe(409); + }); + + it("resolves historical provenance from the creation activity's run", async () => { + const historicalId = randomUUID(); + await db.insert(issues).values({ id: historicalId, companyId, title: "Historical follow-up", status: "todo", createdByAgentId: qaAgentId }); + await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: qaAgentId, runId: legacyRun, action: "issue.created", entityType: "issue", entityId: historicalId }); + const detail = await request(app(boardActor)).get(`/api/issues/${historicalId}`); + expect(detail.status).toBe(200); + expect(detail.body.createdFrom).toMatchObject({ issue: { id: sourceId, identifier: "PAP-168" }, run: { id: legacyRun } }); + }); + + it("returns null when provenance is missing, inaccessible, or from another company", async () => { + const orphanId = randomUUID(); + const fromPrivateId = randomUUID(); + const fromForeignId = randomUUID(); + await db.insert(issues).values([ + { id: orphanId, companyId, title: "No provenance", status: "todo", createdByAgentId: qaAgentId }, + { id: fromPrivateId, companyId, title: "Created from a private task", status: "todo", originRunId: runOnPrivate, createdByAgentId: qaAgentId }, + { id: fromForeignId, companyId, title: "Origin run in another company", status: "todo", originRunId: foreignRun }, + ]); + await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: qaAgentId, runId: null, action: "issue.created", entityType: "issue", entityId: orphanId }); + + const orphan = await request(app(boardActor)).get(`/api/issues/${orphanId}`); + expect(orphan.body.createdFrom).toBeNull(); + + const foreign = await request(app(boardActor)).get(`/api/issues/${fromForeignId}`); + expect(foreign.status).toBe(200); + expect(foreign.body.createdFrom).toBeNull(); + + const asOutsider = await request(app(agentActor(engineerAgentId))).get(`/api/issues/${fromPrivateId}`); + expect(asOutsider.status, JSON.stringify(asOutsider.body)).toBe(200); + expect(asOutsider.body.createdFrom).toBeNull(); + + const asParticipant = await request(app(agentActor(qaAgentId))).get(`/api/issues/${fromPrivateId}`); + expect(asParticipant.status).toBe(200); + expect(asParticipant.body.createdFrom).toMatchObject({ issue: { id: privateSourceId, identifier: "PAP-171" } }); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index f738e91abac..149fef0a791 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -3,6 +3,11 @@ import { hasRequiredWorkspaceRecovery, LEGACY_WORKSPACE_RECOVERY_SCHEMA } from " import { monitorPoliciesEqual, applyActorMonitorScheduledBy, assertCanManageIssueMonitor, summarizeIssueMonitor } from "../services/issue-monitors.js"; import type { IssuePrivacyConstraints } from "@paperclipai/shared"; import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js"; +import { + loadCreationSourceAgent, + loadRunBoundIssue, + resolveIssueCreationSource, +} from "../services/issue-creation-source.js"; import { activeIssueInteractionCondition, readTaskQuestionContext } from "../services/issue-question-context.js"; import { setIssueTitle } from "../services/issue-title.js"; import { setIssueTitleSchema } from "@paperclipai/shared"; @@ -113,6 +118,7 @@ import { isMarkdownArtifactWorkProduct, isMarkdownAttachmentContent, isUuidLike, + type IssueCreationSource, normalizeIssueIdentifier as normalizeIssueReferenceIdentifier, type CompactIssue, type CompanySearchExtractQuery, @@ -3755,6 +3761,124 @@ export function issueRoutes( ); } + /** + * Default structural parent for a delegated follow-up: the task the agent's + * run is executing. Returns null when the new task should stay standalone: + * no run-bound task, a conversation (chat handoffs are top-level by design), + * a task the agent may not mutate, a parent that would create a delegation + * cycle with the requested assignee, or a parent the agent may not create + * children under (for example a protected assignment policy without a + * grant). The default must never turn a previously allowed standalone + * create into a denial; an explicitly requested parent is still rejected by + * the ordinary assignment check. Callers only use this when the request + * omitted `parentId`; an explicit `parentId: null` keeps the task standalone. + */ + async function resolveRunDelegationParentDefault( + req: Request, + companyId: string, + rawCreateBody: Omit< + Parameters[0], + "companyId" | "parentId" + > & { assigneeUserId?: string | null }, + assigneeAgentId: string | null, + ): Promise { + if ( + req.actor.type !== "agent" || + !req.actor.agentId || + !req.actor.runId || + !isUuidLike(req.actor.runId) + ) + return null; + const run = await db + .select() + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.id, req.actor.runId), + eq(heartbeatRuns.companyId, companyId), + ), + ) + .then((rows) => rows[0] ?? null); + if (!run || run.agentId !== req.actor.agentId) return null; + const source = await loadRunBoundIssue(db, run); + if (!source || isConversation(source)) return null; + const decision = await decideIssueAccess(req, source, "issue:mutate"); + if (!decision.allowed) return null; + if (assigneeAgentId && assigneeAgentId !== req.actor.agentId) { + const ancestor = await svc.findOpenAncestorCreatedByAgent( + source.id, + assigneeAgentId, + ); + if (ancestor) return null; + } + // Mirror the assignment check the create path runs with the defaulted + // parent in scope. A protected parent (or project it supplies) denies + // child creation without a grant; that must fall back to standalone work + // rather than fail a request that succeeded before the default existed. + const assignmentScope: TaskAssignmentAuthorizationScope = { + projectId: await resolveCreateAssignmentProjectId({ + ...rawCreateBody, + companyId, + parentId: source.id, + }), + parentIssueId: source.id, + assigneeAgentId, + assigneeUserId: + typeof rawCreateBody.assigneeUserId === "string" + ? rawCreateBody.assigneeUserId + : null, + }; + const assignment = await access.decide({ + actor: req.actor, + action: "tasks:assign", + resource: { + type: "issue", + companyId, + issueId: null, + projectId: assignmentScope.projectId ?? null, + parentIssueId: source.id, + assigneeAgentId, + assigneeUserId: assignmentScope.assigneeUserId ?? null, + }, + scope: assignmentScope, + }); + if (!assignment.allowed) return null; + return source.id; + } + + /** + * Creation provenance the viewer may see: the source task must be readable + * and the run must be visible to the actor. Returns null otherwise, so a + * private or foreign source is indistinguishable from no provenance. + */ + async function resolveVisibleIssueCreationSource( + req: Request, + issue: { id: string; companyId: string; originRunId: string | null }, + ): Promise { + const source = await resolveIssueCreationSource(db, issue); + if (!source) return null; + const { run, sourceIssue } = source; + const readable = await decideIssueAccess(req, sourceIssue, "issue:read"); + if (!readable.allowed) return null; + const runVisible = await canActorReadHeartbeatRun(db, access, req.actor, { + companyId: run.companyId, + scopeKind: run.scopeKind, + issueId: run.issueId ?? run.nativeIssueId ?? sourceIssue.id, + }); + if (!runVisible) return null; + const agent = await loadCreationSourceAgent(db, issue.companyId, run.agentId); + return { + issue: { + id: sourceIssue.id, + identifier: sourceIssue.identifier, + title: sourceIssue.title, + status: sourceIssue.status, + }, + run: { id: run.id, agentId: run.agentId }, + agent, + }; + } + async function resolveAgentTrustForIssue( input: { agentId: string | null | undefined; @@ -9146,6 +9270,7 @@ export function issueRoutes( externalChannelBinding, currentExecutionWorkspace, workProducts, + createdFrom, ] = await Promise.all([ timing.time("project_goal", () => resolveIssueProjectAndGoal(issue)), timing.time("ancestors", () => svc.getAncestors(issue.id)), @@ -9169,6 +9294,7 @@ export function issueRoutes( ? executionWorkspacesSvc.getById(issue.executionWorkspaceId) : Promise.resolve(null)), timing.time("work_products", () => workProductsSvc.listForIssue(issue.id)), + timing.time("created_from", () => resolveVisibleIssueCreationSource(req, issue)), ]); const [recoveryActionsByRelationIssue, revalidatedActiveRecoveryAction, mentionedProjects] = await Promise.all([ timing.time("relation_recovery", () => relationRecoveryActionMap(recoveryActionsSvc, issue.companyId, relations)), @@ -9218,6 +9344,7 @@ export function issueRoutes( workProducts, linkedCases, externalChannelBinding, + createdFrom, }); }); @@ -12061,9 +12188,27 @@ export function issueRoutes( watchdogDiscovery, ); if (watchdogProductBugFollowUp === false) return; + const normalizedAssigneeAgentId = + await normalizeIssueAssigneeAgentReference( + companyId, + rawCreateBody.assigneeAgentId as string | null | undefined, + { actorType: req.actor.type }, + ); + // A delegated follow-up from an ordinary execution task keeps that task as + // its structural parent when the agent omits `parentId`. An explicit + // `parentId: null` is an intentional standalone task and is honored. + const runDelegationParentId = + !watchdogProductBugFollowUp && rawCreateBody.parentId === undefined + ? await resolveRunDelegationParentDefault( + req, + companyId, + rawCreateBody, + normalizedAssigneeAgentId ?? null, + ) + : null; const effectiveParentId = watchdogProductBugFollowUp ? null - : rawCreateBody.parentId; + : (runDelegationParentId ?? rawCreateBody.parentId); let createParent: Awaited> | null = null; if (req.actor.type === "agent" && effectiveParentId) { createParent = await svc.getById(effectiveParentId); @@ -12087,12 +12232,6 @@ export function issueRoutes( )) ) return; - const normalizedAssigneeAgentId = - await normalizeIssueAssigneeAgentReference( - companyId, - rawCreateBody.assigneeAgentId as string | null | undefined, - { actorType: req.actor.type }, - ); await assertNoAgentDelegationCycle({ actorType: req.actor.type, actorAgentId: req.actor.agentId, @@ -12328,6 +12467,9 @@ export function issueRoutes( details: { title: issue.title, identifier: issue.identifier, + ...(runDelegationParentId && issue.parentId === runDelegationParentId + ? { parentId: issue.parentId, parentDefaultedFromRunIssue: true } + : {}), ...(watchdogProductBugFollowUp ? { watchdogDiscovery: { diff --git a/server/src/services/issue-creation-source.ts b/server/src/services/issue-creation-source.ts new file mode 100644 index 00000000000..42f5ce816a7 --- /dev/null +++ b/server/src/services/issue-creation-source.ts @@ -0,0 +1,111 @@ +import { and, asc, eq, inArray, isNotNull } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { activityLog, agents, heartbeatRuns, issues } from "@paperclipai/db"; +import { isUuidLike } from "@paperclipai/shared"; + +type IssueRow = typeof issues.$inferSelect; +type HeartbeatRunRow = typeof heartbeatRuns.$inferSelect; + +/** The task a run was executing when it created another task. */ +export interface IssueCreationSourceRecord { + run: HeartbeatRunRow; + sourceIssue: IssueRow; +} + +const CREATION_ACTIVITY_ACTIONS = ["issue.created", "issue.child_created"]; + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +/** + * The issue a heartbeat run is bound to: native run identity first, then the + * run's issue scope, then the persisted legacy task context. Returns a UUID or + * an issue identifier such as `PAP-168`; the caller resolves identifiers. + */ +export function runBoundIssueReference(run: Pick): string | null { + const context = run.contextSnapshot && typeof run.contextSnapshot === "object" + ? (run.contextSnapshot as Record) + : null; + const paperclipIssue = context?.paperclipIssue && typeof context.paperclipIssue === "object" + ? (context.paperclipIssue as Record) + : null; + return ( + run.nativeIssueId ?? + run.issueId ?? + readNonEmptyString(context?.issueId) ?? + readNonEmptyString(context?.taskId) ?? + readNonEmptyString(paperclipIssue?.id) ?? + readNonEmptyString(context?.taskKey) + ); +} + +/** Loads the run's bound issue within the run's company, or null. */ +export async function loadRunBoundIssue( + db: Db, + run: Pick, +): Promise { + const reference = runBoundIssueReference(run); + if (!reference) return null; + const condition = isUuidLike(reference) + ? eq(issues.id, reference) + : eq(issues.identifier, reference); + const [issue] = await db + .select() + .from(issues) + .where(and(condition, eq(issues.companyId, run.companyId))) + .limit(1); + return issue ?? null; +} + +/** + * Resolves the task from which an issue was created, using recorded run + * provenance: the issue's origin run, or for historical rows without one, the + * run on its creation activity. Company-scoped; a run or source task from + * another company never resolves. Does not check viewer access. + */ +export async function resolveIssueCreationSource( + db: Db, + issue: Pick, +): Promise { + let runId = issue.originRunId; + if (!runId) { + const [creation] = await db + .select({ runId: activityLog.runId }) + .from(activityLog) + .where(and( + eq(activityLog.companyId, issue.companyId), + eq(activityLog.entityType, "issue"), + eq(activityLog.entityId, issue.id), + inArray(activityLog.action, CREATION_ACTIVITY_ACTIONS), + isNotNull(activityLog.runId), + )) + .orderBy(asc(activityLog.createdAt)) + .limit(1); + runId = creation?.runId ?? null; + } + if (!runId || !isUuidLike(runId)) return null; + const [run] = await db + .select() + .from(heartbeatRuns) + .where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, issue.companyId))) + .limit(1); + if (!run) return null; + const sourceIssue = await loadRunBoundIssue(db, run); + if (!sourceIssue || sourceIssue.id === issue.id) return null; + return { run, sourceIssue }; +} + +/** Company-scoped agent summary for a creation source. */ +export async function loadCreationSourceAgent( + db: Db, + companyId: string, + agentId: string, +): Promise<{ id: string; name: string } | null> { + const [agent] = await db + .select({ id: agents.id, name: agents.name }) + .from(agents) + .where(and(eq(agents.id, agentId), eq(agents.companyId, companyId))) + .limit(1); + return agent ?? null; +} diff --git a/server/src/services/issue-read-timing.ts b/server/src/services/issue-read-timing.ts index 332bf3d97fc..a3244e6f653 100644 --- a/server/src/services/issue-read-timing.ts +++ b/server/src/services/issue-read-timing.ts @@ -5,7 +5,7 @@ import { SpanStatusCode, trace } from "@opentelemetry/api"; type IssueReadPhase = "lookup" | "authorization" | "project_goal" | "ancestors" | "mentions" | "documents" | "relations" | "blockers" | "review" | "references" | "handoff" | "retry" | "recovery" | "cases" | "inbox" - | "channel" | "workspace" | "work_products" | "execution_blocker" + | "channel" | "workspace" | "work_products" | "created_from" | "execution_blocker" | "relation_recovery" | "revalidate_recovery" | "mentioned_projects"; export function createIssueReadTiming() { diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index f2ec350c7da..c3b6993bfd0 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -219,7 +219,7 @@ Before ending any heartbeat, apply this final-disposition checklist: - `done`: the requested work is complete, verification is recorded, and no follow-up remains on this issue. - `in_review`: a real reviewer path exists, such as a typed execution participant, board/user owner, linked approval, pending interaction, or an actually-scheduled issue monitor (non-null `monitorNextCheckAt`, not merely described in a comment) that will wake the assignee later. Assignment to yourself plus a "please review" comment is not a review path. - `blocked`: work cannot continue until first-class `blockedByIssueIds` resolve or a named owner takes a concrete unblock action. -- Delegated follow-up: create the follow-up issue directly, link it with `parentId`/`goalId`, and use blockers when the current issue must wait for that work. +- Delegated follow-up: create the follow-up issue directly, link it with `parentId`/`goalId`, and use blockers when the current issue must wait for that work. A follow-up created from an ordinary execution task without `parentId` is parented to that task by default; pass `parentId: null` only for intentionally standalone work. - Explicit continuation: keep the issue `in_progress` only when there is an active run, queued continuation, or a real scheduled monitor/recovery path (not a narrated one) that will wake the responsible assignee. Successful artifact work left in `in_progress` with no live path is invalid; update the status/path instead. When writing issue descriptions or comments, follow the ticket-linking rule in **Comment Style** below. @@ -264,7 +264,7 @@ Because of that, follow these rules: - **Never imply a live watcher on a task you are marking `done`.** `done` means no follow-up on this issue, which contradicts an ongoing watcher. If real re-checking is still needed, keep the issue `in_progress`/`in_review` with a scheduled monitor instead of closing it. - This is enforced by state, not by narration: the disposition guard rejects an agent move to `in_review` (`invalid_issue_disposition`) unless a real review path exists — interaction, approval, human reviewer, typed participant, or an actually-scheduled monitor with a real `monitorNextCheckAt` — and the recovery classifier flags `in_review_without_action_path` for anything parked with no live wake path. Keep your comments consistent with that real state. -**Step 9 — Delegate if needed.** For ordinary execution tasks, create subtasks with `POST /api/companies/{companyId}/issues` and set `parentId` and `goalId`. For conversation tasks, use the project handoff above instead. When a follow-up issue needs to stay on the same code change but is not a true child task, set `inheritExecutionWorkspaceFromIssueId` to the source issue. Set `billingCode` for cross-team work. +**Step 9 — Delegate if needed.** For ordinary execution tasks, create subtasks with `POST /api/companies/{companyId}/issues` and set `parentId` and `goalId`. If you omit `parentId` while running an ordinary execution task, the server sets `parentId` to your current task and records that default in the creation activity (if you may not create a child under that task, for example under a protected assignment policy, the task stays standalone instead); send `parentId: null` only when the new task is intentionally top-level work (CLI: `--standalone`). Either way the follow-up keeps run provenance, and the task page shows "Created from by " without implying a blocker. For conversation tasks, use the project handoff above instead. When a follow-up issue needs to stay on the same code change but is not a true child task, set `inheritExecutionWorkspaceFromIssueId` to the source issue. Set `billingCode` for cross-team work. ### Delegating review tasks diff --git a/ui/src/components/task-detail/IssueCreatedFromNote.tsx b/ui/src/components/task-detail/IssueCreatedFromNote.tsx new file mode 100644 index 00000000000..839cdb9b2eb --- /dev/null +++ b/ui/src/components/task-detail/IssueCreatedFromNote.tsx @@ -0,0 +1,45 @@ +import type { IssueCreationSource } from "@paperclipai/shared"; +import { GitBranch } from "lucide-react"; +import { + createIssueDetailPath, + rememberIssueDetailLocationState, +} from "@/lib/issueDetailBreadcrumb"; +import { Link } from "@/lib/router"; +import { cn } from "@/lib/utils"; + +export interface IssueCreatedFromNoteProps { + /** Access-checked creation provenance from the issue detail response. */ + createdFrom: IssueCreationSource | null | undefined; + issueLinkState?: unknown; + className?: string; +} + +/** + * "Created from PAP-168 by Paperclip QA": the task a run was executing when it + * created this one. Provenance only; it is not the structural parent and it is + * not a blocker. Renders nothing when the viewer may not see the source. + */ +export function IssueCreatedFromNote({ createdFrom, issueLinkState, className }: IssueCreatedFromNoteProps) { + if (!createdFrom) return null; + const { issue, agent } = createdFrom; + const ref = issue.identifier ?? issue.id; + return ( +

+ + Created from + rememberIssueDetailLocationState(ref, issueLinkState)} + className="min-w-0 truncate hover:text-foreground transition-colors" + title={issue.title} + > + {issue.identifier ? `${issue.identifier} ${issue.title}` : issue.title} + + {agent && by {agent.name}} +

+ ); +} diff --git a/ui/src/components/task-detail/TaskDetailTasksPanel.test.tsx b/ui/src/components/task-detail/TaskDetailTasksPanel.test.tsx index 8da9642cf0c..866efd786db 100644 --- a/ui/src/components/task-detail/TaskDetailTasksPanel.test.tsx +++ b/ui/src/components/task-detail/TaskDetailTasksPanel.test.tsx @@ -98,4 +98,30 @@ describe("TaskDetailTasksPanel", () => { expect(retry).toHaveBeenCalledOnce(); expect(container.querySelector('[data-task-id="child"]')).not.toBeNull(); }); + + it("shows the access-checked creation source even when the task has no parent", () => { + render({ + createdFrom: { + issue: { id: "source", identifier: "PAP-168", title: "Verify Tailscale fix", status: "in_progress" }, + run: { id: "run-1", agentId: "qa" }, + agent: { id: "qa", name: "Paperclip QA" }, + }, + subtasks: [], createdTasks: [], projects: [], + }); + const group = container.querySelector('section[aria-label="Created from"]')!; + expect(group).not.toBeNull(); + const note = group.querySelector('[data-testid="issue-created-from"]')!; + expect(note.textContent).toContain("Created from"); + expect(note.textContent).toContain("PAP-168 Verify Tailscale fix"); + expect(note.textContent).toContain("by Paperclip QA"); + expect(note.querySelector('a')?.getAttribute("href")).toBe("/issues/PAP-168"); + expect(container.querySelector('section[aria-label="Ancestors"]')).toBeNull(); + expect(container.textContent).not.toContain("No tasks yet."); + }); + + it("omits the creation source when the viewer may not see it", () => { + render({ createdFrom: null, subtasks: [], createdTasks: [], projects: [] }); + expect(container.querySelector('section[aria-label="Created from"]')).toBeNull(); + expect(container.textContent).toContain("No tasks yet."); + }); }); diff --git a/ui/src/components/task-detail/TaskDetailTasksPanel.tsx b/ui/src/components/task-detail/TaskDetailTasksPanel.tsx index cb48ebe5193..8f3f91738f4 100644 --- a/ui/src/components/task-detail/TaskDetailTasksPanel.tsx +++ b/ui/src/components/task-detail/TaskDetailTasksPanel.tsx @@ -7,6 +7,7 @@ import { issueStatusOrder } from "@/lib/issue-filters"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { RelationNavigationList, TaskDetailSubtasksPanel, TaskDetailTaskList } from "./TaskDetailRelationsPanel"; +import { IssueCreatedFromNote } from "./IssueCreatedFromNote"; function TaskGroup({ name, projectPath, children }: { name: string; projectPath?: string; children: ReactNode }) { return ( @@ -36,6 +37,8 @@ function TaskGroup({ name, projectPath, children }: { name: string; projectPath? export interface TaskDetailTasksPanelProps { /** API order: immediate parent first. Displayed root first. */ ancestors?: NonNullable; + /** Access-checked creation provenance; shown even when the source is not the parent. */ + createdFrom?: Issue["createdFrom"]; subtasks: Issue[]; createdTasks: Issue[]; projects: Project[]; @@ -45,7 +48,7 @@ export interface TaskDetailTasksPanelProps { issueLinkState?: unknown; } -export function TaskDetailTasksPanel({ ancestors = [], subtasks, createdTasks, projects, isLoading, hasError, onRetry, issueLinkState }: TaskDetailTasksPanelProps) { +export function TaskDetailTasksPanel({ ancestors = [], createdFrom = null, subtasks, createdTasks, projects, isLoading, hasError, onRetry, issueLinkState }: TaskDetailTasksPanelProps) { const sortedSubtasks = sortTasks(subtasks); const groups = new Map(); for (const item of sortTasks(createdTasks)) { @@ -63,6 +66,11 @@ export function TaskDetailTasksPanel({ ancestors = [], subtasks, createdTasks, p } return (
+ {createdFrom && ( + + + + )} {ancestors.length > 0 && ( Retry} )} - {!isLoading && !hasError && ancestors.length === 0 && subtasks.length === 0 && createdTasks.length === 0 && ( + {!isLoading && !hasError && !createdFrom && ancestors.length === 0 && subtasks.length === 0 && createdTasks.length === 0 && (

No tasks yet.

)}
diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 69d2043dc49..7249989b805 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -2471,7 +2471,12 @@ describe("IssueDetail", () => { it("loads ancestors, subtask membership and created work independently and refreshes on issue activity", async () => { const ancestors = [{ id: "parent-task", identifier: "PAP-0", title: "Parent task", status: "in_progress" }] as Issue["ancestors"]; - const source = createIssue({ ancestors }); + const createdFrom: Issue["createdFrom"] = { + issue: { id: "origin-task", identifier: "PAP-168", title: "Origin task", status: "in_progress" }, + run: { id: "run-1", agentId: "qa-agent" }, + agent: { id: "qa-agent", name: "Paperclip QA" }, + }; + const source = createIssue({ ancestors, createdFrom }); const child = createIssue({ id: "manual-child", parentId: source.id, title: "Manual child" }); const created = createIssue({ id: "created-task", parentId: null, title: "Created elsewhere" }); mockIssuesApi.get.mockResolvedValue(source); @@ -2485,7 +2490,8 @@ describe("IssueDetail", () => { expect(taskProjection()?.content.props.subtasks.map((row: Issue) => row.id)).toEqual([child.id]); expect(taskProjection()?.content.props.createdTasks.map((row: Issue) => row.id)).toEqual([created.id]); expect(taskProjection()?.content.props.ancestors).toEqual(ancestors); - expect(taskProjection()?.count).toBe(3); + expect(taskProjection()?.content.props.createdFrom).toEqual(createdFrom); + expect(taskProjection()?.count).toBe(4); const next = createIssue({ id: "new-created-task", parentId: source.id }); mockIssuesApi.list.mockImplementation((_companyId, filters?: { descendantOf?: string; createdFromIssueId?: string }) => @@ -2493,7 +2499,7 @@ describe("IssueDetail", () => { ); await act(async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(source.companyId) }); }); await flushReact(); - expect(taskProjection()?.count).toBe(4); + expect(taskProjection()?.count).toBe(5); expect(taskProjection()?.content.props.createdTasks.map((row: Issue) => row.id)).toContain(next.id); }); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 7b5dbcd5413..a7b7ff66094 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -16,6 +16,7 @@ import { agentDetailHref } from "./agent-detail-navigation"; import { ExecutionBlockerNotice } from "../components/ExecutionBlockerNotice"; import type { TaskComposerPause } from "../components/task-chat/TaskChatPausedTakeover"; import { TaskDetailTasksPanel } from "@/components/task-detail/TaskDetailTasksPanel"; +import { IssueCreatedFromNote } from "@/components/task-detail/IssueCreatedFromNote"; import { EmailThreadProvider } from "../components/EmailMessageCard"; import { EmailTaskActivity } from "../components/EmailTaskActivity"; import { TaskChatScrollNavigation, taskChatScrollEntry } from "@/components/task-chat/scroll-navigation"; @@ -3654,11 +3655,17 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS const createdTasks = createdTasksQuery.data ?? EMPTY_ISSUES; const hasError = createdTasksQuery.isError || childIssuesError; return { - count: new Set([...(issue?.ancestors ?? []), ...childIssues, ...createdTasks].map((task) => task.id)).size, + count: new Set([ + ...(issue?.createdFrom ? [issue.createdFrom.issue] : []), + ...(issue?.ancestors ?? []), + ...childIssues, + ...createdTasks, + ].map((task) => task.id)).size, hasError, content: ( ) : null; + // Creation provenance ("Created from PAP-168 by Paperclip QA"). Distinct from + // the parent chain above; the streamlined Tasks tab renders it instead. + const createdFromNote = + !streamlinedTaskDetailEnabled && issue.createdFrom ? ( + + ) : null; const issueStatusControl = ( {ancestorsNav} + {createdFromNote} {issueHeaderBlock} {pluginOutletsBlock} @@ -7620,6 +7639,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS > {/* Parent chain breadcrumb (redesign: rendered inside the thread viewport) */} {taskChatShellEnabled ? null : ancestorsNav} + {taskChatShellEnabled ? null : createdFromNote} Date: Sat, 10 Oct 2026 00:42:10 -0700 Subject: [PATCH 2/6] fix(ui): keep code-block pastes in the nested editor (#15773) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - People write a routine description in a rich markdown editor > - That editor contains a nested code block for literal text > - A paste that starts with a markdown mark, such as a dash, goes to the outer editor > - The text does not appear in the code block > - This pull request sends that paste to the nested editor as literal text > - This pull request also adds a copy control next to the Overview heading > - The benefit is that a person can keep a routine description in a code block and copy it to another Paperclip instance ## Linked Issues or Issue Description **What happened?** A paste into a code block in a routine description does not appear when the pasted text starts with a markdown mark, such as a dash. A plain single line still appears. **Expected behavior** The nested code editor receives the paste as literal text. The text stays in the code block. **Steps to reproduce** 1. Open a routine and edit the description. 2. Add a text code block. 3. Click inside that code block. 4. Paste a line that starts with a dash. 5. The line does not appear in the code block. **Paperclip version or commit** `master` at `3389bea`. **Deployment mode** Local dev (pnpm dev) ## What Changed - The markdown editor does not take a paste that starts inside a nested code block. - The same editor is used for descriptions in the routine page and in the rest of the app. - The Overview heading has a copy button. - The button copies the draft description while you edit. - The button copies the saved description when you do not edit. - Unit tests cover the paste path and the copy button. - Screenshots show the heading while you view a description and while you edit it. ## Screenshots The copy icon sits next to the Overview title. The heading component is the production component. The page frame around it is a preview. ![Overview heading while you view a description, light theme](https://raw.githubusercontent.com/paperclipai/paperclip/PAP-103-pasting-into-text-area/docs/pr-screenshots/pr-15773/overview-view-light.png) ![Overview heading while you edit a description, light theme](https://raw.githubusercontent.com/paperclipai/paperclip/PAP-103-pasting-into-text-area/docs/pr-screenshots/pr-15773/overview-edit-light.png) ![Overview heading while you view a description, dark theme](https://raw.githubusercontent.com/paperclipai/paperclip/PAP-103-pasting-into-text-area/docs/pr-screenshots/pr-15773/overview-view-dark.png) ![Overview heading while you edit a description, dark theme](https://raw.githubusercontent.com/paperclipai/paperclip/PAP-103-pasting-into-text-area/docs/pr-screenshots/pr-15773/overview-edit-dark.png) ## Verification - Run `corepack pnpm exec vitest run --config ui/vitest.config.ts ui/src/components/MarkdownEditor.test.tsx ui/src/components/RoutineSectionHeading.test.tsx ui/src/lib/routine-description-copy.test.ts` from the repository root. 57 tests passed on the code commit. - Open a routine description. Paste a line that starts with a dash into a text code block. Confirm that the line stays in the code block. - Click the copy button next to Overview. Paste the result into a new routine. Confirm that the description matches the draft while you edit, and the saved description when you do not edit. - Open the screenshots above. Confirm the copy icon is next to Overview in the view state and in the edit state, in light theme and in dark theme. - This workspace did not open a signed-in routine page. The screenshots render the production heading with the app color tokens. ## Risks Low risk. A paste inside a code block now stays in that block. A paste in the rest of the editor still follows the previous markdown path. The copy button only reads the description text. ## Model Used xAI Grok 4.7 with tool use. The model wrote this change in a Paperclip agent run. Context window is large. The model used tools to edit code, run tests, and capture screenshots. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (no user docs describe this paste path) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Paperclip --- docs/pr-screenshots/pr-15773/README.md | 15 ++++ .../pr-15773/overview-edit-dark.png | Bin 0 -> 52273 bytes .../pr-15773/overview-edit-light.png | Bin 0 -> 52183 bytes .../pr-15773/overview-view-dark.png | Bin 0 -> 53439 bytes .../pr-15773/overview-view-light.png | Bin 0 -> 53459 bytes ui/src/components/MarkdownEditor.test.tsx | 59 ++++++++++++++ ui/src/components/MarkdownEditor.tsx | 31 ++++++-- .../components/RoutineSectionHeading.test.tsx | 73 ++++++++++++++++++ ui/src/components/RoutineSectionHeading.tsx | 30 +++++++ ui/src/lib/routine-description-copy.test.ts | 28 +++++++ ui/src/lib/routine-description-copy.ts | 12 +++ ui/src/pages/RoutineDetail.production.tsx | 15 +++- ui/src/pages/RoutineDetail.tsx | 15 +++- 13 files changed, 267 insertions(+), 11 deletions(-) create mode 100644 docs/pr-screenshots/pr-15773/README.md create mode 100644 docs/pr-screenshots/pr-15773/overview-edit-dark.png create mode 100644 docs/pr-screenshots/pr-15773/overview-edit-light.png create mode 100644 docs/pr-screenshots/pr-15773/overview-view-dark.png create mode 100644 docs/pr-screenshots/pr-15773/overview-view-light.png create mode 100644 ui/src/components/RoutineSectionHeading.test.tsx create mode 100644 ui/src/components/RoutineSectionHeading.tsx create mode 100644 ui/src/lib/routine-description-copy.test.ts create mode 100644 ui/src/lib/routine-description-copy.ts diff --git a/docs/pr-screenshots/pr-15773/README.md b/docs/pr-screenshots/pr-15773/README.md new file mode 100644 index 00000000000..ae0ff5c3645 --- /dev/null +++ b/docs/pr-screenshots/pr-15773/README.md @@ -0,0 +1,15 @@ +# Overview copy control + +These Chromium screenshots render the production `RoutineSectionHeading` and `CopyText` components with the app color tokens. The header and description frame is a preview around that heading. It is not a capture of a signed-in routine page. + +Each theme shows the same heading in two states: + +- View: the saved description is visible, and the header shows Edit routine. +- Edit: the description source is visible, and the header shows Cancel editing. + +The copy icon stays next to the Overview title in both states. Desktop and mobile widths were checked. These files are the desktop captures. + +- [View, light](overview-view-light.png) +- [Edit, light](overview-edit-light.png) +- [View, dark](overview-view-dark.png) +- [Edit, dark](overview-edit-dark.png) diff --git a/docs/pr-screenshots/pr-15773/overview-edit-dark.png b/docs/pr-screenshots/pr-15773/overview-edit-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..1f9a65da489722190b3ed6a60be5efb86602c49e GIT binary patch literal 52273 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 I(%}040A>k?b^rhX literal 0 HcmV?d00001 diff --git a/docs/pr-screenshots/pr-15773/overview-edit-light.png b/docs/pr-screenshots/pr-15773/overview-edit-light.png new file mode 100644 index 0000000000000000000000000000000000000000..0ed726e7e8affc314cb9dcad8e35ccc251d9f302 GIT binary patch literal 52183 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 Vn3P_xU+iZ)aYXTOip<3u{|(#vU&R0b literal 0 HcmV?d00001 diff --git a/docs/pr-screenshots/pr-15773/overview-view-dark.png b/docs/pr-screenshots/pr-15773/overview-view-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..a333928bb0f50e50637307d3c6bb2b3ae7536c43 GIT binary patch literal 53439 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 TXZLxKPxQhW8Og-cS6%-X&0N;x literal 0 HcmV?d00001 diff --git a/docs/pr-screenshots/pr-15773/overview-view-light.png b/docs/pr-screenshots/pr-15773/overview-view-light.png new file mode 100644 index 0000000000000000000000000000000000000000..a1e9c8c457fc148de02e53a8fef049645b7fbb71 GIT binary patch literal 53459 zcmeFZbySwy*EM`&iy|tZAS$Sc2qG3BAYq_V3ev3@G)N(LfOL0A zx1^NcynaVKzJGkr7~endc;5GW9A}&(9=W-$z4uyct~ux0uF{t-P*LunB#}r|Vi(U{ zA(4pRt{z*r0{_`E5EZ%fH8euiYil|NG_pRTM}5{p!K`6<7cL zY8}n0xBq^%nRT5L#h)*kB{!2+{`pGe6*FngpRb&boF;Aj^A+jZz5m@maIf>f58C;S zSPhTD{SrPUEL;A1p>={BGtZtr&9ffw>@I$pG)K=Y<~kO}igcyg&1p_F!f7{#XOvD6c~l@a3J?9T2Ko-a6rUUF#`SdWKI>93LRQ8muzzVqX|E6+;Se^*dMht2xR zMazX*lbdhFzZ|<&VqMIcNzZ&JK-h6QC;HsUQwSz%6s?j-KxDt^54~V+AKW~ zVK7AgwIsVH-r$gGhQXY^(-G6Dftu@|-TIS`wZ|)`vX(l${rd0u*vwmJe<|qH>q*7e zO4CCPVvbuJ&*&X>F%pz$toiyY;>lco)9^CrA2|N?txzJ zfZg5_C%)-F%e8rhc4-gRU^TkdeeBXfv5aUOJZIdFsv&y_(o;N?xPplULu86tB5#+O^K9 ztEMzifO_Z2&BF!^$8LNH6tKb`@7udqa`U~{yLGFgzIk)JY~>MsY@O5_eJOBI{#!vDZx90tv z=ryj5S8mEMl#P;P&?%*tr{-95_)5gZbiJD0_LCJQzQ_GNR8ntAb$xo~O~LqQN7^5D z?tXjm9P!S^Z3U4MFD*w~^X-2AsEU$09(ud4JQNXpIpE}YgLZI&bm;p4J~PQaH5|hJ z=wQ~x)yp3vq9sCldQPvmG!RcXQXcvN=RmS==gHe4VqUCF4zr=_w?tMqWg7D?e*;Nm zuZOjZ)0!KV5n_~1T_6%>4BO!JW1&fb)0Q~9>bZD zmbKKot3TjeG+WfE;~CjiGvdPz(P&E`b7UC!vZ|!%n0(%4waU-%$M>&}zZNrDgKS6m zaxc5t{rGx7(|*d^NAI)St~I<=s~;OSruhomW~d*tne3Zsb0n)~8nHfHvuP*&@F@iE zU`4pg`ck~_?mOSNNR{~Wj<(v4rWYIcvh=b8)OW+st-mBv9DVAtZNVAXn6bVr;DAL&7KpLbS%#s6P4h z0V(qRi@)Xull%@KG7I0O>(`kVSkkdd_&lB5{?E>c)X(<#8#`c8aN<-cl2sHwKV#(8 zDqP3?b}mrZc6vy>R3*bexAT1es1wD?6EfMR-E1q`U&}`8ji0DU*H_dg?X;So8LhL` z3q!s_?mT{R@~~RgSp9W(PUSb}s^~Zr4#`H9;8|ye8mbVYSoxQ1GJDUD-jXNB989MY zBDuZJFl<8pKlAg;M47O$C+ns##{K=P-(Eg7e(v>^$c<;T2Wu0sNpo1tsz?dN5Rtps zyMrOz;|FgRz2MfXzD;M@UwN~$&~=x9Xi|K4m?I++p)!C!cAbpUj&ZUr}yq9=vZz z)kY0kDii*tBfu7L80R339QMc=VP@R$rn@}!?3|5ajP#y5cZ$Fl1%r=me|#6?6kA&O z`D8nnk=CWSaKRWG+sVF)G~KG6B5#RwMMh4y&;lvj8;Jb1n|87(C0{KK;46N~#iVn| zuocI#Uv<Njc%73~>QFC=wqQ4GtL^|F(f2v#Ez+53KVVj_mE=TRP zUxLSOc5HgMiBa>o!~Hc>)n}GIZr3D^Gpm?#wrONzj4^r9((8uF<`y>Iip}N1zZNE< zgB=Fg<8i*qf&_Ra|3a$yw%F#`gMs#!D`)zs`N`7N5kqR70)S# z{vfAZ|Gb7T^;m30_=Q}`a1H(L+bh>@s&*~=X#iv}P?wZs+O5HQt$#L{&n&87TUHU5 zdQOBfT}`a~@uShVWh#DLV6y*iFWz=WYdh)kt0UK%vrO(1(4y!*wNT$7B&}~R4$9`4 z?G|=Wt7A+^x+h6Cj!cqrKlS6H`))z(rPd1DPS*oOu4Z3AJb2R%>}$0dO!(?`fFmL; zcn%=|B%`Hu=FV_xnb076Nua>YOHGH@t{;`8IIGECdCf4|!AQwLaCDR8Rf z+obC+9oH`1$jBop@#8a-W4&`x=oHo2rlOn=lN?ETS}hgt#Cm<%I&ZB0+V$D}*L0I{ zy5PQj`|^*+R1RG1uT5y;%xu>B=M*@tim;U7mQ=oexYe@B5EYPX*b9ijI{^Q}+96N- zH-0;;S+G!bS-9r4?1kZA-hqOk;92_Soe0tpOU1YrYa|;>NiSr+PtO=R){gVktRd>^ z-G^LM-u~H%J>UH6=1kU~8Dyn&qGl2@SS)(`m0Pb6pO?T~^BUt~H${^d}o-{$zs zepKoP@?}Hb9WxrUqLcYgULLp{)UI7Hn3$Vqwnk-eur3Kt)TFX{RKk2*Au9dqyL(9m zb45Fz3hj}PmHWy_dSXHCszi=%`=Qk4+=(hbZX|!8F4e(S&Yj_S6iU2Ia2@T7O^DRl zv5rPr@jENOMHy61-52N@6pme&iX+z=t#!J%U%h*;=wtdeQVXS8K3B!+se<{D`LPew z7E1jc*zhTfF&8F>vC`KsRJOOHjOebRsYha*o9quCb!*kAEA3wR8g1B=k)sg>Fg9v% z*KLD+p#)d{2_!|DUG`6gk(_5{nC5JbnsgR2IWEW?obtLX^g4;QmD+Tvuka=c_MFDn zzO@uSVLa|Ket3!HmW^)cVkIeo~4mZA@GQiQ% zW3p|(yB3woFJhiz-?;2t+{v?iXO0Drl7DGP6)J4skv?}RSV*15s`XAZh0zXP8Pd*; zs}sY|Kl>3Cq@H75k&fDBVENULTffzQM!k-}GSVzN-`j3{pm#(yNjvBcPJ4l?)|#6o zlwsy7@$zv#Vj_2Qa)N{%TMHFJzR`b_+Slp7B9B^Vmidb>ot~a!f0I1fVR4}}WY9Nr z3~*V$G3|Am(VLX^nSF9G^p(bQ`nDt4z2@yF4C=?;tz}XpJJ{2j75pgTuxhbT#B(hO z#SLZ_aF+%NHm%Y|kt^!={-{D0-1t!8!s*<$$vQf|vR6mXZs^LiO%!!0LjcBIZ7M88 zdp8;Rz52E6yOMZ8V*ebcY7~n~e}nPOzp4wOc}{S5O8Fj(yxsBM@@Gw)BGPD*oHdG& z!r8DMWq=EmEQD=7c2n@@cQ3C4mwDD30f*?Fm+)ry)(<$-rEk9L_^qeh_jmUo_gjrL z18XFzW+t|n=m0e9T~CX`pXs(J)Ozw7Ybd%O;$E}#qnJyj0jP<69T#c=>Jy4emwGG$ zT8T3fd4a%;0AQih=T~n=VNBA` z4yYLGFoQ?^(Os+?AGpX8T?A_VDLALRpS2ykD zj!sfZFPeRL&cbb5$o7%ObO{=SteEey?$&%;i=KuwU9agkmrhcj8WXv@VzuN|{*Y~) zDiKt1^0j+)BPWG`_>S{Zt+>joPhXFxuUEE4NsJD%89@3=+dZ%R7W~}oG*ZG+%6O|HN{q?T=i|KjuBe{21 zTvd;_f7y{Ur(hsC@W{0@9utF|-}8p}_}tIgdsV$Z_k5|b)o$V&68x+7K1S6zg@k~8 z78fL5ss=uyWmNqj^}ELyKljs%JJd;BD!uli!{g_zcY1e;o+fZ&O=6tW=NyLQ@n^Yl z(?>sw>P7i;>-%`VS~a47!|uaC?8nO#uLgt6&3Z~I*k^ZHygBE(l|wNxEsBxHK<6d( zbPCfzH;VHcrlp^~!QcG2C)_^9lo)^i@*Mq$#WuZb@m9Oh#1I>Xvc_SO9CXD$ueBFZ zC^r}K>CXPXPr)+b=kv!uF@ECZVWCKVziEGNtp+2%xel;D(BmTdgo7&`hEhHQtp6I$ zY}=tZ=YQ2+w>n^{h$HFb=$RP%797~6#d!fozK-t+ak2E%eV%4^L%!Zi>_yxa!1-VM ztDKvqsRD$B8*F%q1@7ma)$zkl^=LzM=kUzNiYrx8b&>3&ER z9leE;b26j0me1shc0@OC;~hEW`#*o4Wd>lG1{N7g&Fe<>t~l7`s~vQ-;E*_7=@k3^ zqx}A_JSw>>3s-8-`3u@w2Fmgn)VEt*QqMr1)*oGb*!2}n*~-PuhibRH+{#V1=eO9! z&3o_gSN^Ga`&iVf*w#79NUpPUvEKtv*Cnva$M%)z-)qm=msExFA-UqpreUk^+FZ_s zepR+R`z1UN^R)itkc%k~6MZ5ulGTNG0Zyxu|J)>Rg~Jc1dg7hEO-OWrs-#$6fzQuC z&b@oX%=;_(^@au?&#dTrx#=rlu}xJQfXx7VBv&AEM3u86(kCOYk$LIWsp`6)MN|8? z{sRQo?HSB)Mas&x`}wA3b$M9*`MW+A5?8y5xzxE9aPF(q;vOcUb>44B8hYtB8f4wM zp7RPaSFtxobRoZDqS{*f>N9-?b8+gq)6N^V$47*K>ftl}o*ZntiS?9m>#6iB8hO?g zvpGU_Nt(aj-d(BJC5ZHd?CkBvp*AM4Vj2KBew%Q90#CRnPq&#$EP7JGv9~Mr>I05k zySMl7sMJXHD*js~^j*c`X>vV1QM)F~&r&NdUJ#z^3r8j>nl)T+Jn%vnjA+RK*U(ZB%{Sxk+p6rvnyBoXeb^zninru|e|?W@ukpRGYU}sYwA`W@ zdcDZyF3|r*OJv9`*Xd zg(R&WfI4?Z-b*iO0>#x*^O5yARWl@=1Wo|7&Eed(k{!u22Ll^_^n7D1@$W0Qa0`i> zcu_jl%9zeRJyI3z?wSBlraD8Xa5&~k%Aj(j6TfJV_>gX3Y+k;u1zp|Gi(5j}!{THB z(bY9yfhcGsJ31OqJyB`Nu}Dvw^NZvRH)bS|pvk)W;&01&m(MEz>hC(C<0sJ7JoB9cxvmgY|S>qW#XpLy-a9clFA=-DtD?@b@)uWt7y{!aVf*c-TW2< z5tbGo!(2miV{1jfc~XWsEKVsZdPMm&t=_P$p1P(n+pvizE>#)$zxqV4edd=hU$$#l zpYBic69Wwq%Tmu?cOgdYV2EG5%SMK<+wbO8K5nLbinggo{C)Mt7?RSnCct*%$_Up% z`3>6-%Xx*cHm2)q8yPizp%z|9_FjtJRWI(yS+t{iRYElF8#b{TAGucJt%dkY36qN4 zy*LOdR*9P^h8tpxzxCgka^sYX@z}JJdlOJ}bK&Cw&mVyV(Sn#vjGmhwj+<2U??btj zrAv1(3|VPepL}g`ZfYKwFO30&h&IjIcRSBjT$zhAd-P_v?E#`10HC#_ol5ssqe$M- ziyztzto6nvR>3daTGaTB)3)!)b3r$#)01N4P5KeeBUU{K@+RT! zmAs*-6n93MhIUzr9+>&yLY+&=p%C9E>Bz|+%CXjBQ~aLCzYa*g+Pp9to0e@Z|0RGg zwM`q1|G?t&2jeXTikglIx}5TX{1!f(stWlwQ;4`%eQE`E4e2G@)Nc+?)`fYM0E)yL zTi2GY4>a5Qo33YGYDRrInt$3q;byT9_`m|2sn|xI`#a;xB(Axy-!eFoO&t78#z?;# z`JCTnWL0D6K05#dTh8g*y}rBmAv&_SK$4Lth+w>k!PT#LS7IPaFtj%FQftQrA z=0vF<_u#V~-;X_)o{P&+EzJdxH5+{IZ#t9A9M0^leSFstVmbJXPE`6nz)tq z{)ZJ`UlD!oZG{xY>+g{RWKMEHybvwdx))20nJ*p*sB39DE?V5Qn105#wH_RMo-;oU zw{dH;YU0KEQL7bX<4#4pX!56*vpGg-NW4;Hvkd*ZQf{Tfv($p$1BX$361S4u4ry9< ztR^qM3T#U;NIaY>!zLT`G_mMzrRLAUByyg!yU<)r;8G)P?HY!nxyAmosGj+O#rwg$ zwn|JD*w5d~{&?)4(z#gx<*y;jB)o0u$h+jQT93@*pWVFY3|exyAxIQ9pvFBj-Y7xl z^r{uyUlu69d;QbK(Nr`UvTwRM70Yk7_{c~#ByQ3L{g>J*E>*O?Hj;bGW1dC%H%4~* zgyULSzYkBOT~%N@GR&+!r~ew52wN)kz6dE17%acLcKD=xLtabTIj$7;SMnc><97?% zSi2O1y}ifXaq4_+qB_w+?$GQR=54D{g)Y&Bv*>&K0(E=LBuJ>p%d``~PkX*+E~S5z zrM?{!Zngp#tFwGt7%~yS>*j8X49`tTBWpiJ&WzMIy6HTkhf$xz*hpbk2`=}a? zZ;MqZoD*vJXZ1y@tL22u`=q%g3II}DwTCT-Ou9Y^qQ+7Ns2%_Q&EE=(Lx{fP&x-Jv0ATk<3n$~mz=7##@7p4UVa$ZKcsa)Ndt6j ze;fH1Pz~)36+v-My~7!PBEbMaW~ru8ncR*Io9KD|n_sE!$K#K`}w4J9B_F8SSdg2D9E$ zLn$`WVa3D%ZbRO7j)5QZ3e$bDMNU##5;hdKQOCmbnG)I>cW$_Aw8nr#`OWwCclV|8 zT%&DC7fiIR$-LBL8H(iTE{{EDcbw38LCl)DPC>ikRhi!3CcPu@^{{WOJV&8BHU@l8xZ3t*y0 zK;7C8N_FL~_Zz2rtgkf)3VgkJo1m*qaPVREoToeau1LgLW^cErH9shAhcw82O{)_x zSfulet7BT=zTZwdSzR9TK^59}W@v>8Y>=hsn{&oWze*jPLCLG5elcMBX`d~0eDhw? zo8b$VpPNU6q8oN8kROM4z5Idxrwqr>%Pa;A?b;PnaZ>uu!nptFjtiunea@5_bfg0h zeYAVO7Ty|F6BV}X0NWS8-q^Imw6kzM&4_2~=5i(oE0-(QcQ}>g7*XxwKT)1KZa&yH zyG?X!4=A?8Q@p9AP>6(FNpyO4vH5MIj6DyfhDV zCQ7p={KK`sc)U}kAAC0%*gKN7?w0$7`9NJ9TL;7efW^)^q7CQ zi^5o4tkSP6F1hXfS4(>;&iC9+e9=08uL%st^)MPwi%EfL?%$bu>x}EG=i|#ZlVavI= zogGtknpQ&dAkg@$+Oo`f-8bZNS%@@MlEbAn`P%*Lek>qLAZNbqjGNX&;Cg5nYx!7=s~0Q>H&&+#xl!Dv<(grb8 z!*y`#ap^lAGFhhlWhs3^dxV(RZ=1$uVbkwlLT9CDfXy93OgId2xMt##3iK>K=9>3= z)|{DtyOR2xVCVR=$Ro30hQ(eU4vexIZoK)jsd#5hLaVrC&DK7vLvQ)adX#7s>bJ>q zs-#JGbwE%84>1S|qjtyYvDeW-$9RL0_LPN%`k)wcEJC!hN|?+{3Ad}U<3|+U|=VhxIX8v{5^t|n6 zXAk?qHdI+(|J@X~MPi?Ta~9a%GRy~sZUQ~B|H+A_gb4_-4qS``WonWPN@lQpY7XI> z@4WstZ^LX=gcwcWOnx_ZVID23U+rkok zCmNX*8cusc7Q0p*Ah}vNXU@!gEDhL(Osv+55<2A`k zAOkB4!6gfzXX%w4MV~D&niuX0ft8&%JEcEq+=9}$IV*+LVPX6;q^+u{cuFXxTr@Or zm>m5zAO1jE7c_TzHkz!J`>PVQ(*qO613;|zAIi)*$)Pi|X^c;&oSDo-JA9h<431+0 z6nR!c1!LoaC~k`@$4+hQ+s(jnvgDW5je!S@~>JGk|}bE;(;1-9)1%}2&!@O|N3 ze;wrK$GINb1jGP=?;d2zTYrCkA?Q-N5Hx zb1uB*PO2w^AF?^F9{714s?d(E{M^_9n-HP&cN4Zu*1PW3W=(&ZiF;6|VJj7Uv(o^m zke@m5zQrMpye#F^TdjO}$3fmn@iXEt4=)0k-YK@IoD|q}cA;YXF7kDI^O5ET@2P=~ z<7KD10#l_!&(zk%`@68mE_Mkt<=vSGD|ND1y@Ss*!mR<)foPMvuyy18(oshq_2UJz zolnq&YGmEM25~Qkp4YJPMB}^Lhvt50FS?bXBpe2TzG*TR{vfD((%uosY3AGik)1?V z&_n2`dGZ>r5sSgvXJKv5L6sX7cq32?3-edXuLB`Dl#toVjsR+W#G`?RW zareR6XV!ESdYx_pBNuxozzZvtu{RT%DZ56dI1W{3zx_lVRd(LRZ+;{t zlFGc)K~#xrgeD_1p5e9+5j%C7R^{S=U>P$6sahM(6ZpLWe|@Z<86o$QiOmyx+F9Ap9ð4d-L`|v22^Wi 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 literal 0 HcmV?d00001 diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx index ac179b36c08..1d442922983 100644 --- a/ui/src/components/MarkdownEditor.test.tsx +++ b/ui/src/components/MarkdownEditor.test.tsx @@ -931,6 +931,17 @@ describe("MarkdownEditor", () => { expect(mdxEditorMockState.insertedMarkdownValues).toEqual([pasted]); expect(innerPaste).not.toHaveBeenCalled(); + const dashed = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(dashed, "clipboardData", { + value: { types: ["text/plain"], getData: () => "- a single dashed line" }, + }); + await act(async () => { editable.dispatchEvent(dashed); }); + expect(dashed.defaultPrevented).toBe(true); + expect(mdxEditorMockState.insertedMarkdownValues).toEqual([ + pasted, + "- a single dashed line", + ]); + const plain = new Event("paste", { bubbles: true, cancelable: true }); Object.defineProperty(plain, "clipboardData", { value: { types: ["text/plain"], getData: () => "ordinary text" }, @@ -941,6 +952,54 @@ describe("MarkdownEditor", () => { await act(async () => { root.unmount(); }); }); + it("leaves a markdown paste inside a nested code block", async () => { + const root = createRoot(container); + await act(async () => { + root.render( {}} />); + }); + await flush(); + + const editable = container.querySelector('[data-testid="mdx-editor"]') as HTMLDivElement; + const code = document.createElement("div"); + code.className = "cm-content"; + code.setAttribute("contenteditable", "true"); + const text = document.createTextNode(""); + code.appendChild(text); + editable.appendChild(code); + + const range = document.createRange(); + range.setStart(text, 0); + range.collapse(true); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + code.focus(); + + const pasted = "- keep this line in the code block\n- and this one"; + const event = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { + configurable: true, + value: { + types: ["text/plain"], + getData: (type: string) => (type === "text/plain" ? pasted : ""), + }, + }); + const innerPaste = vi.fn(); + code.addEventListener("paste", innerPaste); + + await act(async () => { + code.dispatchEvent(event); + }); + + expect(event.defaultPrevented).toBe(false); + expect(mdxEditorMockState.insertedMarkdownValues).toEqual([]); + expect(innerPaste).toHaveBeenCalledOnce(); + + await act(async () => { + root.unmount(); + }); + }); + it("escapes angle brackets in pasted markdown", async () => { const root = createRoot(container); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index f856063c2af..80d13c6b5a3 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -510,12 +510,22 @@ function getMentionMenuSize(optionCount: number): MentionMenuSize { }; } +// CodeMirror's editable surface is `.cm-content`, not a `pre` or `code` node. +// MDXEditor wraps that surface in a hashed `codeMirrorWrapper` class. A paste +// that starts inside either of those belongs to the nested editor. +const CODE_LIKE_SELECTOR = [ + "pre", + "code", + ".cm-editor", + ".cm-content", + "[class*='codeMirrorWrapper']", + "[class*='codeBlockEditor']", +].join(","); + function nodeInsideCodeLike(container: HTMLElement, node: Node | null): boolean { if (!node || !container.contains(node)) return false; - const el = node.nodeType === Node.ELEMENT_NODE - ? (node as HTMLElement) - : node.parentElement; - return Boolean(el?.closest("pre, code")); + const el = node instanceof Element ? node : node.parentElement; + return Boolean(el?.closest(CODE_LIKE_SELECTOR)); } function isSelectionInsideCodeLikeElement(container: HTMLElement | null) { @@ -528,6 +538,17 @@ function isSelectionInsideCodeLikeElement(container: HTMLElement | null) { return false; } +function isPasteInsideCodeLikeElement( + container: HTMLElement | null, + event: { target: EventTarget | null }, +): boolean { + if (!container) return false; + if (event.target instanceof Node && nodeInsideCodeLike(container, event.target)) return true; + const active = typeof document !== "undefined" ? document.activeElement : null; + if (active instanceof Node && nodeInsideCodeLike(container, active)) return true; + return isSelectionInsideCodeLikeElement(container); +} + /** The human title of an issue mention — `name` minus its leading identifier. */ export function issueMentionTitle(option: MentionOption): string { const name = option.name.trim(); @@ -1280,7 +1301,7 @@ export const MarkdownEditor = forwardRef if (!clipboard || !ref.current) return; const types = new Set(Array.from(clipboard.types)); if (types.has("Files") || types.has("text/html")) return; - if (isSelectionInsideCodeLikeElement(containerRef.current)) return; + if (isPasteInsideCodeLikeElement(containerRef.current, event)) return; const rawText = clipboard.getData("text/plain"); if (!looksLikeMarkdownPaste(rawText)) return; diff --git a/ui/src/components/RoutineSectionHeading.test.tsx b/ui/src/components/RoutineSectionHeading.test.tsx new file mode 100644 index 00000000000..210edbdae1b --- /dev/null +++ b/ui/src/components/RoutineSectionHeading.test.tsx @@ -0,0 +1,73 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { RoutineSectionHeading } from "./RoutineSectionHeading"; + +const copyTextToClipboard = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock("@/lib/clipboard", () => ({ + copyTextToClipboard, +})); + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + +describe("RoutineSectionHeading", () => { + let container: HTMLDivElement; + + afterEach(() => { + container?.remove(); + copyTextToClipboard.mockClear(); + }); + + it("copies the description markdown from the button next to the heading", async () => { + container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const description = "```text\n- keep this line\n```"; + + await act(async () => { + root.render(); + }); + + const button = container.querySelector('button[aria-label="Copy description"]'); + expect(button).not.toBeNull(); + expect(container.querySelector("#routine-section-title")?.textContent).toBe("Overview"); + + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(copyTextToClipboard).toHaveBeenCalledWith(description); + + await act(async () => { + root.unmount(); + }); + }); + + it("omits the copy button on sections that are not the overview", async () => { + container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render(); + }); + + expect(container.querySelector('button[aria-label="Copy description"]')).toBeNull(); + expect(container.querySelector("#routine-section-title")?.textContent).toBe("Triggers"); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/components/RoutineSectionHeading.tsx b/ui/src/components/RoutineSectionHeading.tsx new file mode 100644 index 00000000000..053012feba8 --- /dev/null +++ b/ui/src/components/RoutineSectionHeading.tsx @@ -0,0 +1,30 @@ +import { Copy } from "lucide-react"; +import { CopyText } from "./CopyText"; + +export function RoutineSectionHeading({ + title, + copyText = null, +}: { + title: string; + /** Description markdown to copy. Omit the button when this is null. */ + copyText?: string | null; +}) { + return ( +
+

+ {title} +

+ {copyText != null ? ( + + + ) : null} +
+ ); +} diff --git a/ui/src/lib/routine-description-copy.test.ts b/ui/src/lib/routine-description-copy.test.ts new file mode 100644 index 00000000000..33f56dd58e3 --- /dev/null +++ b/ui/src/lib/routine-description-copy.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { routineOverviewCopyText } from "./routine-description-copy"; + +describe("routineOverviewCopyText", () => { + it("copies the draft while the description is being edited", () => { + expect(routineOverviewCopyText({ + editing: true, + draft: "- draft line", + saved: "- saved line", + })).toBe("- draft line"); + }); + + it("copies the saved description when the overview is not being edited", () => { + expect(routineOverviewCopyText({ + editing: false, + draft: "- draft line", + saved: "- saved line", + })).toBe("- saved line"); + }); + + it("copies an empty string when there is no saved description", () => { + expect(routineOverviewCopyText({ + editing: false, + draft: "", + saved: null, + })).toBe(""); + }); +}); diff --git a/ui/src/lib/routine-description-copy.ts b/ui/src/lib/routine-description-copy.ts new file mode 100644 index 00000000000..3ab51150d27 --- /dev/null +++ b/ui/src/lib/routine-description-copy.ts @@ -0,0 +1,12 @@ +/** + * Markdown copied from a routine Overview heading. + * While the description editor is open, copy the draft. Otherwise copy the + * saved description so it can be pasted into a routine on another instance. + */ +export function routineOverviewCopyText(input: { + editing: boolean; + draft: string; + saved: string | null | undefined; +}): string { + return input.editing ? input.draft : input.saved ?? ""; +} diff --git a/ui/src/pages/RoutineDetail.production.tsx b/ui/src/pages/RoutineDetail.production.tsx index 992f226d240..a3ef2185955 100644 --- a/ui/src/pages/RoutineDetail.production.tsx +++ b/ui/src/pages/RoutineDetail.production.tsx @@ -20,6 +20,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToastActions } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; import { copyTextToClipboard } from "../lib/clipboard"; +import { routineOverviewCopyText } from "../lib/routine-description-copy"; import { buildMarkdownMentionOptions } from "../lib/company-members"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { EmptyState } from "../components/EmptyState"; @@ -39,6 +40,7 @@ import { RoutineSectionPicker, } from "../components/RoutineSubSidebar"; import { RoutineSaveBar } from "../components/RoutineSaveBar"; +import { RoutineSectionHeading } from "../components/RoutineSectionHeading"; import { EDITABLE_SECTIONS, ROUTINE_SECTION_KEYS, @@ -879,9 +881,16 @@ export function RoutineDetail() { aria-labelledby="routine-section-title" className={isEditableSection ? "mx-auto w-full max-w-3xl" : "w-full"} > -

- {SECTION_TITLES[section]} -

+ {section === "overview" && } {section === "triggers" && } diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx index 4bc3573325f..e12a01787e3 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -21,6 +21,7 @@ import { useToastActions } from "../context/ToastContext"; import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled"; import { queryKeys } from "../lib/queryKeys"; import { copyTextToClipboard } from "../lib/clipboard"; +import { routineOverviewCopyText } from "../lib/routine-description-copy"; import { buildMarkdownMentionOptions } from "../lib/company-members"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { EmptyState } from "../components/EmptyState"; @@ -37,6 +38,7 @@ import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects" import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { RoutineOverview } from "../components/RoutineOverview"; +import { RoutineSectionHeading } from "../components/RoutineSectionHeading"; import { RoutineSectionPicker, RoutineSubSidebar } from "../components/RoutineSubSidebar"; import { isRoutineDetailView, @@ -899,9 +901,16 @@ export function RoutineDetail() { : "w-full" } > -

- {SECTION_TITLES[section]} -

+ {section === "overview" && (overviewEditing ? : )} {section === "triggers" && } From 30c8aa1cc1c67900fc3fde354ed743ea437ba9d4 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sat, 10 Oct 2026 01:13:35 -0700 Subject: [PATCH 3/6] fix(ui): remove workspace from task and inbox filters (#15788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updated 14 checked lines erclip is the open source app people use to manage AI agents for work > - The board shows tasks in a list and in the inbox > - Each of those screens has a filter menu > - The filter menu includes a Workspace section > - The list already shows the workspace name, and the user can group by workspace > - This pull request removes Workspace from those filter menus > - The benefit is a shorter filter menu. The workspace label and the group option stay ## Linked Issues or Issue Description No public GitHub issue exists for this change. This description follows the enhancement template. Related pull request: Refs #15403. That pull request also edits the task list. It does not remove the Workspace filter. **What existing behavior does this improve?** The filter menu on the task list and the inbox. **Subsystem affected** ui/ — React + Vite board UI **Current behavior** The filter menu shows a Workspace section when isolated workspaces are on. The user can select one or more workspaces there. The task list also shows the workspace name as a label. The user can group the list by workspace. A workspace value in the page address also limits the list. **Proposed behavior** The filter menu does not show Workspace. The workspace name stays in the list. Group by workspace stays. A workspace value in the page address still limits the task list. A click on a workspace name in the list still limits the list to that workspace. **Reason and benefit** The filter menu repeats a choice that the list and the group control already give. Removal of that section makes the filter menu shorter. Saved links that include a workspace value still open the same limited list. **Breaking changes** The filter menu no longer lets the user pick a workspace. A saved workspace query still works. Decision filters are not part of this change. ## What Changed - Remove the Workspace section from the task list filter menu - Remove the Workspace section from the inbox filter menu - Keep workspace labels, group by workspace, and workspace query scope - Update the filter menu test so it checks that Workspace is absent - Drop a saved workspace selection when preferences load, and keep the other saved filters ## Verification - Run `corepack pnpm exec vitest run --config vitest.config.ts src/components/IssueFiltersPopover.test.tsx src/components/IssuesList.test.tsx src/pages/Inbox.test.tsx src/lib/issue-filters.test.ts src/components/IssueColumns.test.tsx` from `ui/` - Result: 5 files passed, 111 tests passed - Open the task list filter menu. Confirm that Workspace is not a section - Open the inbox filter menu. Confirm that Workspace is not a section - Open a task list with a workspace query. Confirm that the list shows only tasks for that workspace - Group the task list by workspace. Confirm that the groups still appear - A browser check of the live screens was not run in this workspace ## Risks - Low risk. The change removes a menu section. It does not change filter matching - A user who used the menu to pick a workspace must use the workspace label or the page address - Pull request #15403 also edits the task list. Merge order can cause a small conflict in `ui/src/components/IssuesList.tsx` ROADMAP.md does not plan this filter removal. This change does not add a core feature. ## Model Used - Provider: xAI - Model: Grok 4.7 - Capabilities: tool use, code edits, and test execution ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Paperclip --- .../components/IssueFiltersPopover.test.tsx | 4 +- ui/src/components/IssueFiltersPopover.tsx | 35 +------------ ui/src/components/IssuesList.test.tsx | 50 +++++++++++++++++++ ui/src/components/IssuesList.tsx | 14 ++---- ui/src/components/LegacyIssuesList.tsx | 12 +---- ui/src/lib/inbox.test.ts | 4 +- ui/src/lib/inbox.ts | 10 +++- ui/src/pages/Inbox.tsx | 10 ++-- ui/src/pages/LegacyInbox.tsx | 2 - .../stories/external-objects.stories.tsx | 2 - .../stories/issue-management.stories.tsx | 1 - 11 files changed, 74 insertions(+), 70 deletions(-) diff --git a/ui/src/components/IssueFiltersPopover.test.tsx b/ui/src/components/IssueFiltersPopover.test.tsx index 69eb612fee5..796735a8eb6 100644 --- a/ui/src/components/IssueFiltersPopover.test.tsx +++ b/ui/src/components/IssueFiltersPopover.test.tsx @@ -65,7 +65,6 @@ describe("IssueFiltersPopover", () => { agents={[{ id: "agent-1", name: "Agent One" }]} projects={[{ id: "project-1", name: "Project One" }]} labels={[{ id: "label-1", name: "Bug", color: "#ff0000" }]} - workspaces={[{ id: "workspace-1", name: "Workspace One" }]} enableRoutineVisibilityFilter />, ); @@ -73,6 +72,8 @@ describe("IssueFiltersPopover", () => { const popoverContent = container.querySelector("[data-testid='popover-content']"); expect(popoverContent).not.toBeNull(); + expect(popoverContent?.querySelector('[data-filter-options="workspaces"]')).toBeNull(); + expect(popoverContent?.textContent).not.toContain("Workspace"); expect(popoverContent?.className).toContain("overflow-y-auto"); expect(popoverContent?.className).toContain("max-h-(--sz-calc-9)"); expect(popoverContent?.querySelectorAll(".overflow-y-auto").length).toBe(0); @@ -97,7 +98,6 @@ describe("IssueFiltersPopover", () => { agents={[{ id: "agent-1", name: "Agent One" }]} projects={[{ id: "project-1", name: "Project One" }]} labels={[{ id: "label-1", name: "Bug", color: "#ff0000" }]} - workspaces={[{ id: "workspace-1", name: "Workspace One" }]} enableRoutineVisibilityFilter />, ); diff --git a/ui/src/components/IssueFiltersPopover.tsx b/ui/src/components/IssueFiltersPopover.tsx index 05f717493e2..5546054e1f9 100644 --- a/ui/src/components/IssueFiltersPopover.tsx +++ b/ui/src/components/IssueFiltersPopover.tsx @@ -4,7 +4,7 @@ import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Bot, Check, Filter, HardDrive, Search, User, X } from "lucide-react"; +import { Bot, Check, Filter, Search, User, X } from "lucide-react"; import { PriorityIcon } from "./PriorityIcon"; import { SHOW_TASK_PRIORITY_UI } from "../lib/ui-flags"; import { StatusIcon } from "./StatusIcon"; @@ -43,11 +43,6 @@ type LabelOption = { color: string; }; -type WorkspaceOption = { - id: string; - name: string; -}; - type CreatorOption = { id: string; label: string; @@ -116,7 +111,6 @@ export function IssueFiltersPopover({ enableRoutineVisibilityFilter = false, buttonVariant = "ghost", iconOnly = false, - workspaces, creators, presentation = "legacy", inboxScopeFilters, @@ -132,7 +126,6 @@ export function IssueFiltersPopover({ enableRoutineVisibilityFilter?: boolean; buttonVariant?: "ghost" | "outline"; iconOnly?: boolean; - workspaces?: WorkspaceOption[]; creators?: CreatorOption[]; presentation?: "legacy" | "streamlined"; inboxScopeFilters?: InboxScopeFilters; @@ -142,7 +135,6 @@ export function IssueFiltersPopover({ const [assigneeSearch, setAssigneeSearch] = useState(""); const [projectSearch, setProjectSearch] = useState(""); const [labelSearch, setLabelSearch] = useState(""); - const [workspaceSearch, setWorkspaceSearch] = useState(""); const visibleAgents = useMemo( () => searchIssueFilterOptions(agents, assigneeSearch, (option) => option.name), [agents, assigneeSearch], @@ -155,10 +147,6 @@ export function IssueFiltersPopover({ () => searchIssueFilterOptions(labels, labelSearch, (option) => option.name), [labels, labelSearch], ); - const visibleWorkspaces = useMemo( - () => searchIssueFilterOptions(workspaces, workspaceSearch, (option) => option.name), - [workspaces, workspaceSearch], - ); const creatorOptions = creators ?? []; const creatorOptionById = useMemo( () => new Map(creatorOptions.map((option) => [option.id, option])), @@ -490,27 +478,6 @@ export function IssueFiltersPopover({ ) : null} - {workspaces && workspaces.length > 0 ? ( -
- Workspace - {streamlined && workspaces.length > SEARCHABLE_FILTER_THRESHOLD ? ( - - ) : null} -
- {(streamlined ? visibleWorkspaces : workspaces).map((workspace) => ( - - ))} -
-
- ) : null} - {enableExternalObjectFilters ? (
External object status diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index 72cdcf23ecd..f50a3332c04 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -1928,6 +1928,56 @@ describe("IssuesList", () => { }); }); + it("ignores a saved workspace filter when the page address has no workspace query", async () => { + localStorage.setItem( + taskCollectionPreferencesStorageKey({ + companyId: "company-1", + collectionKey: "paperclip:test-issues", + }), + JSON.stringify({ + version: 1, + companyId: "company-1", + collectionKey: "paperclip:test-issues", + viewState: { + workspaces: ["workspace-alpha"], + }, + columns: ["id"], + }), + ); + const alphaIssue = createIssue({ + id: "issue-alpha", + identifier: "PAP-40", + title: "Alpha issue", + executionWorkspaceId: "workspace-alpha", + }); + const betaIssue = createIssue({ + id: "issue-beta", + identifier: "PAP-41", + title: "Beta issue", + executionWorkspaceId: "workspace-beta", + }); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Alpha issue"); + expect(container.textContent).toContain("Beta issue"); + }); + + act(() => { + root.unmount(); + }); + }); + it("applies an initial workspace filter from the issues URL state", async () => { const alphaIssue = createIssue({ id: "issue-alpha", diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index da467d40fda..4603c567b9f 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -207,6 +207,9 @@ function normalizeIssueViewState(value: unknown): IssueViewState { ...defaultViewState, ...parsed, ...normalizeIssueFilterState(parsed), + // A saved workspace selection has no filter control. Drop it on load. + // `initialWorkspaces` from the page address is applied after this. + workspaces: [], sortField: ["status", "priority", "title", "created", "updated", "workflow"].includes(parsed.sortField ?? "") ? parsed.sortField as IssueSortField : defaultViewState.sortField, @@ -1043,16 +1046,6 @@ function StreamlinedIssuesList({ return map; }, [defaultProjectWorkspaceIds, executionWorkspaceById, projectWorkspaceById]); - const workspaceOptions = useMemo(() => { - const options = new Map(); - for (const [workspaceId, workspaceName] of workspaceNameMap) { - options.set(workspaceId, workspaceName); - } - return [...options.entries()] - .sort((a, b) => a[1].localeCompare(b[1])) - .map(([id, name]) => ({ id, name })); - }, [workspaceNameMap]); - const creatorOptions = useMemo(() => { const options = new Map(); const knownAgentIds = new Set(); @@ -1920,7 +1913,6 @@ function StreamlinedIssuesList({ enableExternalObjectFilters={externalObjectsEnabled} enableRoutineVisibilityFilter={enableRoutineVisibilityFilter} iconOnly - workspaces={isolatedWorkspacesEnabled ? workspaceOptions : undefined} presentation={rowPresentation === "task" ? "streamlined" : "legacy"} /> diff --git a/ui/src/components/LegacyIssuesList.tsx b/ui/src/components/LegacyIssuesList.tsx index 55cfbb48f6d..5cd5db5358a 100644 --- a/ui/src/components/LegacyIssuesList.tsx +++ b/ui/src/components/LegacyIssuesList.tsx @@ -198,6 +198,7 @@ function getViewState(key: string): IssueViewState { ...defaultViewState, ...parsed, ...normalizeIssueFilterState(parsed), + workspaces: [], boardCardDensity: normalizeBoardCardDensity(parsed.boardCardDensity), boardColdLaneMode: normalizeBoardColdLaneMode(parsed.boardColdLaneMode), boardColumnPageSize: normalizeBoardColumnPageSize(parsed.boardColumnPageSize), @@ -965,16 +966,6 @@ export function IssuesList({ return map; }, [defaultProjectWorkspaceIds, executionWorkspaceById, projectWorkspaceById]); - const workspaceOptions = useMemo(() => { - const options = new Map(); - for (const [workspaceId, workspaceName] of workspaceNameMap) { - options.set(workspaceId, workspaceName); - } - return [...options.entries()] - .sort((a, b) => a[1].localeCompare(b[1])) - .map(([id, name]) => ({ id, name })); - }, [workspaceNameMap]); - const creatorOptions = useMemo(() => { const options = new Map(); const knownAgentIds = new Set(); @@ -1814,7 +1805,6 @@ export function IssuesList({ enableExternalObjectFilters={externalObjectsEnabled} enableRoutineVisibilityFilter={enableRoutineVisibilityFilter} iconOnly - workspaces={isolatedWorkspacesEnabled ? workspaceOptions : undefined} /> {/* Sort (list view only) */} diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 36b3baf8067..79d0afbf397 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -1159,7 +1159,7 @@ describe("inbox helpers", () => { creators: ["user:user-1"], labels: ["label-1"], projects: ["project-1"], - workspaces: ["workspace-1"], + workspaces: [], liveOnly: true, externalObjectStatuses: [], hideRoutineExecutions: false, @@ -1210,7 +1210,7 @@ describe("inbox helpers", () => { creators: ["user:user-1"], labels: [], projects: ["project-1"], - workspaces: ["workspace-1"], + workspaces: [], liveOnly: false, externalObjectStatuses: [], hideRoutineExecutions: false, diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index c95b015352c..368cc213a48 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -217,7 +217,10 @@ export function loadInboxFilterPreferences( return { allCategoryFilter: normalizeInboxCategoryFilter(parsed.allCategoryFilter), allApprovalFilter: normalizeInboxApprovalFilter(parsed.allApprovalFilter), - issueFilters: normalizeIssueFilterState(parsed.issueFilters), + issueFilters: { + ...normalizeIssueFilterState(parsed.issueFilters), + workspaces: [], + }, }; } catch { return { @@ -240,7 +243,10 @@ export function saveInboxFilterPreferences( JSON.stringify({ allCategoryFilter: normalizeInboxCategoryFilter(preferences.allCategoryFilter), allApprovalFilter: normalizeInboxApprovalFilter(preferences.allApprovalFilter), - issueFilters: normalizeIssueFilterState(preferences.issueFilters), + issueFilters: { + ...normalizeIssueFilterState(preferences.issueFilters), + workspaces: [], + }, }), ); } catch { diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 37944e25d83..88ae042b349 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -221,7 +221,13 @@ function normalizeInboxCollectionViewState(value: unknown): StreamlinedInboxView ? category : "everything", allApprovalFilter: approval === "actionable" || approval === "resolved" ? approval : "all", - issueFilters: normalizeIssueFilterState(candidate.issueFilters), + issueFilters: { + ...normalizeIssueFilterState(candidate.issueFilters), + // The filter menu no longer offers Workspace. Drop a saved selection so + // it cannot keep hiding tasks. A task-link workspace query is applied + // by the task list after this load. + workspaces: [], + }, showDateGroupSeparators: candidate.showDateGroupSeparators !== false, }; } @@ -2482,7 +2488,6 @@ function StreamlinedInbox({ enableRoutineVisibilityFilter buttonVariant="outline" iconOnly - workspaces={isolatedWorkspacesEnabled ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace").map((w) => ({ id: w.id, name: w.name })) : undefined} presentation={streamlinedUiEnabled ? "streamlined" : "legacy"} /> @@ -2588,7 +2593,6 @@ function StreamlinedInbox({ enableRoutineVisibilityFilter buttonVariant="outline" iconOnly - workspaces={isolatedWorkspacesEnabled ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace").map((w) => ({ id: w.id, name: w.name })) : undefined} presentation={streamlinedUiEnabled ? "streamlined" : "legacy"} inboxScopeFilters={tab === "all" ? { category: allCategoryFilter, diff --git a/ui/src/pages/LegacyInbox.tsx b/ui/src/pages/LegacyInbox.tsx index 35196635373..21d1a885e15 100644 --- a/ui/src/pages/LegacyInbox.tsx +++ b/ui/src/pages/LegacyInbox.tsx @@ -2353,7 +2353,6 @@ export function Inbox() { enableRoutineVisibilityFilter buttonVariant="outline" iconOnly - workspaces={isolatedWorkspacesEnabled ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace").map((w) => ({ id: w.id, name: w.name })) : undefined} /> @@ -2452,7 +2451,6 @@ export function Inbox() { enableRoutineVisibilityFilter buttonVariant="outline" iconOnly - workspaces={isolatedWorkspacesEnabled ? executionWorkspaces.filter((w) => w.mode === "isolated_workspace").map((w) => ({ id: w.id, name: w.name })) : undefined} /> diff --git a/ui/storybook/stories/external-objects.stories.tsx b/ui/storybook/stories/external-objects.stories.tsx index 00108d96e13..e0e91816dae 100644 --- a/ui/storybook/stories/external-objects.stories.tsx +++ b/ui/storybook/stories/external-objects.stories.tsx @@ -32,7 +32,6 @@ import { import type { IssueExternalObjectGroup } from "@/hooks/useIssueExternalObjects"; import { storybookAgents, - storybookExecutionWorkspaces, storybookIssueLabels, storybookIssues, storybookProjects, @@ -650,7 +649,6 @@ function FilterPopoverWithExternalChecked() { currentUserId="user-board" enableRoutineVisibilityFilter buttonVariant="outline" - workspaces={storybookExecutionWorkspaces.map((workspace) => ({ id: workspace.id, name: workspace.name }))} creators={[ { id: "user:user-board", label: "Riley Board", kind: "user", searchText: "board user human" }, ]} diff --git a/ui/storybook/stories/issue-management.stories.tsx b/ui/storybook/stories/issue-management.stories.tsx index 1939c5b3bfd..e1774d82d92 100644 --- a/ui/storybook/stories/issue-management.stories.tsx +++ b/ui/storybook/stories/issue-management.stories.tsx @@ -596,7 +596,6 @@ function OpenFiltersPopover() { currentUserId="user-board" enableRoutineVisibilityFilter buttonVariant="outline" - workspaces={storybookExecutionWorkspaces.map((workspace) => ({ id: workspace.id, name: workspace.name }))} creators={[ { id: "user:user-board", label: "Riley Board", kind: "user", searchText: "board user human" }, ...storybookAgents.map((agent) => ({ From caa7895f9a0c2328e4d7d83d4961af534b474315 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sat, 10 Oct 2026 01:18:17 -0700 Subject: [PATCH 4/6] fix(opencode): report timeouts without claiming deadline expiry (#15789) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The OpenCode adapter reports the outcome of a local or remote execution. > - A provider can report a timeout before the configured execution deadline. > - The adapter treated every timeout as proof that the configured deadline expired. > - This pull request uses a generic timeout message and retains the resolved policy. > - Operators can compare the run duration with its actual configured limit without a false cause claim. ## Linked Issues or Issue Description **What happened?** A remote execution can return `timedOut: true` after a short interval while the sandbox policy is four hours. OpenCode reported `Timed out after 14400s`. It also dropped the resolved policy on that result. Run finalization then used the raw `timeoutSec: 0` configuration, so diagnostics incorrectly reported an unlimited timeout. **Expected behavior** Keep the timeout outcome. Report the resolved policy separately from the failure message. Do not infer which timer fired from the provider's boolean or stderr. **Steps to reproduce** Call the OpenCode adapter with a sandbox execution target and `timeoutSec: 0`. Return a timed-out process result before four hours. Inspect the message and the policy that finalization sends to the run-failure collector. The new tests reproduce this with local fixtures and an in-memory Sentry transport. **Paperclip version or commit** Reproduced on `de042e79efbb7bb88e9716835407882e3ebab188`. **Deployment mode** Legacy OpenCode execution on a local instance or remote sandbox. Refs #14832 for related timeout result preservation; this PR does not change session state. Related PRs #14964, #15659 and #15715 cover silent-run limits, gateway requests and OpenCode v2 support. They do not fix this policy/message path. ## What Changed - Retain the complete existing timeout resolution on both OpenCode result branches. - Report `OpenCode execution timed out` without copying provider output into exception text. - Document that configured policy and the existing `timeoutFired` outcome field do not prove timer origin. - Test policy overrides, sandbox defaults, opt-out, ordinary exits, real local timer expiry, cleanup and real Sentry SDK delivery. ## Verification - 142 focused adapter, stop-metadata and diagnostic tests passed on Node 25.6.1. - The real Sentry SDK contract suite passed 11/11 with the audited optional peer 10.71.0 and an in-memory transport. The new test runs the actual adapter, finalization policy merge and collector. It verifies that raw HTML and credential canaries stay out of the event and unrelated captures stay isolated. - Full workspace `pnpm -r typecheck` and `pnpm build` passed. All hosted CI checks passed on `f4df5cba1d2223d444559de960bbf88dfa985bbf` (54 passed, 2 expected skips). - The local aggregate is not a pass: 15 existing PostgreSQL fixture cases initially failed because this fresh macOS install lacked bundled ICU symlinks. The dependency's existing hydration script repaired the isolated install; all 15 affected cases then passed with real PostgreSQL. No test or production source was changed for that setup issue. The aggregate was stopped after hosted test coverage passed; it is incomplete, not a local full-suite pass. All owned test processes exited. - Greptile scored this exact head 5/5 with no review threads. Independent review found no issues. It ran 58 positive cases and adapter typechecking. Three negative controls failed as expected when it restored the old producer, removed timeout policy, or copied raw stderr into the message. ## Risks The timeout message changes for local and remote OpenCode runs. The resolved policy is additive result data. The numeric timeout, grace period, process result, retry rules, session handling, cleanup and recovery behavior are unchanged. This fixes reporting and does not identify or fix an upstream provider timeout. No schema change or migration is required. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools and independent agent review. The exact service model identifier and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- doc/observability.md | 8 +- .../src/server/execute.timeout.test.ts | 97 +++++++++++++++++++ .../opencode-local/src/server/execute.ts | 12 ++- .../run-failure-sentry-real-sdk.test.ts | 84 ++++++++++++++++ 4 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 packages/adapters/opencode-local/src/server/execute.timeout.test.ts diff --git a/doc/observability.md b/doc/observability.md index 021c26ec87c..7d17a9f564c 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -628,7 +628,7 @@ pending. Recent events do not prove useful progress. These fields contain only numbers and a boolean, never tool names, IDs, arguments, or event content, and do not change the execution timeout, cancellation, or recovery policy. -ACP results retain the adapter's resolved wall-clock timeout as +ACP and legacy OpenCode results retain the adapter's resolved wall-clock timeout as `adapterExecutionTimeout` in the instance run result. Finalization uses it for `effectiveTimeoutSec`, `timeoutSource`, and `timeoutConfigured`. Sources are `configured`, `sandbox_default`, or `unlimited`; `timeoutConfigured` identifies an @@ -636,6 +636,12 @@ explicit override, including a negative value that disables the timer. An untouc sandbox value of zero reports the four-hour default, while a local zero reports unlimited. Older adapters without a valid resolution retain the config-based metadata fallback. This does not change timers, Stop acknowledgement, or recovery. +Legacy OpenCode reports a generic timeout message because a provider or output +observation timeout can occur before the configured execution deadline. The +resolved policy describes the configured limit, not proof that its timer fired; +`timeoutFired` retains the existing terminal timeout classification. Provider +stderr remains in the instance run log and is not used as the timeout exception +message. An earlier provider timeout does not establish its upstream cause. When settlement records a workspace restore failure, `run_execution` also includes `workspaceRestoreFailure` with one of the shared, path-free codes: diff --git a/packages/adapters/opencode-local/src/server/execute.timeout.test.ts b/packages/adapters/opencode-local/src/server/execute.timeout.test.ts new file mode 100644 index 00000000000..748e88d5c11 --- /dev/null +++ b/packages/adapters/opencode-local/src/server/execute.timeout.test.ts @@ -0,0 +1,97 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; +import type { RunProcessResult } from "@paperclipai/adapter-utils/server-utils"; + +const mocks = vi.hoisted(() => ({ + process: vi.fn(), restore: vi.fn(), stop: vi.fn(), +})); +vi.mock("@paperclipai/adapter-utils/execution-target", async (importOriginal) => ({ + ...await importOriginal(), + ensureAdapterExecutionTargetRuntimeCommandInstalled: vi.fn(), + ensureAdapterExecutionTargetCommandResolvable: vi.fn(), + prepareAdapterExecutionTargetRuntime: vi.fn(async () => ({ + runtimeRootDir: "/workspace/runtime", workspaceRemoteDir: "/workspace", + assetDirs: {}, restoreWorkspace: mocks.restore, + })), + readAdapterExecutionTargetHomeDir: vi.fn(async () => "/home/fixture"), + startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({ env: {}, stop: mocks.stop })), + runAdapterExecutionTargetProcess: mocks.process, +})); +import { execute } from "./execute.js"; + +const rawOutput = "private-output-canary"; +const rawError = "504 Gateway Time-out private-provider-token-canary"; +const failed: RunProcessResult = { + timedOut: true, exitCode: null, signal: null, stdout: rawOutput, stderr: rawError, + pid: null, startedAt: "2020-01-01T00:00:00.000Z", durationMs: 100, +}; + +describe("OpenCode timeout reporting", () => { + let dir: string; + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-timeout-")); + vi.stubEnv("XDG_CONFIG_HOME", dir); + mocks.process.mockReset().mockResolvedValue(failed); + mocks.restore.mockReset().mockResolvedValue(undefined); + mocks.stop.mockReset().mockResolvedValue(undefined); + }); + afterEach(async () => { + vi.unstubAllEnvs(); + await fs.rm(dir, { recursive: true, force: true }); + }); + const context = (timeoutSec: number, remote = true): AdapterExecutionContext => ({ + runId: "run-fixture", + agent: { id: "agent-fixture", companyId: "company-fixture", name: "Fixture", adapterType: "opencode_local", adapterConfig: {} }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { command: "opencode", model: "openai/fixture", cwd: dir, timeoutSec, + env: { OPENCODE_ALLOW_ALL_MODELS: "1" } }, + context: {}, onLog: async () => {}, + ...(remote ? { executionTarget: { kind: "remote", transport: "sandbox", remoteCwd: "/workspace" } } : {}), + }); + + it.each([ + [0, true, 14400, "sandbox_default"], [12, true, 12, "configured"], + [-1, true, 0, "configured"], [0, false, 0, "unlimited"], [12, false, 12, "configured"], + ] as const)("keeps policy separate from an opaque timeout (%s, remote=%s)", async (configured, remote, resolved, source) => { + const result = await execute(context(configured, remote)); + expect(result).toMatchObject({ timedOut: true, exitCode: null, signal: null, + errorMessage: "OpenCode execution timed out", + resultJson: { adapterExecutionTimeout: { timeoutSec: resolved, source } } }); + expect(JSON.stringify(result)).not.toContain("private-"); + expect(result.errorMessage).not.toContain(String(resolved)); + expect(mocks.process).toHaveBeenCalledTimes(1); + expect(mocks.process.mock.calls[0]![4]).toMatchObject({ timeoutSec: resolved, graceSec: 20 }); + expect(mocks.restore).toHaveBeenCalledTimes(remote ? 1 : 0); + expect(mocks.stop).toHaveBeenCalledTimes(remote ? 1 : 0); + }); + + it.each([0, 7])("retains output and completion semantics for a non-timeout exit %s", async (exitCode) => { + mocks.process.mockResolvedValue({ ...failed, timedOut: false, exitCode, stderr: "fixture failure" }); + const result = await execute(context(0)); + expect(result).toMatchObject({ timedOut: false, exitCode, signal: null, + errorMessage: exitCode === 0 ? null : "fixture failure", + resultJson: { stdout: rawOutput, stderr: "fixture failure", + adapterExecutionTimeout: { timeoutSec: 14400, source: "sandbox_default" } } }); + expect(mocks.process).toHaveBeenCalledTimes(1); + expect(mocks.restore).toHaveBeenCalledTimes(1); + expect(mocks.stop).toHaveBeenCalledTimes(1); + }); + + it("retains real local deadline and termination behavior without claiming provider origin", async () => { + const actual = await vi.importActual("@paperclipai/adapter-utils/execution-target"); + mocks.process.mockImplementation(actual.runAdapterExecutionTargetProcess); + const command = path.join(dir, "fixture-opencode"); + await fs.writeFile(command, "#!/usr/bin/env node\nsetInterval(() => {}, 1000);\n", { mode: 0o755 }); + const ctx = context(0.1, false); + ctx.config.command = command; + ctx.config.graceSec = 0.1; + const result = await execute(ctx); + expect(result).toMatchObject({ timedOut: true, exitCode: null, signal: "SIGTERM", + errorMessage: "OpenCode execution timed out", + resultJson: { adapterExecutionTimeout: { timeoutSec: 0.1, source: "configured" } } }); + expect(mocks.process).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index abd105704ec..a7458fd3d97 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -21,7 +21,7 @@ import { adapterExecutionTargetEnablesSandboxDuplexBridge, readAdapterExecutionTarget, readAdapterExecutionTargetHomeDir, - resolveAdapterExecutionTargetTimeoutSec, + resolveAdapterExecutionTargetTimeout, resolveAdapterExecutionTargetCommandForLogs, runAdapterExecutionTargetProcess, runAdapterExecutionTargetShellCommand, @@ -338,10 +338,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", ), ); - const timeoutSec = resolveAdapterExecutionTargetTimeoutSec( + const adapterExecutionTimeout = resolveAdapterExecutionTargetTimeout( executionTarget, asNumber(config.timeoutSec, 0), ); + const timeoutSec = adapterExecutionTimeout.timeoutSec; const graceSec = asNumber(config.graceSec, 20); await ensureAdapterExecutionTargetRuntimeCommandInstalled({ runId, @@ -710,7 +711,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise { vi.unstubAllEnvs(); vi.doUnmock("@sentry/node"); vi.doUnmock("../peer-version-check.js"); + vi.doUnmock("@paperclipai/adapter-utils/execution-target"); vi.resetModules(); }); describe.skipIf(!sentryPackage)("run failure context with the real Sentry SDK", () => { + it("captures an early OpenCode timeout without inventing a deadline or exporting provider output", async () => { + const Sentry = sentryPackage!; + const events: Array> = []; + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-sentry-")); + const restore = vi.fn(async () => {}); + const stop = vi.fn(async () => {}); + const processResult = { + exitCode: null, signal: null, timedOut: true, pid: null, + startedAt: "2020-01-01T00:00:00.000Z", durationMs: 100, + stdout: "private-timeout-output-canary", + stderr: "504 Gateway Time-out private-timeout-credential-canary", + }; + const runProcess = vi.fn(async () => processResult); + vi.stubEnv("XDG_CONFIG_HOME", root); + vi.stubEnv("SENTRY_DSN_BACKEND", "https://public@example.invalid/1"); + vi.doMock("../peer-version-check.js", () => ({ checkExactPeerVersions: () => ({ ok: true }) })); + vi.doMock("@sentry/node", () => ({ ...Sentry, + init: (options: Record) => Sentry.init({ ...options, + transport: () => ({ send: async (envelope: [unknown, Array<[{ type: string }, Record]>]) => { + for (const [header, payload] of envelope[1]) if (header.type === "event") events.push(payload); + return {}; + }, flush: async () => true }), + }), + })); + // Only external execution/setup seams are replaced. The real adapter owns + // the message/policy, and the real finalization/collector/SDK send the event. + vi.doMock("@paperclipai/adapter-utils/execution-target", async (importOriginal) => ({ + ...await importOriginal(), + ensureAdapterExecutionTargetRuntimeCommandInstalled: vi.fn(), + ensureAdapterExecutionTargetCommandResolvable: vi.fn(), + prepareAdapterExecutionTargetRuntime: vi.fn(async () => ({ + runtimeRootDir: "/workspace/runtime", workspaceRemoteDir: "/workspace", + assetDirs: {}, restoreWorkspace: restore, + })), + readAdapterExecutionTargetHomeDir: vi.fn(async () => "/home/fixture"), + startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({ env: {}, stop })), + runAdapterExecutionTargetProcess: runProcess, + })); + vi.resetModules(); + try { + const { execute } = await import("@paperclipai/adapter-opencode-local/server"); + const { sentryReady, captureRunFailure, captureException } = await import("../sentry.js"); + await sentryReady; + const result = await execute({ + runId: "22222222-2222-4222-8222-222222222222", + agent: { id: "agent-fixture", companyId: "company-fixture", name: "Fixture", adapterType: "opencode_local", adapterConfig: {} }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { command: "opencode", model: "openai/fixture", cwd: root, timeoutSec: 0, + env: { OPENCODE_ALLOW_ALL_MODELS: "1" } }, + context: {}, onLog: async () => {}, + executionTarget: { kind: "remote", transport: "sandbox", remoteCwd: "/workspace" }, + }); + expect(result).toMatchObject({ timedOut: true, exitCode: null, signal: null }); + expect(runProcess).toHaveBeenCalledTimes(1); + expect(restore).toHaveBeenCalledTimes(1); + expect(stop).toHaveBeenCalledTimes(1); + const resultJson = mergeHeartbeatRunStopMetadata(result.resultJson, + buildHeartbeatRunStopMetadata({ adapterType: "opencode_local", adapterConfig: { timeoutSec: 0 }, outcome: "timed_out" })); + const diagnostics = sanitizeRunFailureDiagnostics(collectRunFailureDiagnostics({ + errorCode: "timeout", resultJson, + startedAt: new Date("2020-01-01T00:00:00.000Z"), finishedAt: new Date("2020-01-01T00:00:00.100Z"), + } as typeof heartbeatRuns.$inferSelect, { phase: "execute" })); + captureRunFailure({ taskId: "11111111-1111-4111-8111-111111111111", runId: "22222222-2222-4222-8222-222222222222", + errorMessage: result.errorMessage!, errorCode: "timeout", agentAdapter: "opencode_local", + runStatus: "timed_out", exitCode: result.exitCode, signal: result.signal, diagnostics }); + captureException(new Error("Unrelated timeout fixture")); + await Sentry.flush(2000); + expect(events).toHaveLength(2); + const event = events.find(entry => (entry.tags as Record)?.error_code === "timeout"); + expect(event).toMatchObject({ exception: { values: [{ value: "OpenCode execution timed out" }] }, + contexts: { run_execution: { durationMs: 100, effectiveTimeoutSec: 14400, + timeoutSource: "sandbox_default", timeoutConfigured: false, timeoutFired: true, stopReason: "timeout" } } }); + expect(JSON.stringify(events)).not.toMatch(/private-timeout-||504|Timed out after/); + expect(events.find(entry => entry !== event)).not.toHaveProperty("contexts.run_execution"); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + it("sends only owned acquisition observations and isolates unrelated captures", async () => { const Sentry = sentryPackage!; const events: Array> = []; From 7e10dbe652ef0104f0a688a196fe8ea47b6f4b8e Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sat, 10 Oct 2026 01:27:03 -0700 Subject: [PATCH 5/6] fix(server): retain stopped ephemeral workspaces after failed copy-back (#15790) ## Thinking Path > - Paperclip manages work performed by agents. > - A remote adapter must copy workspace changes back after execution. > - Cancellation can stop and release an ephemeral sandbox before copy-back fails. > - The recovery recorder previously considered only active or already held leases. > - This change carries the original host lease into the recorder and checks both stop receipts. > - The exact stopped source receives a board repair obligation without replaying work. ## Linked Issues or Issue Description **What happened?** A legacy remote run can report `workspaceRestoreFailure` after its dedicated stop has released the source lease. The recorder misses that source and does not create its file-repair action. Related prior work: #15637 adds bounded transfer diagnostics; #15718 protects native accepted finalization. This change addresses the separate legacy recording order. **Expected behavior** Preserve the exact eligible source and record the existing board repair obligation. Preserve cancellation, later task progress, and newer execution ownership. **Steps to reproduce** 1. Acquire an ephemeral remote lease for a legacy run. 2. Complete the dedicated stop-and-retain lifecycle and persist both stop receipts. 3. Report a workspace copy-back failure from the original adapter invocation. 4. Observe that the old recorder omits the released source. The PostgreSQL regression reproduces this order. **Paperclip version or commit** de042e79efbb7bb88e9716835407882e3ebab188. **Deployment mode** Server with PostgreSQL and a sandbox provider. The regression uses disposable local PostgreSQL and synthetic provider receipts. ## What Changed - Clone the realized host lease before adapter dispatch and carry it into restore recording. - Admit a released source only when its original and current policies are ephemeral, its full ownership and workspace identity match, and its dedicated retention receipt agrees with the independent stopped-execution receipt. - Reject reusable, expired, destroyed, rebound, competing, or insufficiently proved sources. Preserve release state and receipts when recording the existing no-replay repair action. - Cover the actual stop-before-copy-back lifecycle, cleanup retries, built-in dispatch, rollback, repeated recording, newer owners, and ownership/receipt failures. Document the narrow scope. ## Verification - `pnpm exec vitest run server/src/__tests__/legacy-workspace-restore-recovery.test.ts server/src/services/legacy-execution-recovery.test.ts`: 80 passed. - Existing Grok confirmed-stop/copy-back boundary test: passed. - The new PostgreSQL regression fails on the baseline because the recovery marker is absent. - Guard-removal controls fail when either the ephemeral-policy gate or original-source identity checks are removed. - `pnpm --filter @paperclipai/server typecheck`: passed. - Additional competing-owner/lifecycle checks: 6 passed, including older stopped and destroyed allocation owners. - `pnpm -r typecheck` and `pnpm build`: passed. - Independent review approved the final source. The exact ephemeral regression fails on the baseline; policy/identity guard-removal controls fail their intended assertions. - Compatibility with #15789 was checked in a disposable combined checkout: both commit orders yield the same tree. The earlier broad run passed 136 cases; the final guard/SDK delta passed 9 cases. - Greptile: 5/5 on the current head, with no review threads. - Exact-head CI: 53 successful checks and 2 expected skips; no failures or pending checks. This includes all test shards and Canary Dry Run. - The duplicate local `pnpm test:run` aggregate was stopped after full CI passed. It is not reported as a completed local suite. - No live provider calls, historical data repair, run replay, or deployment was performed. ## Risks No schema or API change. A qualifying source remains held for manual file repair and eventual removal, which can retain provider storage longer. This fix does not recover historical files or establish that the provider still holds the bytes. Reusable sources remain excluded from this new path because provider resume can start before the database handoff. Existing active-source and previously recorded recovery behavior is unchanged. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, repository tools, and independent agent review. The exact model revision and context-window size are not exposed to this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/native-workspace-finalization-recovery.md | 25 ++ .../legacy-workspace-restore-recovery.test.ts | 266 +++++++++++++++++- server/src/services/heartbeat.ts | 5 +- .../src/services/legacy-execution-recovery.ts | 10 +- .../legacy-workspace-restore-recovery.ts | 67 ++++- .../src/services/sandbox-stop-and-retain.ts | 35 ++- 6 files changed, 391 insertions(+), 17 deletions(-) diff --git a/doc/native-workspace-finalization-recovery.md b/doc/native-workspace-finalization-recovery.md index 469514c278e..2be7f1b67db 100644 --- a/doc/native-workspace-finalization-recovery.md +++ b/doc/native-workspace-finalization-recovery.md @@ -219,3 +219,28 @@ plugin RPC. Its intent pins the registered built-in provider and its receipt say `builtin.stopLease`. This provider owns no real process or filesystem; the receipt models its lifecycle. Missing built-in stop support or an unconfirmed receipt also remains pending through restart, without calling release or destroy. + +### Legacy copy-back after a confirmed stop + +A legacy adapter can report failed workspace copy-back after cancellation has +already stopped and released its sandbox lease. The host records the exact lease +snapshot from before adapter dispatch. For an originally ephemeral source, the +recorder can preserve the released allocation for repair only while the current +lease is still ephemeral and its company, run, issue, workspace, provider, +allocation, acquisition time, plugin, and realized root still match. It requires +both the dedicated stop-and-retain receipt and the matching stopped-execution +receipt. A competing owner or changed source fails closed. The recorder leaves +release status and receipts intact, excludes the allocation from reuse, and +creates the existing board-owned file-repair obligation without replaying work +or changing a newer run or the task's status. + +This late-recording path does not admit reusable leases. Provider resume can +precede the database handoff, so an old stop receipt and a database row lock +cannot prove that reuse has not started. Existing active-source and previously +recorded recovery paths retain their behavior. A missing or rejected original +snapshot does not authorize adopting another released lease. + +A retained source receipt records control-plane evidence; it does not prove the +provider still holds the original bytes or that copy-back succeeded. Operators +must verify and recover the exact source separately. Later successful runs and +completed tasks do not certify recovery of earlier workspace files. diff --git a/server/src/__tests__/legacy-workspace-restore-recovery.test.ts b/server/src/__tests__/legacy-workspace-restore-recovery.test.ts index 24d12c6674c..6278de98192 100644 --- a/server/src/__tests__/legacy-workspace-restore-recovery.test.ts +++ b/server/src/__tests__/legacy-workspace-restore-recovery.test.ts @@ -1,12 +1,13 @@ import { randomUUID } from "node:crypto"; import { and, eq, sql } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { agents, companies, createDb, environmentLeases, environments, heartbeatRuns, issueRecoveryActions, issues } from "@paperclipai/db"; +import { agents, companies, createDb, environmentLeases, environments, executionWorkspaces, heartbeatRuns, issueRecoveryActions, issues, projects } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; import { recordLegacyWorkspaceRestoreFailure, legacyExecutionNeedsReconciliationWithEvidence, terminalizeLegacyExecution } from "../services/legacy-execution-recovery.js"; import { hasConversationContinuationPolicy, conversationRecoveryActionPredicate } from "../services/conversation-continuation.js"; import { preserveWorkspaceRestoreRecoveryMetadataSql } from "../services/legacy-workspace-restore-recovery.js"; -import { settleStopOnlyCleanup } from "../services/sandbox-stop-and-retain.js"; +import { hasConfirmedSandboxStopAndRetain, prepareSandboxStopAndRetain, settleStopOnlyCleanup } from "../services/sandbox-stop-and-retain.js"; +import { environmentService } from "../services/environments.js"; import { hasRequiredWorkspaceRecovery } from "../services/workspace-restore-recovery-state.js"; const externalTestDatabaseUrl = process.env.PAPERCLIP_TEST_DATABASE_URL?.trim(); @@ -22,14 +23,20 @@ const support = externalTestDatabaseUrl ? { supported: true } : await getEmbedde await db.insert(companies).values({ id: companyId, name: "Workspace recovery", issuePrefix: companyId.slice(0, 8) }); await db.insert(agents).values({ id: agentId, companyId, name: "Engineer", role: "engineer", adapterType: "codex_local" }); await db.insert(issues).values({ id: issueId, companyId, title: "Restore files", status: options.taskDone ? "done" : "in_progress", assigneeAgentId: agentId }); - const [run] = await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", runtimeMode: "legacy", contextSnapshot: { issueId } }).returning(); + const [project] = await db.insert(projects).values({ companyId, name: "Workspace project" }).returning(); + const [workspace] = await db.insert(executionWorkspaces).values({ companyId, projectId: project.id, + sourceIssueId: issueId, mode: "isolated", strategyType: "git_worktree", name: "Fixture workspace", cwd: "/work/fixture", + }).returning(); + const [run] = await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, issueId, status: "running", runtimeMode: "legacy", + contextSnapshot: { issueId, executionWorkspaceId: workspace.id }, + }).returning(); if (options.local) await db.insert(environments).values({ name: "Local", driver: "local" }).onConflictDoNothing(); const [environment] = options.local ? await db.select().from(environments).where(eq(environments.driver, "local")) : await db.insert(environments).values({ name: `Isolated test sandbox ${runId}`, driver: "sandbox" }).returning(); - const [lease] = await db.insert(environmentLeases).values({ companyId, issueId, heartbeatRunId: runId, environmentId: environment.id, + const [lease] = await db.insert(environmentLeases).values({ companyId, issueId, executionWorkspaceId: workspace.id, heartbeatRunId: runId, environmentId: environment.id, status: "active", leasePolicy: options.reusable ? "reuse_by_environment" : "ephemeral", provider: options.local ? "local" : "daytona", providerLeaseId: randomUUID(), - metadata: { driver: options.local ? "local" : "sandbox", pluginId: randomUUID(), sandboxProviderPlugin: !options.local }, + metadata: { remoteCwd: "/work/fixture", workspaceRealization: { authoritativeRoot: "/work/fixture" }, driver: options.local ? "local" : "sandbox", pluginId: randomUUID(), sandboxProviderPlugin: !options.local }, }).returning(); return { companyId, runId, issueId, run, lease }; } @@ -38,6 +45,22 @@ const support = externalTestDatabaseUrl ? { supported: true } : await getEmbedde } }; const readLease = async (id: string) => (await db.select().from(environmentLeases).where(eq(environmentLeases.id, id)))[0]; + async function stopAndRetain(f: Awaited>, retry = false) { + let stopping = (await prepareSandboxStopAndRetain(db, f.lease))!; + expect(stopping.status).toBe("pending_cleanup"); + if (retry) { + [stopping] = await db.update(environmentLeases).set({ metadata: { + ...stopping.metadata, pendingCleanupAttemptId: randomUUID(), + } }).where(eq(environmentLeases.id, f.lease.id)).returning(); + } + const stopped = (await settleStopOnlyCleanup(db, stopping, { + attemptId: String(stopping.metadata!.pendingCleanupAttemptId), + receipt: { providerLeaseId: f.lease.providerLeaseId, state: "stopped" }, + }))!; + expect(stopped.status).toBe("released"); + return stopped; + } + it.each([false, true])("persists terminal status, stop-only source and board action atomically (task done=%s)", async taskDone => { const f = await seed({ taskDone, reusable: true }); expect(await legacyExecutionNeedsReconciliationWithEvidence(db, { ...f.run, status: "failed", ...patch })).toBe(true); @@ -69,6 +92,239 @@ const support = externalTestDatabaseUrl ? { supported: true } : await getEmbedde expect((await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId)))).toHaveLength(1); }); + it("records the exact stopped source when provider cancellation finishes before copy-back", async () => { + const f = await seed({ taskDone: true }); + const stopping = await prepareSandboxStopAndRetain(db, f.lease); + expect(stopping).not.toBeNull(); + const stopped = await settleStopOnlyCleanup(db, stopping!, { + attemptId: String(stopping!.metadata?.pendingCleanupAttemptId), + receipt: { providerLeaseId: f.lease.providerLeaseId, state: "stopped" }, + }); + expect(stopped?.status).toBe("released"); + await db.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date(), + resultJson: { executionCancellation: { state: "acknowledged" } }, + }).where(eq(heartbeatRuns.id, f.runId)); + // This is the lease captured by the host before invoking the adapter. + await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson, f.lease); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)); + expect(run.status).toBe("cancelled"); + expect(run.resultJson?.executionCancellation).toEqual({ state: "acknowledged" }); + expect(run.resultJson?.workspaceRestoreRecovery).toMatchObject({ leaseIds: [f.lease.id] }); + const retained = await readLease(f.lease.id); + expect(retained).toMatchObject({ status: "released", leasePolicy: "retain_on_failure", cleanupStatus: "success", + releasedAt: stopped!.releasedAt, metadata: { remoteExecutionTermination: stopped!.metadata!.remoteExecutionTermination, + sandboxStopAndRetainReceipt: stopped!.metadata!.sandboxStopAndRetainReceipt } }); + const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId)); + expect(action).toMatchObject({ status: "resolved", outcome: "blocked", ownerType: "board", + evidence: { automaticRecovery: { replay: "blocked" }, workspaceRestoreRecovery: { leaseIds: [f.lease.id] } } }); + expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0].status).toBe("done"); + // A lost commit response can cause the exact adapter receipt to be recorded twice. + await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson, f.lease); + const repeated = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId)); + expect(repeated).toHaveLength(1); + expect(repeated[0]).toMatchObject({ id: action.id, status: "resolved", outcome: "blocked", attemptCount: 1, + evidence: { automaticRecovery: { replay: "blocked" }, workspaceRestoreRecovery: { leaseIds: [f.lease.id] } } }); + expect(await readLease(f.lease.id)).toEqual(retained); + }); + + it.each(["retry", "builtin"])("keeps the original stop proof after %s dispatch", async scenario => { + const f = await seed(); + if (scenario === "builtin") { + [f.lease] = await db.update(environmentLeases).set({ provider: "fake", metadata: { + driver: "sandbox", remoteCwd: "/work/fixture", + } }).where(eq(environmentLeases.id, f.lease.id)).returning(); + } + const stopped = await stopAndRetain(f, scenario === "retry"); + if (scenario === "retry") expect((stopped.metadata!.sandboxStopAndRetainReceipt as Record).requestId) + .not.toBe(stopped.metadata!.pendingCleanupAttemptId); + await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson, f.lease); + expect(await readLease(f.lease.id)).toMatchObject({ leasePolicy: "retain_on_failure", status: "released", + metadata: { sandboxStopAndRetainReceipt: stopped.metadata!.sandboxStopAndRetainReceipt } }); + }); + + it.each(["id", "companyId", "heartbeatRunId", "environmentId", "issueId", "executionWorkspaceId", "provider", "providerLeaseId", "acquiredAt", "pluginId", "remoteCwd", "workspaceRealization"])( + "rejects a stopped source with a different original %s", async field => { + const f = await seed(); + const stopped = await stopAndRetain(f); + const source = { ...f.lease, metadata: { ...f.lease.metadata } }; + if (["pluginId", "remoteCwd", "workspaceRealization"].includes(field)) source.metadata[field] = "different-original-value"; + else if (field === "acquiredAt") source.acquiredAt = new Date(source.acquiredAt.getTime() - 1); + else Object.assign(source, { [field]: randomUUID() }); + await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson, source); + expect(await readLease(f.lease.id)).toEqual(stopped); + expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toEqual([]); + }, + ); + + it.each(["missing_dedicated", "missing_termination", "destroyed", "foreign_receipt", "wrong_plugin", "wrong_method", "empty_request", "invalid_time", "before_acquisition", "after_release", "unequal_confirmation", "pending_intent", "cleanup_in_flight", "expired_allocation", "missing_release_time", "retained_without_stop", "failed_cleanup"])( + "does not infer retention from %s", async scenario => { + const f = await seed(); + const stopped = await stopAndRetain(f); + const metadata = structuredClone(stopped.metadata!); + const dedicated = metadata.sandboxStopAndRetainReceipt as Record; + const termination = metadata.remoteExecutionTermination as Record; + if (scenario === "missing_dedicated") delete metadata.sandboxStopAndRetainReceipt; + if (scenario === "missing_termination") delete metadata.remoteExecutionTermination; + if (scenario === "destroyed") termination.state = "destroyed"; + if (scenario === "foreign_receipt") dedicated.runId = randomUUID(); + if (scenario === "wrong_plugin") dedicated.pluginId = randomUUID(); + if (scenario === "wrong_method") dedicated.method = "environmentReleaseLease"; + if (scenario === "empty_request") dedicated.requestId = " "; + if (scenario === "invalid_time") dedicated.confirmedAt = termination.confirmedAt = "not-a-time"; + if (scenario === "before_acquisition") dedicated.confirmedAt = termination.confirmedAt = new Date(stopped.acquiredAt.getTime() - 1).toISOString(); + if (scenario === "after_release") dedicated.confirmedAt = termination.confirmedAt = new Date(stopped.releasedAt!.getTime() + 1).toISOString(); + if (scenario === "unequal_confirmation") dedicated.confirmedAt = new Date(stopped.releasedAt!.getTime() - 1).toISOString(); + if (scenario === "pending_intent") metadata.sandboxStopAndRetain = {}; + if (scenario === "cleanup_in_flight") metadata.pendingCleanupInFlight = true; + const [invalid] = await db.update(environmentLeases).set({ metadata, + ...(scenario === "retained_without_stop" ? { status: "retained" } : {}), + ...(scenario === "expired_allocation" ? { expiresAt: new Date(stopped.acquiredAt.getTime() - 1) } : {}), + ...(scenario === "missing_release_time" ? { releasedAt: null } : {}), + ...(scenario === "failed_cleanup" ? { cleanupStatus: "failed" } : {}), + }).where(eq(environmentLeases.id, f.lease.id)).returning(); + await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson, f.lease); + expect(await readLease(f.lease.id)).toEqual(invalid); + expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toEqual([]); + }, + ); + + it("rejects invalid lease timestamps instead of treating receipt metadata as proof", async () => { + const f = await seed(); + const stopped = await stopAndRetain(f); + expect(hasConfirmedSandboxStopAndRetain(stopped)).toBe(true); + for (const field of ["acquiredAt", "releasedAt", "expiresAt"] as const) { + expect(hasConfirmedSandboxStopAndRetain({ ...stopped, [field]: new Date(Number.NaN) })).toBe(false); + } + }); + + it.each(["missing_root", "empty_root", "invalid_realized_root", "run_workspace_mismatch"])( + "requires the original execution root after %s", async scenario => { + const f = await seed(); + const metadata = { ...f.lease.metadata }; + if (scenario === "missing_root") delete metadata.remoteCwd; + if (scenario === "empty_root") metadata.remoteCwd = " "; + if (scenario === "invalid_realized_root") metadata.workspaceRealization = { authoritativeRoot: " " }; + [f.lease] = await db.update(environmentLeases).set({ metadata }).where(eq(environmentLeases.id, f.lease.id)).returning(); + if (scenario === "run_workspace_mismatch") await db.update(heartbeatRuns).set({ contextSnapshot: { + ...f.run.contextSnapshot, executionWorkspaceId: randomUUID(), + } }).where(eq(heartbeatRuns.id, f.runId)); + const stopped = await stopAndRetain(f); + await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson, f.lease); + expect(await readLease(f.lease.id)).toEqual(stopped); + expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toEqual([]); + }, + ); + + it("does not search arbitrary stopped leases when the original host snapshot is missing", async () => { + const f = await seed(); + const stopped = await stopAndRetain(f); + await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson); + expect(await readLease(f.lease.id)).toEqual(stopped); + expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toEqual([]); + }); + + it.each(["active", "stopped", "older_stopped", "older_destroyed"])("rejects a competing %s allocation owner across companies", async state => { + const f = await seed(), other = await seed(); + const stopped = await stopAndRetain(f); + [other.lease] = await db.update(environmentLeases).set({ providerLeaseId: f.lease.providerLeaseId, + acquiredAt: new Date(f.lease.acquiredAt.getTime() + (state.startsWith("older_") ? -1_000 : 1)), + }).where(eq(environmentLeases.id, other.lease.id)).returning(); + if (state !== "active") { + const stoppedOther = await stopAndRetain(other); + if (state === "older_destroyed") await db.update(environmentLeases).set({ metadata: { + ...stoppedOther.metadata, remoteExecutionTermination: { + ...(stoppedOther.metadata!.remoteExecutionTermination as Record), state: "destroyed", + }, + } }).where(eq(environmentLeases.id, other.lease.id)); + } + const competing = await readLease(other.lease.id); + await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson, f.lease); + expect(await readLease(f.lease.id)).toEqual(stopped); + expect(await readLease(other.lease.id)).toEqual(competing); + expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toEqual([]); + }); + + it("does not claim a released reusable source from a stop receipt that may precede resume", async () => { + const f = await seed({ reusable: true }); + const stopped = await stopAndRetain(f); + await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson, f.lease); + expect(await readLease(f.lease.id)).toEqual(stopped); + expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toEqual([]); + }); + + it.each(["original_reusable", "current_reusable", "expired", "root_rebound", "run_rebound"])( + "leaves a stopped source unchanged after %s", async scenario => { + const f = await seed({ reusable: scenario === "original_reusable" }); + const other = await seed(); + const stopped = await stopAndRetain(f); + const [changed] = await db.update(environmentLeases).set({ + ...(scenario === "original_reusable" ? { leasePolicy: "ephemeral" } : {}), + ...(scenario === "current_reusable" ? { leasePolicy: "reuse_by_environment" } : {}), + ...(scenario === "expired" ? { status: "expired" } : {}), + ...(scenario === "root_rebound" ? { metadata: { ...stopped.metadata, remoteCwd: "/work/replacement" } } : {}), + ...(scenario === "run_rebound" ? { heartbeatRunId: other.runId } : {}), + }).where(eq(environmentLeases.id, f.lease.id)).returning(); + await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson, f.lease); + expect(await readLease(f.lease.id)).toEqual(changed); + expect(await readLease(other.lease.id)).toEqual(other.lease); + expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toEqual([]); + }, + ); + + it("does not adopt the expired source when a normal reusable handoff wins first", async () => { + const f = await seed({ reusable: true }); + await stopAndRetain(f); + const newerRunId = randomUUID(); + await db.insert(heartbeatRuns).values({ id: newerRunId, companyId: f.companyId, agentId: f.run.agentId, + status: "running", runtimeMode: "legacy", contextSnapshot: { issueId: f.issueId } }); + // The production handoff also checks the immutable task/agent scope. + [f.lease] = await db.update(environmentLeases).set({ metadata: { + ...(await readLease(f.lease.id)).metadata, agentId: f.run.agentId, + } }).where(eq(environmentLeases.id, f.lease.id)).returning(); + const newer = await environmentService(db).acquireLease({ companyId: f.companyId, + environmentId: f.lease.environmentId!, issueId: f.issueId, executionWorkspaceId: f.lease.executionWorkspaceId, heartbeatRunId: newerRunId, + leasePolicy: "reuse_by_environment", provider: f.lease.provider!, providerLeaseId: f.lease.providerLeaseId, + metadata: { ...f.lease.metadata, agentId: f.run.agentId }, replacesReusableLeaseId: f.lease.id, + }); + const expired = await readLease(f.lease.id); + expect(expired.status).toBe("expired"); + await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson, f.lease); + expect(await readLease(f.lease.id)).toEqual(expired); + expect(await readLease(newer.id)).toEqual(newer); + expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toEqual([]); + }); + + it("rolls back the stopped-source hold and action if recording cannot commit", async () => { + const f = await seed(); + const stopped = await stopAndRetain(f); + await expect(db.transaction(async tx => { + await recordLegacyWorkspaceRestoreFailure(tx as unknown as typeof db, f.run, patch.resultJson, f.lease); + throw new Error("recording transaction failed"); + })).rejects.toThrow("recording transaction failed"); + expect(await readLease(f.lease.id)).toEqual(stopped); + expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0].resultJson).toBeNull(); + expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId))).toEqual([]); + }); + + it("preserves newer task execution while retaining only the original stopped source", async () => { + const f = await seed(), newerRunId = randomUUID(); + await stopAndRetain(f); + await db.insert(heartbeatRuns).values({ id: newerRunId, companyId: f.companyId, agentId: f.run.agentId, + status: "running", runtimeMode: "legacy", contextSnapshot: { issueId: f.issueId } }); + await db.update(issues).set({ executionRunId: newerRunId, checkoutRunId: newerRunId }).where(eq(issues.id, f.issueId)); + const [newerLease] = await db.insert(environmentLeases).values({ companyId: f.companyId, issueId: f.issueId, + heartbeatRunId: newerRunId, environmentId: f.lease.environmentId, provider: "daytona", providerLeaseId: randomUUID(), + status: "active", leasePolicy: "ephemeral", metadata: { driver: "sandbox", pluginId: randomUUID() }, + }).returning(); + await recordLegacyWorkspaceRestoreFailure(db, f.run, patch.resultJson, f.lease); + expect(await readLease(f.lease.id)).toMatchObject({ leasePolicy: "retain_on_failure" }); + expect(await readLease(newerLease.id)).toEqual(newerLease); + expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]) + .toMatchObject({ status: "in_progress", executionRunId: newerRunId, checkoutRunId: newerRunId }); + const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId)); + expect(action.evidence.automaticRecovery).toMatchObject({ replay: "blocked" }); + }); + it("leaves local lock retry and successful remote cleanup policy unchanged", async () => { const local = await seed({ local: true }); const failure = { ...patch, resultJson: { ...patch.resultJson, workspaceRestoreFailure: "restore_lock_timeout" } }; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 4df871c3d5d..a549ee7ce42 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -4738,6 +4738,9 @@ export function heartbeatService( await bindIssueToPersistedExecutionWorkspace(persistedExecutionWorkspace); const workspaceRealization = realizationResult.workspaceRealization; const executionTarget = realizationResult.executionTarget; + // Preserve the host-owned source before adapter context can share lease + // metadata. A later copy-back failure must not adopt a rebound source. + const workspaceRestoreSource = structuredClone(realizationResult.lease); let instructionCopy: Awaited> = null; let instructionSave: Record | null = null; const instructionPreparationKey = createHash("sha256").update(JSON.stringify({ @@ -7157,7 +7160,7 @@ export function heartbeatService( }; // Retention is the fallback even if recording this receipt fails. providerResourceDispositionForRun = "stop_and_retain"; - await recordLegacyWorkspaceRestoreFailure(db, run, requiredWorkspaceRestoreEvidence); + await recordLegacyWorkspaceRestoreFailure(db, run, requiredWorkspaceRestoreEvidence, workspaceRestoreSource); } for (const stream of ["stdout", "stderr"] as const) { const tail = identityRedactor.finish(stream); diff --git a/server/src/services/legacy-execution-recovery.ts b/server/src/services/legacy-execution-recovery.ts index 9d56e1891e0..828ad951553 100644 --- a/server/src/services/legacy-execution-recovery.ts +++ b/server/src/services/legacy-execution-recovery.ts @@ -1,5 +1,5 @@ import { hasRequiredWorkspaceRecovery, preserveWorkspaceRestoreRecoveryMetadata, LEGACY_WORKSPACE_RECOVERY_SCHEMA } from "./workspace-restore-recovery-state.js"; -import { hasUnrestoredRemoteWorkspace, preserveLegacyWorkspaceRestoreSources } from "./legacy-workspace-restore-recovery.js"; +import { hasUnrestoredRemoteWorkspace, preserveLegacyWorkspaceRestoreSources, type LegacyWorkspaceRestoreSource } from "./legacy-workspace-restore-recovery.js"; import { isPreDispatchReviewWaitVerified } from "./pre-dispatch-review-wait.js"; import { hasWorkspaceRestoreFailure } from "@paperclipai/shared"; import { normalizeMaxTurnStopReason } from "./heartbeat-stop-metadata.js"; @@ -74,6 +74,7 @@ export async function terminalizeLegacyExecution(input: { writeConditions?: SQL[]; /** Adapter settlement records a copy-back failure before host finalization. */ recordRestoreFailureOnly?: boolean; + workspaceRestoreSource?: LegacyWorkspaceRestoreSource; }) { const { db, run, status } = input; let patch = input.patch; @@ -122,7 +123,8 @@ export async function terminalizeLegacyExecution(input: { ) .returning(); if (!updated) return null; - const retainedLeaseIds = await preserveLegacyWorkspaceRestoreSources(tx as unknown as Db, input.recordRestoreFailureOnly ? { ...updated, status: "failed" } : updated); + const retainedLeaseIds = await preserveLegacyWorkspaceRestoreSources(tx as unknown as Db, + input.recordRestoreFailureOnly ? { ...updated, status: "failed" } : updated, input.workspaceRestoreSource); if (retainedLeaseIds.length) { [updated] = await tx.update(heartbeatRuns).set({ resultJson: { ...updated.resultJson, @@ -310,9 +312,9 @@ export async function settleInterruptedNativeBootstrap( /** Capture the required-file obligation before unrelated host writes. Keeps * execution ownership while the adapter's host finalization is still running. */ -export async function recordLegacyWorkspaceRestoreFailure(db: Db, run: Run, evidence: Record) { +export async function recordLegacyWorkspaceRestoreFailure(db: Db, run: Run, evidence: Record, source?: LegacyWorkspaceRestoreSource) { if (run.runtimeMode !== "legacy" || !hasWorkspaceRestoreFailure(evidence)) return; await terminalizeLegacyExecution({ db, run, status: run.status, - recordRestoreFailureOnly: true, patch: { resultJson: evidence }, + recordRestoreFailureOnly: true, patch: { resultJson: evidence }, workspaceRestoreSource: source, }); } diff --git a/server/src/services/legacy-workspace-restore-recovery.ts b/server/src/services/legacy-workspace-restore-recovery.ts index 9f7822375af..fc1419ddfb8 100644 --- a/server/src/services/legacy-workspace-restore-recovery.ts +++ b/server/src/services/legacy-workspace-restore-recovery.ts @@ -1,28 +1,57 @@ +import { isDeepStrictEqual } from "node:util"; import { and, eq, inArray, isNotNull, ne, or, sql } from "drizzle-orm"; import { environmentLeases, environments, heartbeatRuns, type Db } from "@paperclipai/db"; import { hasWorkspaceRestoreFailure, WORKSPACE_RESTORE_FAILURE_CODES } from "@paperclipai/shared"; -import { prepareSandboxStopAndRetain } from "./sandbox-stop-and-retain.js"; +import { hasConfirmedSandboxStopAndRetain, prepareSandboxStopAndRetain } from "./sandbox-stop-and-retain.js"; import { hasRequiredWorkspaceRecovery, LEGACY_WORKSPACE_RECOVERY_SCHEMA } from "./workspace-restore-recovery-state.js"; type Run = typeof heartbeatRuns.$inferSelect; +/** The host's realized lease snapshot from before adapter execution. Never + * reconstruct this identity from a terminal status or a search of old leases. */ +export type LegacyWorkspaceRestoreSource = Pick; function needsRemoteRestore(run: Run): boolean { return run.runtimeMode === "legacy" && ["failed", "timed_out", "interrupted", "cancelled"].includes(run.status) && hasWorkspaceRestoreFailure(run.resultJson); } -function leaseScope(run: Run) { +function leaseScope(run: Run, source?: LegacyWorkspaceRestoreSource) { return and(eq(environmentLeases.companyId, run.companyId), eq(environmentLeases.heartbeatRunId, run.id), or(eq(environmentLeases.status, "active"), and( sql`${environmentLeases.metadata}->'workspaceRestoreRecovery'->>'schema' = ${LEGACY_WORKSPACE_RECOVERY_SCHEMA}`, - sql`${environmentLeases.metadata}->'workspaceRestoreRecovery'->>'runId' = ${run.id}`)), + sql`${environmentLeases.metadata}->'workspaceRestoreRecovery'->>'runId' = ${run.id}`), + source ? and(eq(environmentLeases.id, source.id), eq(environmentLeases.status, "released")) : undefined), isNotNull(environmentLeases.providerLeaseId), isNotNull(environmentLeases.provider), ne(environmentLeases.provider, "local"), or(eq(environments.driver, "sandbox"), sql`${environmentLeases.metadata}->>'driver' = 'sandbox'`, sql`${environmentLeases.metadata}->>'sandboxProviderPlugin' = 'true'`)); } +function matchesOriginalSource(lease: typeof environmentLeases.$inferSelect, run: Run, source: LegacyWorkspaceRestoreSource) { + const issueId = run.nativeIssueId ?? run.issueId ?? run.contextSnapshot?.issueId ?? null; + const workspaceId = run.contextSnapshot?.executionWorkspaceId; + const remoteCwd = source.metadata?.remoteCwd; + const realization = source.metadata?.workspaceRealization; + if (typeof remoteCwd !== "string" || !remoteCwd.trim() + || (typeof workspaceId === "string" && source.executionWorkspaceId !== workspaceId)) return false; + if (realization !== undefined && realization !== null) { + if (typeof realization !== "object" || Array.isArray(realization)) return false; + const root = (realization as Record).authoritativeRoot; + if (root !== undefined && (typeof root !== "string" || !root.trim())) return false; + } + return source.leasePolicy === "ephemeral" + && lease.leasePolicy === source.leasePolicy + && source.companyId === run.companyId && source.heartbeatRunId === run.id && source.issueId === issueId + && (["id", "companyId", "heartbeatRunId", "environmentId", "issueId", "executionWorkspaceId", "provider", "providerLeaseId"] as const) + .every(key => lease[key] === source[key]) + && lease.acquiredAt.getTime() === source.acquiredAt.getTime() + && (["pluginId", "sandboxProviderPlugin", "remoteCwd", "workspaceRealization"] as const) + .every(key => isDeepStrictEqual(lease.metadata?.[key], source.metadata?.[key])); +} + /** A local lock timeout has no remote source to retain and keeps its old policy. */ export async function hasUnrestoredRemoteWorkspace(db: Db, run: Run): Promise { if (!needsRemoteRestore(run)) return false; @@ -35,14 +64,40 @@ export async function hasUnrestoredRemoteWorkspace(db: Db, run: Run): Promise { +export async function preserveLegacyWorkspaceRestoreSources(db: Db, run: Run, source?: LegacyWorkspaceRestoreSource): Promise { if (!needsRemoteRestore(run)) return []; const rows = await db.select({ lease: environmentLeases }).from(environmentLeases) .leftJoin(environments, eq(environments.id, environmentLeases.environmentId)) - .where(leaseScope(run)).for("update", { of: environmentLeases }); + .where(leaseScope(run, source)).for("update", { of: environmentLeases }); const retained: string[] = []; for (const { lease } of rows) { - if (lease.status !== "active") { retained.push(lease.id); continue; } + if (lease.status !== "active") { + const recovery = lease.metadata?.workspaceRestoreRecovery as Record | undefined; + if (recovery?.schema === LEGACY_WORKSPACE_RECOVERY_SCHEMA && recovery.runId === run.id) { + retained.push(lease.id); continue; + } + // Cancellation can finish the dedicated stop before copy-back reports its + // failure. Only the host's original source and two matching receipts can + // turn that released row into a file-repair hold. + if (!source || !matchesOriginalSource(lease, run, source) || !hasConfirmedSandboxStopAndRetain(lease)) continue; + const [other] = await db.select({ id: environmentLeases.id }).from(environmentLeases).where(and( + ne(environmentLeases.id, lease.id), eq(environmentLeases.provider, lease.provider!), + eq(environmentLeases.providerLeaseId, lease.providerLeaseId!), + )).limit(1); + // An ephemeral source has no legitimate reuse history. Any other physical + // owner makes retention ambiguous, including an older owner that later + // destroyed the allocation. Do not expose cross-company owner identity. + if (other) continue; + await db.update(environmentLeases).set({ leasePolicy: "retain_on_failure", + metadata: sql`${environmentLeases.metadata} || ${JSON.stringify({ workspaceRestoreRecovery: { + schema: LEGACY_WORKSPACE_RECOVERY_SCHEMA, runId: run.id, + } })}::jsonb`, + }).where(eq(environmentLeases.id, lease.id)); + // Reusable sources are excluded: provider resume precedes the database + // handoff, so a row lock cannot fence an in-flight resume. Keep the release + // status and receipts; this repair hold grants no execute right. + retained.push(lease.id); continue; + } // The stop-only worker rechecks exact allocation ownership, including // competing leases, before dispatch. Persist the intent even when cleanup // must wait, so no terminal-run sweep can fall back to destruction. diff --git a/server/src/services/sandbox-stop-and-retain.ts b/server/src/services/sandbox-stop-and-retain.ts index e9823dcabd4..d90c2d3e625 100644 --- a/server/src/services/sandbox-stop-and-retain.ts +++ b/server/src/services/sandbox-stop-and-retain.ts @@ -2,13 +2,46 @@ import { randomUUID } from "node:crypto"; import { and, eq, sql, type SQL } from "drizzle-orm"; import { environmentLeases, type Db } from "@paperclipai/db"; import { isBuiltinSandboxProvider } from "./sandbox-provider-runtime.js"; -import { remoteTerminationReceipt } from "./remote-execution-termination.js"; +import { hasRemoteTerminationReceipt, remoteTerminationReceipt } from "./remote-execution-termination.js"; import { hasNativeWorkspaceExportResume, readNativeWorkspaceExportResume, settleNativeWorkspaceExportResume } from "./native-runtime/native-workspace-export-resume.js"; type Lease = Pick; export const SANDBOX_STOP_AND_RETAIN_KEY = "sandboxStopAndRetain"; +/** A successful stop is not by itself proof that the allocation was retained. + * Require the dedicated stop-only dispatch receipt and its independent stop + * confirmation, both bound to the original lease. No provider work is allowed. */ +export function hasConfirmedSandboxStopAndRetain(lease: typeof environmentLeases.$inferSelect): boolean { + const acquiredAt = lease.acquiredAt.getTime(); + const releasedAt = lease.releasedAt?.getTime(); + const expiresAt = lease.expiresAt?.getTime(); + if (!Number.isFinite(acquiredAt) || releasedAt === undefined || !Number.isFinite(releasedAt) + || (expiresAt !== undefined && (!Number.isFinite(expiresAt) || expiresAt <= Date.now()))) return false; + const value = lease.metadata?.sandboxStopAndRetainReceipt; + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const receipt = value as Record; + const termination = lease.metadata?.remoteExecutionTermination as Record | undefined; + if (lease.status !== "released" || !hasRemoteTerminationReceipt(lease) + || termination?.state !== "stopped" || hasStopOnlyCleanup(lease) + || lease.metadata?.pendingCleanupInFlight === true + || receipt.schema !== "paperclip.sandbox-stop-and-retain-receipt.v1" + || receipt.companyId !== lease.companyId || receipt.runId !== lease.heartbeatRunId + || receipt.leaseId !== lease.id || receipt.provider !== lease.provider + || receipt.providerLeaseId !== lease.providerLeaseId + || typeof receipt.requestId !== "string" || !receipt.requestId.trim() + || typeof receipt.confirmedAt !== "string" || receipt.confirmedAt !== termination.confirmedAt) return false; + const confirmedAt = Date.parse(receipt.confirmedAt); + if (!Number.isFinite(confirmedAt) || confirmedAt < acquiredAt || confirmedAt > releasedAt) return false; + if (receipt.builtinProvider !== undefined) { + return receipt.builtinProvider === lease.provider && isBuiltinSandboxProvider(lease.provider!) + && receipt.method === "builtin.stopLease" && receipt.pluginId === undefined + && lease.metadata?.pluginId == null && !lease.metadata?.sandboxProviderPlugin; + } + return receipt.method === "environmentStopLease" && typeof receipt.pluginId === "string" + && !!receipt.pluginId.trim() && receipt.pluginId === lease.metadata?.pluginId; +} + export function hasStopOnlyCleanup(lease: Pick): boolean { return hasNativeWorkspaceExportResume(lease) || Object.prototype.hasOwnProperty.call(lease.metadata ?? {}, SANDBOX_STOP_AND_RETAIN_KEY); From eedf25655b689eae031bd983870be42b280c15c9 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sat, 10 Oct 2026 01:55:29 -0700 Subject: [PATCH 6/6] test(adapter-utils): publish bridge queue fixtures atomically (#15792) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Remote agents send API requests through a callback bridge queue. > - Queue readers reserve memory from each published file's measured size. > - Several tests wrote directly into visible queue files while the reader was active. > - A reader could measure an empty file and then reject its completed contents with HTTP 503. > - This pull request makes those fixtures use the existing atomic publisher and proves the race without timing guesses. ## Linked Issues or Issue Description **What happened?** The public readiness run [38035333062](https://github.com/paperclipai/paperclip/actions/runs/38035333062) failed the schema-discovery bridge test. It expected HTTP 200 but received 503. The same test logged `Bridge envelope exceeded the configured size limit.` The fixture wrote requests directly to visible `.json` files. If a writer completed between the reader's size measurement and bounded read, the reader correctly rejected the changed file. The production gateway and filesystem queue client already publish complete files by renaming a temporary file. **Expected behavior** Queue fixtures must publish requests with the same atomic contract as the production writer. Schema assertions must observe completed responses. The runtime must still reject a request that grows beyond the size reserved for its read. **Steps to reproduce** Create an empty visible request file. Have the worker measure its size. Complete the write before the bounded read. The new deterministic regression produces the exact error and HTTP 503, with no handler dispatch. A separate publication test polls during an incomplete write and verifies that the real atomic writer keeps it invisible. **Paperclip version or commit** The failing run used `de042e79efbb7bb88e9716835407882e3ebab188`. The same fixture and reader are present in the base of this PR, `7e10dbe652ef0104f0a688a196fe8ea47b6f4b8e`. **Deployment mode** Local filesystem fixtures in the adapter-utils readiness tests. No external provider is required. Related PRs #15003 and #15696 address shutdown listing and idle polling. This is a separate fixture-publication race. This PR does not adopt or change their runtime work. ## What Changed - Use the existing filesystem queue client's atomic publisher at seven live-worker publication sites in five tests. - Wait for all seven schema responses within the existing single five-second bound, then assert the GET response and all six denials. Worker shutdown remains cleanup for that test. - Add a deterministic incomplete-write publication regression and a real worker regression for the bounded reader's existing HTTP 503 recovery. - Keep intentional malformed fixtures, dedicated drain assertions, runtime limits and runtime source unchanged. Test comments explain the publication contract; no product documentation changes are needed. ## Verification - Full bridge file: 68/68 tests passed on Node 25.6.1. - Seven focused publication, schema, authorization, drain and recovery cases passed. - Negative control: replacing the fixture helper with a direct write makes the publication test fail in milliseconds with the exact bounded-reader error. Restoring atomic publication passes. - Broader adapter-utils run: 1,690 passed, 12 skipped, one unchanged GitHub-launcher identity test exceeded its existing five-second limit. That exact case passed unchanged in isolation. This is not claimed as a full aggregate pass; no timeout or launcher source was changed. - Full workspace `pnpm -r typecheck` and `pnpm build` passed. - Independent review passed all 68 bridge tests and adapter-utils typecheck, plus three negative controls: both forms of direct publication fail, and removing the bounded read fails the expected-503 regression. The original source was restored and targeted positives passed again. - Hosted [CI 38038772400](https://github.com/paperclipai/paperclip/actions/runs/38038772400) passed at `aa4bcd8c1729e8ce098f2eb0143502ab34237d01`, including the full workspaces-b suite that originally failed. The PR has 53 successful checks and two expected Storybook skips. - Greptile scored that same commit 5/5; independent source review is approved, no review threads remain open, and GitHub reports no merge conflicts. ## Risks This change only affects tests. The new filesystem spy is limited to its own temporary directory and is restored in `finally`. A producer regression must still fail the publication test. The schema test retains all authorization checks and a single five-second response bound. The bridge's size limits, failure recovery, request handling and shutdown policy are unchanged. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools and independent agent review. The exact service model identifier and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- .../src/sandbox-callback-bridge.test.ts | 107 +++++++++++++++--- 1 file changed, 92 insertions(+), 15 deletions(-) diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts index 7420c689a26..c099f588a27 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts @@ -1,6 +1,6 @@ import { execFile as execFileCallback, spawn } from "node:child_process"; import { createServer } from "node:http"; -import { mkdir, mkdtemp, readFile, readdir, rm, utimes, writeFile } from "node:fs/promises"; +import fs, { mkdir, mkdtemp, readFile, readdir, rm, utimes, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -101,6 +101,12 @@ describe("sandbox callback bridge", () => { }; } + // Match the gateway's atomic publication. A visible, growing .json file can + // be stat'ed while empty and exceed the reader's reserved size on the read. + function publishBridgeRequest(requestPath: string, body: string): Promise { + return createFileSystemSandboxCallbackBridgeQueueClient().writeTextFile(requestPath, body); + } + async function waitForJsonFile(directory: string, timeoutMs = 2_000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { @@ -297,6 +303,80 @@ describe("sandbox callback bridge", () => { }); + it("publishes complete fixture envelopes before queue readers can reserve their size", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-publication-")); + cleanupDirs.push(rootDir); + const client = createFileSystemSandboxCallbackBridgeQueueClient(); + const requestPath = path.join(rootDir, "fixture.json"); + const payload = JSON.stringify({ id: "fixture", method: "GET", path: "/api/openapi.json", body: "" }); + const listingsDuringWrite: string[][] = []; + const write = fs.writeFile.bind(fs); + // Force a poll while the writer has created its file but not filled it. + // A direct .json write would become visible and reserve zero bytes here. + const spy = vi.spyOn(fs, "writeFile").mockImplementation(async (file, body, options) => { + if (typeof file !== "string" || path.dirname(file) !== rootDir) return write(file, body, options); + await write(file, "", options); + const visible = await client.listJsonFiles(rootDir); + listingsDuringWrite.push(visible); + const reserved = visible.includes("fixture.json") ? await client.fileSize!(requestPath) : null; + await write(file, body, options); + if (reserved !== null) await client.readTextFile(requestPath, reserved); + }); + try { + await publishBridgeRequest(requestPath, payload); + expect(listingsDuringWrite).toEqual([[]]); + expect(await client.listJsonFiles(rootDir)).toEqual(["fixture.json"]); + const size = await client.fileSize!(requestPath); + expect(size).toBe(Buffer.byteLength(payload)); + await expect(client.readTextFile(requestPath, size)).resolves.toBe(payload); + } finally { + spy.mockRestore(); + } + }); + + it("keeps the bounded reader fail-closed when a visible request grows after its size is reserved", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-growing-request-")); + cleanupDirs.push(rootDir); + const queueDir = path.join(rootDir, "queue"); + const directories = sandboxCallbackBridgeDirectories(queueDir); + const client = createFileSystemSandboxCallbackBridgeQueueClient(); + const requestPath = path.join(directories.requestsDir, "growing.json"); + await mkdir(directories.requestsDir, { recursive: true }); + // Deliberately violate the publication contract to reproduce the old + // fixture interleaving, without timing assumptions or a weakened limit. + await writeFile(requestPath, ""); + const payload = JSON.stringify({ id: "growing", method: "GET", path: "/api/openapi.json", body: "" }); + let reserved: number | undefined; + const handleRequest = vi.fn(async () => ({ status: 200, body: "unexpected" })); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const worker = await startSandboxCallbackBridgeWorker({ + queueDir, handleRequest, + client: { ...client, fileSize: async (file) => { + const size = await client.fileSize!(file); + if (file === requestPath && reserved === undefined) { + reserved = size; + await writeFile(requestPath, payload); + } + return size; + } }, + }); + cleanupFns.push(() => worker.stop()); + const responseFile = await waitForJsonFile(directories.responsesDir); + const response = JSON.parse(await readFile(path.join(directories.responsesDir, responseFile), "utf8")); + expect(reserved).toBe(0); + expect(response.status).toBe(503); + expect(JSON.parse(response.body)).toEqual({ + error: "Sandbox callback bridge worker failed: Bridge envelope exceeded the configured size limit.", + }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("Bridge envelope exceeded the configured size limit.")); + expect(handleRequest).not.toHaveBeenCalled(); + await worker.stop(); + } finally { + warn.mockRestore(); + } + }); + it("serves schema discovery over the queue and denies schema mutations and lookalikes", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-schema-")); cleanupDirs.push(rootDir); @@ -322,11 +402,14 @@ describe("sandbox callback bridge", () => { { method: "GET", path: "/api/secrets" }, ]; for (const [index, request] of requests.entries()) { - await writeFile(path.join(directories.requestsDir, `schema-${index}.json`), JSON.stringify({ + await publishBridgeRequest(path.join(directories.requestsDir, `schema-${index}.json`), JSON.stringify({ id: `schema-${index}`, ...request, query: "", headers: {}, body: "", createdAt: new Date().toISOString(), })); } - await worker.stop({ drainTimeoutMs: 5_000 }); + await vi.waitFor(async () => { + expect(await createFileSystemSandboxCallbackBridgeQueueClient().listJsonFiles(directories.responsesDir)) + .toEqual(requests.map((_, index) => `schema-${index}.json`)); + }, { timeout: 5_000 }); for (const [index] of requests.entries()) { const response = JSON.parse(await readFile(path.join(directories.responsesDir, `schema-${index}.json`), "utf8")); expect(response.status).toBe(index === 0 ? 200 : 403); @@ -355,7 +438,7 @@ describe("sandbox callback bridge", () => { }, }); - await writeFile( + await publishBridgeRequest( path.posix.join(directories.requestsDir, "req-1.json"), `${JSON.stringify({ id: "req-1", @@ -366,7 +449,6 @@ describe("sandbox callback bridge", () => { body: "", createdAt: new Date().toISOString(), })}\n`, - "utf8", ); await worker.stop({ drainTimeoutMs: 1_000 }); @@ -403,7 +485,7 @@ describe("sandbox callback bridge", () => { }, }); - await writeFile( + await publishBridgeRequest( path.posix.join(directories.requestsDir, "req-a.json"), `${JSON.stringify({ id: "req-a", @@ -414,9 +496,8 @@ describe("sandbox callback bridge", () => { body: "", createdAt: new Date().toISOString(), })}\n`, - "utf8", ); - await writeFile( + await publishBridgeRequest( path.posix.join(directories.requestsDir, "req-b.json"), `${JSON.stringify({ id: "req-b", @@ -427,7 +508,6 @@ describe("sandbox callback bridge", () => { body: "", createdAt: new Date().toISOString(), })}\n`, - "utf8", ); await worker.stop({ drainTimeoutMs: 1_000 }); @@ -462,7 +542,7 @@ describe("sandbox callback bridge", () => { }, }); - await writeFile( + await publishBridgeRequest( path.posix.join(directories.requestsDir, "req-a.json"), `${JSON.stringify({ id: "req-a", @@ -473,9 +553,8 @@ describe("sandbox callback bridge", () => { body: "", createdAt: new Date().toISOString(), })}\n`, - "utf8", ); - await writeFile( + await publishBridgeRequest( path.posix.join(directories.requestsDir, "req-b.json"), `${JSON.stringify({ id: "req-b", @@ -486,7 +565,6 @@ describe("sandbox callback bridge", () => { body: "", createdAt: new Date().toISOString(), })}\n`, - "utf8", ); // Begin the short drain deadline only after the first handler has started. @@ -592,7 +670,7 @@ describe("sandbox callback bridge", () => { }); const requestId = "transient-recovery-1"; - await writeFile( + await publishBridgeRequest( path.join(directories.requestsDir, `${requestId}.json`), JSON.stringify({ id: requestId, @@ -602,7 +680,6 @@ describe("sandbox callback bridge", () => { headers: {}, body: "", }), - "utf8", ); const responseFile = await waitForJsonFile(directories.responsesDir, 10_000);