Files
PaperClipAI/server/src/services/native-runtime/runner-task-attachments.test.ts
T
df6156c2e3 fix(server): wake native status reassessment from committed changes (#17709)
## Thinking Path

> - Paperclip manages agents, tasks and their durable native results.
> - Changed evidence, task authority or review retirement can require a
new status assessment.
> - Periodic orphan recovery repeatedly reads history to find these
changes.
> - This change gives reassessment an owner driven by committed changes
and saved deadlines.
> - Existing result, assessment, decision and task rows remain durable
truth.
> - The status committer still verifies current sources and authority
before writing.
> - Settled or insufficient evidence becomes quiet until a supported
producer changes it.

## Linked Issues or Issue Description

Refs #17702 (merged), #17679 and #17680. This is slice 50, now based on
master. It owns native status reassessment; workspace settlement,
retained sessions and other orphan duties remain separate.

**What happened?**

Periodic recovery repeatedly inspects completed native results. Removing
that scan without complete writer coverage or current authority checks
could strand new evidence or commit an old result over newer work.

**Expected behavior**

Reassess from existing durable facts after relevant outer transactions
commit. Retain real retry/lease deadlines and restart/drain recovery.
Preserve current authority, quality-review and supersession checks. Make
no recurring SQL when work is settled or dormant.

**Steps to reproduce**

Complete a native result awaiting evidence or review, leave it idle,
then approve a real work product or retire the selected automatic
review. Its saved result should advance once when authorized, or record
the existing nonterminal business gate, without a periodic scan.

## What Changed

- Extract reassessment and its pure status policy from finalization
recovery into their owning modules. Add a scoped worker using the
existing delivery coordinator and scheduler; no queue table or migration
is added.
- Retain the three durable discovery triggers: changed work-product
history, authoritative task status/version, or retirement of the
selected automatic completion review. Preserve selected-source,
ownership, effects, retry and lease fences under the existing committer.
- Notify from actual task/work-product, claim release, approval,
interaction, OAuth, event evidence and workspace-settlement owners
before their transaction writes. Keep provider calls outside short
transactions and nested writes behind outer commit.
- Scope ordinary milestone/finalization/coalesced-input wakes to an
issue/run. Keep genuine company authority restoration explicit. Real
Photon and coalescer regressions cover all three response/status queues.
- Move the current parent's required quality policy into the owner and
apply it to every reassessment that could complete a task. An unmet
review records the existing in-progress decision and next action,
avoiding an operational retry loop. Preserve conversation/session reset
locks and the workspace omission-barrier signal.
- Skip reassessment in periodic recovery while preserving
explicit/startup recovery and the historical supersession guard before
independent workspace work. Document the source contract, producer
inventory, failures and qualifications in the linked audit.

## Verification

- Broad source `9fddb3f0b1157df32b64fb1f22e9d6e9624470e8` passes **2731
distinct cases in 28 complete files**, with no skips: 2458
producer/owner/authority/workspace/quality cases in 394.43 seconds and
all 273 governed-response cases in 53.74 seconds. Nineteen dedicated
fresh PostgreSQL 17 databases migrate through 0413; eighteen
embedded-only invocations preserve fresh-per-call isolation through the
existing recovery helper. All 22 temporary imports restore exactly and
all 28 hashes match.
- Final runtime source `5ea3b0fd016bf7e63ba82a7044825a46a0cd2082` passes
**1211 cases in five complete affected files** in 108.23 seconds after
the narrowly reproduced evidence-trigger fix: all 43 worker, 145 corpus,
32 finalization-recovery, 968 native-session and 23 quality-review
cases. Four fresh databases plus the quality helper's own fresh fixture
use PostgreSQL 17. Its one adapted import restores exactly; all five
hashes match. The other 27 broad-run test files are unchanged. These
qualifications are source-scoped and not cumulatively double-counted.
- Required/optional real regressions reproduce retry loops on
retired-review and evidence-approval paths before repair; both become
SQL quiet afterward with unchanged assertions/time limits. Controlled
removal of only ordinary scope guards reproduces two coarse-hint SQL
failures; exact guard restoration passes. Real producer, outer
transaction, claim, authority, 103-item backlog, retry and drain cases
pass. Photon transport is mocked; callbacks, locks and observed SQL are
real.
- Normal merges preserve 12661 parent/unrelated entries, 35 owned
entries and 21 automatic shared results, with five original conflicts
reviewed explicitly. The parent's actual squash reconciliation preserves
the entire held child tree. The final quality fix changes only its owner
and tests. Fourteen added comments are removed with identical nontrivia
tokens; no directives are removed and the final comment check is empty.
- Database build, server types/build, boundaries and exact-head
migration order pass. The database tree is identical to merged #17702:
no new SQL, queue table or snapshot is generated. Earlier benchmarks,
host limitations, failed assertions and their repairs remain in
`doc/plans/2026-10-26-native-status-reassessment-sleep-audit.md` and
`doc/idle-native-status-reassessment.md`.
- This qualifies targeted external PostgreSQL behavior, not embedded
bootstrap on the semaphore-exhausted host, a full local repository
suite, deployed sleep or measured fleet savings. Fresh exact-head CI and
review remain required.

## Risks

- Notifications retain the process-local supported-writer contract.
Independent SQL writers and uncoordinated replicas are not covered by a
new transport.
- Reassessment competes with current authority changes. Existing
row/source locks and retries remain required; arbitrary missing
identities have no row to lock, so this is not a general predicate-lock
guarantee.
- Discovery pages 100 rows but retains outstanding candidates in memory.
It remains proportional to backlog size, and cancellation occurs between
queries.
- Other orphan/workspace/session duties and Cloud sleep eligibility
remain unchanged. Missing supported producer coverage could strand
dormant work, so the producer audit and real callback/transaction tests
remain part of acceptance.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, repository tools, shell
execution and PostgreSQL tests. The runtime does not expose a more
specific serving snapshot or context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (targeted scope is stated
above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Codex <noreply@openai.com>
2026-10-29 03:40:01 -05:00

183 lines
15 KiB
TypeScript

import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external-test-database.js";
import { createHash, randomUUID } from "node:crypto";
import { Readable } from "node:stream";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import { activityLog, agents, assets, companies, createDb, heartbeatRuns, issueAttachments, issues, runnerApiResponseReservations } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import type { StorageService } from "../../storage/types.js";
import { TaskAttachmentReadCache } from "./runner-task-attachments.js";
import { runnerApiCatalog } from "./runner-api-catalog.js";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
const sha = (body: Buffer) => createHash("sha256").update(body).digest("hex");
describe("Dot assigned-task attachment reading", () => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_RUNNER_ATTACHMENTS_TEST_DATABASE_URL");
if (!external) temporary = await startEmbeddedPostgresTestDatabase("dot-task-files-");
db = createDb(external ?? temporary!.connectionString);
});
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await temporary?.cleanup(); });
afterEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); });
async function fixture(body = Buffer.from("only in the file 🌍"), enabled = true) {
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Files", issuePrefix: `F${companyId.slice(0, 6)}` });
await db.insert(agents).values({ id: agentId, companyId, name: "Dot", status: "active", adapterType: "paperclip_runner",
adapterConfig: { provider: "openai_dot", dotAttachmentAccess: enabled, dotWorkspaceAccess: false } });
await db.insert(issues).values({ id: issueId, companyId, title: "Inspect file", status: "in_progress", assigneeAgentId: agentId });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", runtimeMode: "native", nativeIssueId: issueId, invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId } });
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
const [asset] = await db.insert(assets).values({ companyId, provider: "local_disk", objectKey: `${companyId}/private-file`, originalFilename: "proof.txt", contentType: "text/plain", byteSize: body.length, sha256: sha(body) }).returning();
const [attachment] = await db.insert(issueAttachments).values({ companyId, issueId, assetId: asset!.id }).returning();
const getObject = vi.fn().mockImplementation(async () => ({ stream: Readable.from([body]) }));
const storage = { provider: "local_disk", getObject } as unknown as StorageService;
const assertBridgeAuthority = vi.fn().mockResolvedValue(undefined);
const binding = { companyId, agentId, issueId, runId, dotRuntime: true, taskAttachmentRead: enabled, storage, assertBridgeAuthority };
const authority = new PaperclipRunnerToolAuthority(db, binding);
const call = (tool: string, args: unknown = {}) => authority.execute({ tool, callId: randomUUID(), arguments: args });
return { ...binding, authority, call, asset: asset!, attachment: attachment!, body, getObject };
}
it("keeps the grant off by default and independent of workspace commands", async () => {
const f = await fixture();
expect(f.authority.definitions().map(t => t.name)).toContain("read_task_attachment");
expect(f.authority.definitions().map(t => t.name)).not.toContain("workspace_run");
const off = await fixture(Buffer.from("disabled"), false);
expect(off.authority.definitions().map(t => t.name)).not.toContain("read_task_attachment");
await expect(off.call("read_task_attachment", { attachmentId: off.attachment.id })).rejects.toThrow("not enabled");
expect(off.getObject).not.toHaveBeenCalled();
});
it("reads verified file contents without paths or storage keys and audits metadata only", async () => {
const f = await fixture();
expect(await f.call("list_task_attachments")).toMatchObject({ attachments: [{ attachmentId: f.attachment.id, assetId: f.asset.id, sha256: sha(f.body), filename: "proof.txt", readable: true }] });
const result = await f.call("read_task_attachment", { attachmentId: f.attachment.id });
expect(result).toMatchObject({ content: f.body.toString(), sha256: sha(f.body), byteSize: f.body.length, nextOffset: null, contentTrust: "untrusted_attachment" });
expect(JSON.stringify(result)).not.toContain("private-file");
expect(f.getObject).toHaveBeenCalledWith(f.companyId, f.asset.objectKey);
const logs = await db.select().from(activityLog).where(eq(activityLog.companyId, f.companyId));
expect(logs).toHaveLength(1);
expect(logs[0]).toMatchObject({ action: "dot.attachment_read", runId: f.runId });
expect(JSON.stringify(logs)).not.toContain(f.body.toString());
});
it("rejects another task, another company, and caller-supplied scope", async () => {
const f = await fixture(), foreign = await fixture();
expect(await f.call("read_task_attachment", { attachmentId: foreign.attachment.id })).toMatchObject({ outcome: "failed", code: "runner_attachment_not_found" });
const [other] = await db.insert(issues).values({ companyId: f.companyId, title: "Other", status: "todo", assigneeAgentId: f.agentId }).returning();
await db.update(issueAttachments).set({ issueId: other!.id }).where(eq(issueAttachments.id, f.attachment.id));
expect(await f.call("list_task_attachments")).toMatchObject({ attachments: [] });
expect(await f.call("read_task_attachment", { attachmentId: f.attachment.id })).toMatchObject({ outcome: "failed", code: "runner_attachment_not_found" });
expect(await f.call("read_task_attachment", { attachmentId: foreign.attachment.id, taskId: foreign.issueId })).toMatchObject({ outcome: "failed", code: "runner_attachment_invalid_arguments" });
expect(f.getObject).not.toHaveBeenCalled();
});
it.each(["disabled", "reassigned", "cancelled", "connection_revoked"])("fences a read when %s during storage acquisition", async reason => {
const f = await fixture();
f.getObject.mockImplementationOnce(async () => {
if (reason === "disabled") await db.update(agents).set({ adapterConfig: { provider: "openai_dot", dotAttachmentAccess: false } }).where(eq(agents.id, f.agentId));
if (reason === "reassigned") await db.update(issues).set({ assigneeAgentId: null }).where(eq(issues.id, f.issueId));
if (reason === "cancelled") await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, f.runId));
if (reason === "connection_revoked") f.assertBridgeAuthority.mockRejectedValue(new Error("revoked"));
return { stream: Readable.from([f.body]) };
});
expect(await f.call("read_task_attachment", { attachmentId: f.attachment.id })).toMatchObject({ outcome: "failed" });
expect(await db.select().from(activityLog).where(eq(activityLog.companyId, f.companyId))).toHaveLength(0);
});
it.each(["corrupt", "oversized", "deleted", "changed", "storage_error"])("settles %s input without sending bytes", async reason => {
const f = await fixture();
if (reason === "oversized") await db.update(assets).set({ byteSize: 16 * 1024 * 1024 + 1 }).where(eq(assets.id, f.asset.id));
else f.getObject.mockImplementationOnce(async () => {
if (reason === "deleted") await db.delete(issueAttachments).where(eq(issueAttachments.id, f.attachment.id));
if (reason === "changed") await db.update(assets).set({ objectKey: `${f.companyId}/changed` }).where(eq(assets.id, f.asset.id));
if (reason === "storage_error") throw new Error("PRIVATE STORAGE SECRET");
return { stream: Readable.from([reason === "corrupt" ? Buffer.alloc(f.body.length) : f.body]) };
});
const result = await f.call("read_task_attachment", { attachmentId: f.attachment.id });
expect(result).toMatchObject({ outcome: "failed" });
expect(result).not.toHaveProperty("content");
expect(JSON.stringify(result)).not.toContain("PRIVATE STORAGE SECRET");
if (reason === "oversized") expect(f.getObject).not.toHaveBeenCalled();
});
it("paginates UTF-8 across an emoji boundary using byte offsets and a stable hash", async () => {
const f = await fixture(Buffer.from("x".repeat(11999) + "🌍tail"));
const first = await f.call("read_task_attachment", { attachmentId: f.attachment.id }) as any;
expect(first).toMatchObject({ returnedBytes: 11999, nextOffset: 11999, content: "x".repeat(11999) });
const second = await f.call("read_task_attachment", { attachmentId: f.attachment.id, offset: first.nextOffset, expectedSha256: first.sha256 });
expect(second).toMatchObject({ content: "🌍tail", nextOffset: null, returnedBytes: 8 });
expect(f.getObject).toHaveBeenCalledTimes(1);
expect(await f.call("read_task_attachment", { attachmentId: f.attachment.id, offset: 12000 })).toMatchObject({ outcome: "failed", code: "runner_attachment_offset_invalid" });
expect(await f.call("read_task_attachment", { attachmentId: f.attachment.id, expectedSha256: "0".repeat(64) })).toMatchObject({ outcome: "failed", code: "runner_attachment_changed" });
});
it("clears cached bytes on closure and checks deleted attachment links on cached pages", async () => {
const f = await fixture();
await f.call("read_task_attachment", { attachmentId: f.attachment.id });
await db.delete(issueAttachments).where(eq(issueAttachments.id, f.attachment.id));
expect(await f.call("read_task_attachment", { attachmentId: f.attachment.id })).toMatchObject({ code: "runner_attachment_not_found" });
expect(f.getObject).toHaveBeenCalledTimes(1);
const closed = await fixture();
await closed.call("read_task_attachment", { attachmentId: closed.attachment.id });
closed.authority.close();
expect(await closed.call("read_task_attachment", { attachmentId: closed.attachment.id })).toMatchObject({ code: "runner_attachment_scope_closed" });
expect(closed.getObject).toHaveBeenCalledTimes(1);
});
it.each([false, true])("prevents generic API attachment and workspace downloads with attachment grant %s", async enabled => {
const f = await fixture(Buffer.from("private input"), enabled);
for (const [path, pathParams] of [
["/api/attachments/{attachmentId}/content", { attachmentId: f.attachment.id }],
["/api/assets/{assetId}/content", { assetId: f.asset.id }],
["/api/issues/{issueId}/file-resources/content", { issueId: f.issueId }],
] as const) {
const operation = runnerApiCatalog().find(o => o.method === "GET" && o.path === path)!;
await expect(f.call("call_api", { operationId: operation.operationId, pathParams })).rejects.toThrow(/Dot must use|Dot may read/);
}
const upload = runnerApiCatalog().find(o => o.method === "POST" && o.path === "/api/companies/{companyId}/issues/{issueId}/attachments")!;
await expect(f.call("call_api", { operationId: upload.operationId, pathParams: { companyId: f.companyId, issueId: f.issueId }, contentType: "multipart/form-data", files: [{ path: "private.txt" }] })).rejects.toThrow("workspace access");
await expect(f.call("call_api", { operationId: upload.operationId, pathParams: { companyId: f.companyId, issueId: f.issueId }, contentType: "multipart/form-data", files: [{ artifactId: f.asset.id }] })).rejects.toThrow("this run only");
expect(f.getObject).not.toHaveBeenCalled();
});
it("retains pagination of this run's API captures while rejecting another run's capture", async () => {
const f = await fixture(Buffer.from("captured")), other = await fixture();
await db.insert(runnerApiResponseReservations).values([
{ companyId: f.companyId, runId: f.runId, assetId: f.asset.id, reservedBytes: f.body.length },
{ companyId: other.companyId, runId: other.runId, assetId: other.asset.id, reservedBytes: other.body.length },
]);
vi.stubEnv("PAPERCLIP_AGENT_JWT_SECRET", "synthetic-test-key-not-a-live-credential");
vi.stubEnv("PAPERCLIP_API_URL", "http://127.0.0.1:3217");
const fetch = vi.fn().mockResolvedValue(new Response(f.body, { status: 206, headers: { "Content-Type": "text/plain", "Content-Range": `bytes 0-${f.body.length - 1}/${f.body.length}` } }));
vi.stubGlobal("fetch", fetch);
const operation = runnerApiCatalog().find(o => o.method === "GET" && o.path === "/api/assets/{assetId}/content")!;
expect(await f.call("call_api", { operationId: operation.operationId, pathParams: { assetId: f.asset.id } })).toMatchObject({ ok: true, data: "captured" });
await expect(f.call("call_api", { operationId: operation.operationId, pathParams: { assetId: other.asset.id } })).rejects.toThrow("this run only");
expect(fetch).toHaveBeenCalledTimes(1);
});
it("deduplicates concurrent verification and clears a read still pending on closure", async () => {
const cache = new TaskAttachmentReadCache();
let resolve!: (body: Buffer) => void;
const load = vi.fn(() => new Promise<Buffer>(done => { resolve = done; }));
const first = cache.read("file", "v1", load), second = cache.read("file", "v1", load);
resolve(Buffer.from("verified"));
expect((await first).toString()).toBe("verified");
expect((await second).toString()).toBe("verified");
expect(load).toHaveBeenCalledTimes(1);
const pending = cache.read("later", "v1", load);
cache.close(); resolve(Buffer.from("must not retain"));
await expect(pending).rejects.toThrow("scope_closed");
await expect(cache.read("file", "v1", load)).rejects.toThrow("scope_closed");
});
it("evicts verified copies when the per-run memory budget is reached", async () => {
const cache = new TaskAttachmentReadCache(), first = vi.fn(async () => Buffer.alloc(8 * 1024 * 1024));
await cache.read("first", "v1", first);
await cache.read("second", "v1", async () => Buffer.alloc(8 * 1024 * 1024));
await cache.read("third", "v1", async () => Buffer.from("x"));
await cache.read("first", "v1", first);
expect(first).toHaveBeenCalledTimes(2);
cache.close();
});
it("reads binary pages explicitly as base64 and supports empty files", async () => {
const f = await fixture(Buffer.from([0xff, 0x00, 0x81]));
expect(await f.call("read_task_attachment", { attachmentId: f.attachment.id })).toMatchObject({ outcome: "failed", code: "runner_attachment_use_base64" });
expect(await f.call("read_task_attachment", { attachmentId: f.attachment.id, encoding: "base64" })).toMatchObject({ content: f.body.toString("base64"), encoding: "base64", nextOffset: null });
const empty = await fixture(Buffer.alloc(0));
expect(await empty.call("read_task_attachment", { attachmentId: empty.attachment.id })).toMatchObject({ content: "", byteSize: 0, nextOffset: null });
});
});