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 59f6adf4eee..2e8d552459b 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/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/doc/observability.md b/doc/observability.md index 9898171d343..d8f6402ac0d 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. @@ -631,7 +632,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 @@ -639,6 +640,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/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 00000000000..1f9a65da489 Binary files /dev/null and b/docs/pr-screenshots/pr-15773/overview-edit-dark.png differ 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 00000000000..0ed726e7e8a Binary files /dev/null and b/docs/pr-screenshots/pr-15773/overview-edit-light.png differ 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 00000000000..a333928bb0f Binary files /dev/null and b/docs/pr-screenshots/pr-15773/overview-view-dark.png differ 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 00000000000..a1e9c8c457f Binary files /dev/null and b/docs/pr-screenshots/pr-15773/overview-view-light.png differ diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts index 4c22279d7ed..c81f162ea32 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, rename, 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,12 +101,10 @@ describe("sandbox callback bridge", () => { }; } - async function publishBridgeRequest(requestPath: string, body: string, encoding: "utf8" = "utf8") { - // The queue protocol publishes complete envelopes; live .json writes race - // the worker's stat/read size fence and can trigger a recovery response. - const temporaryPath = `${requestPath}.tmp`; - await writeFile(temporaryPath, body, encoding); - await rename(temporaryPath, requestPath); + // 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 { @@ -305,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); @@ -334,7 +406,10 @@ describe("sandbox callback bridge", () => { 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); @@ -374,7 +449,6 @@ describe("sandbox callback bridge", () => { body: "", createdAt: new Date().toISOString(), })}\n`, - "utf8", ); await worker.stop({ drainTimeoutMs: 1_000 }); @@ -422,7 +496,6 @@ describe("sandbox callback bridge", () => { body: "", createdAt: new Date().toISOString(), })}\n`, - "utf8", ); await publishBridgeRequest( path.posix.join(directories.requestsDir, "req-b.json"), @@ -435,7 +508,6 @@ describe("sandbox callback bridge", () => { body: "", createdAt: new Date().toISOString(), })}\n`, - "utf8", ); await worker.stop({ drainTimeoutMs: 1_000 }); @@ -481,7 +553,6 @@ describe("sandbox callback bridge", () => { body: "", createdAt: new Date().toISOString(), })}\n`, - "utf8", ); await publishBridgeRequest( path.posix.join(directories.requestsDir, "req-b.json"), @@ -494,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. @@ -610,7 +680,6 @@ describe("sandbox callback bridge", () => { headers: {}, body: "", }), - "utf8", ); const responseFile = await waitForJsonFile(directories.responsesDir, 10_000); @@ -1001,7 +1070,6 @@ describe("sandbox callback bridge", () => { body: "", createdAt: new Date().toISOString(), })}\n`, - "utf8", ); const queueFullResponse = await fetch(`${bridge.baseUrl}/api/agents/me`, { @@ -1831,7 +1899,7 @@ describe("sandbox callback bridge", () => { const queueDir = path.posix.join(rootDir, "queue"); const directories = sandboxCallbackBridgeDirectories(queueDir); await mkdir(directories.requestsDir, { recursive: true }); - await publishBridgeRequest(path.posix.join(directories.requestsDir, "req-a.json"), bridgeRequestJson("req-a"), "utf8"); + await publishBridgeRequest(path.posix.join(directories.requestsDir, "req-a.json"), bridgeRequestJson("req-a")); const { runtimeSpan, workerErrors } = createWorkerErrorCapture(); @@ -2880,7 +2948,7 @@ describe("sandbox callback bridge", () => { const queueDir = path.posix.join(rootDir, "queue"); const directories = sandboxCallbackBridgeDirectories(queueDir); await mkdir(directories.requestsDir, { recursive: true }); - await publishBridgeRequest(path.posix.join(directories.requestsDir, "req-w.json"), bridgeRequestJson("req-w"), "utf8"); + await publishBridgeRequest(path.posix.join(directories.requestsDir, "req-w.json"), bridgeRequestJson("req-w")); const base = createFileSystemSandboxCallbackBridgeQueueClient(); let listCalls = 0; @@ -2927,7 +2995,7 @@ describe("sandbox callback bridge", () => { const queueDir = path.posix.join(rootDir, "queue"); const directories = sandboxCallbackBridgeDirectories(queueDir); await mkdir(directories.requestsDir, { recursive: true }); - await publishBridgeRequest(path.posix.join(directories.requestsDir, "req-ok.json"), bridgeRequestJson("req-ok"), "utf8"); + await publishBridgeRequest(path.posix.join(directories.requestsDir, "req-ok.json"), bridgeRequestJson("req-ok")); const { runtimeSpan, workerErrors } = createWorkerErrorCapture(); const processed: string[] = []; 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; useProjectWorkspace?: boolean; @@ -840,6 +856,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/__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/__tests__/run-failure-sentry-real-sdk.test.ts b/server/src/__tests__/run-failure-sentry-real-sdk.test.ts index 2c879205080..63ccef13bfd 100644 --- a/server/src/__tests__/run-failure-sentry-real-sdk.test.ts +++ b/server/src/__tests__/run-failure-sentry-real-sdk.test.ts @@ -1,5 +1,9 @@ import { captureEnvironmentAcquisitionDiagnostic } from "../services/environment-acquisition-diagnostics.js"; import { createRequire } from "node:module"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { buildHeartbeatRunStopMetadata, mergeHeartbeatRunStopMetadata } from "../services/heartbeat-stop-metadata.js"; import { afterEach, describe, expect, it, vi } from "vitest"; import { collectRunFailureDiagnostics, sanitizeRunFailureDiagnostics } from "../services/run-failure-diagnostics.js"; import type { heartbeatRuns } from "@paperclipai/db"; @@ -39,10 +43,90 @@ afterEach(async () => { 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> = []; diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 9895688aba7..b5ad656ec8e 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, @@ -3756,6 +3762,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; @@ -9129,6 +9253,7 @@ export function issueRoutes( externalChannelBinding, currentExecutionWorkspace, workProducts, + createdFrom, ] = await Promise.all([ timing.time("project_goal", () => resolveIssueProjectAndGoal(issue)), timing.time("ancestors", () => svc.getAncestors(issue.id)), @@ -9152,6 +9277,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)), @@ -9201,6 +9327,7 @@ export function issueRoutes( workProducts, linkedCases, externalChannelBinding, + createdFrom, }); }); @@ -12044,9 +12171,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); @@ -12070,12 +12215,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, @@ -12312,6 +12451,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/heartbeat.ts b/server/src/services/heartbeat.ts index 4f7bf4efa1a..0fd902728d2 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -4811,6 +4811,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({ @@ -7234,7 +7237,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/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/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); diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 600a94c3452..a4f0f95dd06 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/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/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/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/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/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/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/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} 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/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" && } 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) => ({