mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
<!-- entire-trail-link-start --> https://entire.com/gh/paperclipai/-/trails/78/changes/paperclip/78 <!-- entire-trail-link-end --> ## Thinking Path > - Paperclip manages agent work and human decisions. > - Onboarding asks the first agent to propose a task before it starts work. > - The first-task skill requires human acceptance before task creation. > - The native tool could create and schedule that task without acceptance. > - REST task creation had the same gap. > - This change checks saved acceptance in the shared creation transaction. > - Valid approvals and ordinary delegation keep their existing behavior. ## Linked Issues or Issue Description **What happened?** An agent could create a task from the canonical onboarding first task before the user accepted its proposal. A real journey caught this after the agent read the first-task skill. A local database regression reproduces the missing check in the native tool and both REST creation endpoints. **Expected behavior** New task creation must wait for saved human acceptance of the latest ordinary proposal. A different parent or a standalone task must not bypass that check. **Steps to reproduce** 1. Bind an authenticated agent run to a server-created onboarding first task. 2. Leave its proposal unaccepted. 3. Call native `create_task`, `POST /api/companies/:companyId/issues`, or `POST /api/issues/:issueId/children`. 4. Before this fix, creation succeeds. After this fix, it is denied before a child or wake is committed. **Paperclip version or commit** Reproduced on `3be54534c24fc279533ff421e62a77ad15b72954` before the fix. ## What Changed - Add a shared check for new task creation from an authenticated onboarding run. - Read the latest ordinary confirmation across both confirmation kinds under the source task lock. - Require a human card decision or validated conversational acceptance. Require the current revision for a saved plan. - Preserve ordinary delegation, committed replay, and dispatch recovery. - Add database regressions and update execution and onboarding map documentation. ## Verification - Real PostgreSQL regression before the fix: 17 failures and four passing approval/delegation controls. No skips. - Exact published source: 102 tests passed across admission, conversational confirmation, and project tools, including all 25 admission regressions. Real Linux PostgreSQL, no skips. - `pnpm build` and `pnpm -r typecheck` passed locally. - Map checks and `git diff --check` passed. - `pnpm test:run` was attempted locally. Existing suites failed because the host exhausted PostgreSQL semaphore sets. The remaining owned test process was stopped; its failed log is preserved. No unrelated database was stopped. Current-head Linux CI passed, including build, typecheck, and browser E2E. The first CI attempt lost runners; its evidence is preserved, and interrupted jobs passed one CI rerun. - The live Cloud attempt remains failed. Local tests do not qualify Cloud onboarding, artifact delivery, or native tracing. No deployment or model retry is part of this change. ## Risks A stricter creation check could reject a valid approval. Tests cover direct, conversational, checkbox, dismissed-card, and clock-skew cases. The check applies only to new tasks from the canonical onboarding run. It does not change existing task updates, execution policy, or wake recovery. ## Model Used OpenAI GPT-6 through Codex, with tool use and code execution. The session does not expose a more specific serving model ID 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 - [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>
265 lines
16 KiB
TypeScript
265 lines
16 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { eq } from "drizzle-orm";
|
|
import { authUsers, companyMemberships, heartbeatRuns, issueComments, issueThreadInteractions, issues } from "@paperclipai/db";
|
|
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
|
|
import { documentService } from "../services/documents.js";
|
|
import { resolveConfirmationFromComment } from "../services/confirmation-comment-resolution.js";
|
|
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
|
|
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
|
import { getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js";
|
|
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
|
|
|
|
const support = await getEmbeddedPostgresTestSupport();
|
|
if (!support.supported) console.warn(`Skipping first-task admission database tests: ${support.reason}`);
|
|
describe.skipIf(!support.supported)("onboarding first-task execution admission", () => {
|
|
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>>;
|
|
const originalSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
|
beforeAll(async () => {
|
|
process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID();
|
|
server = await startRunnerApiTestServer();
|
|
}, 60_000);
|
|
afterAll(async () => {
|
|
await server?.close();
|
|
if (originalSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
|
else process.env.PAPERCLIP_AGENT_JWT_SECRET = originalSecret;
|
|
});
|
|
|
|
const fixture = async () => {
|
|
const f = await server.fixture({ disableWakeOnDemand: true, connectionScenario: "fresh" });
|
|
await server.db.update(issues).set({ originKind: "onboarding_first_task" }).where(eq(issues.id, f.issueId));
|
|
return f;
|
|
};
|
|
|
|
it("rejects native child creation before persisted human proposal acceptance", async () => {
|
|
const f = await fixture();
|
|
const before = await f.snapshot();
|
|
const [beforeRun] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
|
const wakes: unknown[] = [];
|
|
const authority = new PaperclipRunnerToolAuthority(server.db, { ...f, enqueueWakeup: async (...args) => { wakes.push(args); return null; } });
|
|
await expect(authority.execute({ tool: "create_task", callId: randomUUID(), arguments: {
|
|
title: "Execute proposed work", idempotencyKey: "before-acceptance", status: "todo",
|
|
} })).rejects.toThrow(/proposal.*accept/i);
|
|
const after = await f.snapshot();
|
|
expect(after.issues).toEqual(before.issues);
|
|
expect(after.activity).toEqual(before.activity);
|
|
expect(wakes).toEqual([]);
|
|
const [afterRun] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
|
expect(afterRun.resultJson).toEqual(beforeRun.resultJson);
|
|
});
|
|
|
|
it.each(["root", "child"])("rejects generic %s creation including explicit standalone parent before acceptance", async path => {
|
|
const f = await fixture();
|
|
const before = await f.snapshot();
|
|
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId)!;
|
|
const route = path === "root" ? `/api/companies/${f.companyId}/issues` : `/api/issues/${f.issueId}/children`;
|
|
const response = await fetch(`${server.apiUrl}${route}`, {
|
|
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ title: "Bypass proposal", parentId: null, assigneeAgentId: f.agentId, status: "todo" }),
|
|
});
|
|
const body = await response.json();
|
|
expect(response.status, JSON.stringify(body)).toBe(403);
|
|
expect(body.error).toMatch(/proposal.*accept/i);
|
|
const after = await f.snapshot();
|
|
expect(after.issues).toEqual(before.issues);
|
|
expect(after.activity).toEqual(before.activity);
|
|
});
|
|
|
|
const create = (f: Awaited<ReturnType<typeof fixture>>, key = randomUUID()) => f.authority.execute({
|
|
tool: "create_task", callId: randomUUID(), arguments: { title: "Approved execution", idempotencyKey: key, status: "backlog" },
|
|
});
|
|
const proposal = async (f: Awaited<ReturnType<typeof fixture>>, checkbox = false) => {
|
|
const [issue] = await server.db.select().from(issues).where(eq(issues.id, f.issueId));
|
|
const plan = checkbox ? (await documentService(server.db).upsertIssueDocument({
|
|
issueId: f.issueId, key: "plan", title: "First task plan", format: "markdown", body: "Create and verify the approved deliverable.",
|
|
createdByAgentId: f.agentId, createdByRunId: f.runId,
|
|
})).document : null;
|
|
const svc = issueThreadInteractionService(server.db);
|
|
const card = await svc.create(issue, checkbox ? {
|
|
kind: "request_checkbox_confirmation", payload: { version: 1, prompt: "Approve this plan?",
|
|
options: [{ id: "execute", label: "Execute the plan" }], defaultSelectedOptionIds: ["execute"], minSelected: 1,
|
|
target: { type: "issue_document", key: "plan", issueId: f.issueId, documentId: plan!.id, revisionId: plan!.latestRevisionId! } },
|
|
} : { kind: "request_confirmation", payload: { version: 1, prompt: "Create one task for the agreed deliverable?" } }, { agentId: f.agentId });
|
|
return { issue, card, svc, plan };
|
|
};
|
|
const accept = async (f: Awaited<ReturnType<typeof fixture>>, p: Awaited<ReturnType<typeof proposal>>) => {
|
|
await p.svc.acceptInteraction(p.issue, p.card.id, p.plan ? { selectedOptionIds: ["execute"] } : {}, { userId: f.responsibleUserId! });
|
|
};
|
|
|
|
it.each([false, true])("admits persisted human proposal acceptance (checkbox=%s)", async checkbox => {
|
|
const f = await fixture();
|
|
const p = await proposal(f, checkbox);
|
|
await accept(f, p);
|
|
await expect(create(f)).resolves.toMatchObject({ task: { parentId: f.issueId } });
|
|
});
|
|
|
|
it("admits a persisted conversational acceptance with actual same-task human comment provenance", async () => {
|
|
const f = await fixture();
|
|
const p = await proposal(f);
|
|
const [comment] = await server.db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId,
|
|
authorUserId: f.responsibleUserId!, authorType: "user", body: "Yes, create that task." }).returning();
|
|
await resolveConfirmationFromComment(server.db, { companyId: f.companyId, issueId: f.issueId,
|
|
interactionId: p.card.id, actor: { agentId: f.agentId, runId: f.runId }, input: { commentId: comment.id, decision: "accept" } });
|
|
const [saved] = await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, p.card.id));
|
|
expect(saved).toMatchObject({ status: "accepted", resolvedByAgentId: f.agentId, resolvedByUserId: null,
|
|
result: { outcome: "accepted", commentId: comment.id } });
|
|
await expect(create(f)).resolves.toMatchObject({ task: { parentId: f.issueId } });
|
|
});
|
|
|
|
it.each(["card", "conversation"])("preserves valid %s acceptance across clocks and a changed responding user", async kind => {
|
|
const f = await fixture();
|
|
const originalUserId = randomUUID();
|
|
await server.db.insert(authUsers).values({ id: originalUserId, name: "Original requester",
|
|
email: `${originalUserId}@fixture.invalid`, createdAt: new Date(), updatedAt: new Date() });
|
|
await server.db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user",
|
|
principalId: originalUserId, status: "active", membershipRole: "member" });
|
|
await server.db.update(issues).set({ responsibleUserId: originalUserId }).where(eq(issues.id, f.issueId));
|
|
const p = await proposal(f);
|
|
if (kind === "card") await accept(f, p);
|
|
else {
|
|
const [comment] = await server.db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId,
|
|
authorUserId: f.responsibleUserId!, authorType: "user", body: "Yes, create that task." }).returning();
|
|
await resolveConfirmationFromComment(server.db, { companyId: f.companyId, issueId: f.issueId,
|
|
interactionId: p.card.id, actor: { agentId: f.agentId, runId: f.runId }, input: { commentId: comment.id, decision: "accept" } });
|
|
}
|
|
// Database-created timestamps and application decision timestamps have
|
|
// independent clocks. Persisted authority must not depend on their order.
|
|
await server.db.update(issueThreadInteractions).set({ resolvedAt: new Date(new Date(p.card.createdAt).getTime() - 100) })
|
|
.where(eq(issueThreadInteractions.id, p.card.id));
|
|
await expect(create(f)).resolves.toMatchObject({ task: { parentId: f.issueId } });
|
|
});
|
|
|
|
it("preserves acceptance of a previously dismissed proposal", async () => {
|
|
const f = await fixture();
|
|
const p = await proposal(f);
|
|
await p.svc.dismissInteraction(p.issue, p.card.id, { userId: f.responsibleUserId! });
|
|
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
|
|
await accept(f, p);
|
|
const [saved] = await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, p.card.id));
|
|
expect(saved.dismissedAt).not.toBeNull();
|
|
await expect(create(f)).resolves.toMatchObject({ task: { parentId: f.issueId } });
|
|
});
|
|
|
|
it.each(["opening-answer", "agent-self-accept", "old-comment", "foreign-comment", "derived-comment", "governed-accept", "foreign-source-run"])(
|
|
"does not treat %s as persisted human proposal acceptance", async kind => {
|
|
const f = await fixture();
|
|
const p = await proposal(f);
|
|
if (kind === "opening-answer") {
|
|
await server.db.update(issueThreadInteractions).set({ kind: "ask_user_questions", status: "answered",
|
|
result: { version: 1, answers: [] }, resolvedByUserId: f.responsibleUserId!, resolvedAt: new Date() }).where(eq(issueThreadInteractions.id, p.card.id));
|
|
} else if (kind === "agent-self-accept") {
|
|
await p.svc.acceptInteraction(p.issue, p.card.id, {}, { agentId: f.agentId, runId: f.runId });
|
|
} else if (kind === "governed-accept") {
|
|
await accept(f, p);
|
|
await server.db.update(issueThreadInteractions).set({ payload: { ...p.card.payload, toolAction: {} } as never }).where(eq(issueThreadInteractions.id, p.card.id));
|
|
} else if (kind === "foreign-source-run") {
|
|
const other = await server.fixture({ disableWakeOnDemand: true });
|
|
await accept(f, p);
|
|
await server.db.update(issueThreadInteractions).set({ sourceRunId: other.runId }).where(eq(issueThreadInteractions.id, p.card.id));
|
|
} else {
|
|
const [comment] = await server.db.insert(issueComments).values({ companyId: f.companyId,
|
|
issueId: kind === "foreign-comment" ? f.blockerId : f.issueId,
|
|
authorUserId: f.responsibleUserId!, authorType: "user", body: "A reply",
|
|
...(kind === "old-comment" ? { createdAt: new Date(new Date(p.card.createdAt).getTime() - 1_000) } : {}),
|
|
...(kind === "derived-comment" ? { derivedAuthorAgentId: f.agentId, derivedCreatedByRunId: f.runId } : {}),
|
|
}).returning();
|
|
await server.db.update(issueThreadInteractions).set({ status: "accepted", resolvedAt: new Date(),
|
|
resolvedByAgentId: f.agentId, resolvedByRunId: f.runId,
|
|
result: { version: 1, outcome: "accepted", commentId: comment.id } }).where(eq(issueThreadInteractions.id, p.card.id));
|
|
}
|
|
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
|
|
});
|
|
|
|
it.each(["pending", "rejected"])("a newer cross-kind %s proposal supersedes old acceptance", async status => {
|
|
const f = await fixture();
|
|
const old = await proposal(f);
|
|
await accept(f, old);
|
|
const current = await proposal(f, true);
|
|
await server.db.update(issueThreadInteractions).set({ createdAt: new Date(new Date(old.card.createdAt).getTime() + 1_000),
|
|
...(status === "rejected" ? { status, resolvedByUserId: f.responsibleUserId!, resolvedAt: new Date(),
|
|
result: { version: 1, outcome: "rejected" } as const } : {}),
|
|
}).where(eq(issueThreadInteractions.id, current.card.id));
|
|
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
|
|
});
|
|
|
|
it("does not substitute untargeted acceptance for approval of a saved plan", async () => {
|
|
const f = await fixture();
|
|
const p = await proposal(f);
|
|
await documentService(server.db).upsertIssueDocument({ issueId: f.issueId, key: "plan", format: "markdown",
|
|
body: "This saved plan requires acceptance of its exact revision.", createdByAgentId: f.agentId, createdByRunId: f.runId });
|
|
await accept(f, p);
|
|
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
|
|
});
|
|
|
|
it.each(["stale", "foreign-target", "empty-selection"])("does not execute a plan with %s approval", async kind => {
|
|
const f = await fixture();
|
|
const p = await proposal(f, true);
|
|
await accept(f, p);
|
|
if (kind === "stale") {
|
|
await documentService(server.db).unlockIssueDocument(f.issueId, "plan");
|
|
await documentService(server.db).upsertIssueDocument({ issueId: f.issueId, key: "plan", format: "markdown",
|
|
baseRevisionId: p.plan!.latestRevisionId, body: "A revised scope that has not been accepted." });
|
|
} else if (kind === "foreign-target") {
|
|
await server.db.update(issueThreadInteractions).set({ payload: { ...p.card.payload,
|
|
target: { type: "issue_document", key: "plan", issueId: f.blockerId, revisionId: p.plan!.latestRevisionId! } } as never }).where(eq(issueThreadInteractions.id, p.card.id));
|
|
} else {
|
|
await server.db.update(issueThreadInteractions).set({ result: { version: 1, outcome: "accepted", selectedOptionIds: [] } }).where(eq(issueThreadInteractions.id, p.card.id));
|
|
}
|
|
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
|
|
});
|
|
|
|
it("keeps ordinary delegation available even when its description invokes the skill", async () => {
|
|
const f = await fixture();
|
|
await server.db.update(issues).set({ originKind: "manual", description: "Please read /first-task, then delegate." }).where(eq(issues.id, f.issueId));
|
|
await expect(create(f)).resolves.toMatchObject({ task: { parentId: f.issueId } });
|
|
});
|
|
|
|
it("preserves native creation replay and dispatch recovery after a newer pending proposal", async () => {
|
|
const f = await fixture();
|
|
const p = await proposal(f);
|
|
await accept(f, p);
|
|
const wakes: unknown[] = [];
|
|
const authority = new PaperclipRunnerToolAuthority(server.db, { ...f, enqueueWakeup: async (...args) => { wakes.push(args); return null; } });
|
|
const input = { tool: "create_task", callId: randomUUID(), arguments: { title: "Approved child", idempotencyKey: "owned", status: "todo" } };
|
|
const first = await authority.execute(input);
|
|
expect(wakes).toHaveLength(1);
|
|
await proposal(f);
|
|
const before = await f.snapshot();
|
|
const [beforeRun] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
|
expect(await authority.execute({ ...input, callId: randomUUID() })).toEqual(first);
|
|
// Existing dispatch recovery remains bounded to this already-admitted child.
|
|
expect(wakes).toHaveLength(2);
|
|
const after = await f.snapshot();
|
|
expect(after.issues).toEqual(before.issues);
|
|
expect(after.activity).toEqual(before.activity);
|
|
const [afterRun] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
|
expect(afterRun.resultJson).toEqual(beforeRun.resultJson);
|
|
await expect(authority.execute({ ...input, arguments: { ...input.arguments, idempotencyKey: "new" } })).rejects.toThrow(/proposal.*accept/i);
|
|
});
|
|
|
|
|
|
it("replays an owned generic creation after a newer pending proposal", async () => {
|
|
const f = await fixture();
|
|
await accept(f, await proposal(f));
|
|
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
|
|
const route = `${server.apiUrl}/api/companies/${f.companyId}/issues`;
|
|
const body = { title: "Approved child", parentId: f.issueId, idempotencyKey: "owned-rest", assigneeAgentId: f.agentId, status: "backlog" };
|
|
const post = async (input = body) => {
|
|
const response = await fetch(route, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify(input) });
|
|
return { response, data: await response.json() };
|
|
};
|
|
const first = await post();
|
|
expect(first.response.status, JSON.stringify(first.data)).toBe(201);
|
|
await proposal(f);
|
|
const before = await f.snapshot();
|
|
const replay = await post();
|
|
expect(replay.response.status, JSON.stringify(replay.data)).toBe(200);
|
|
expect(replay.data).toMatchObject({ id: first.data.id, deduplicated: true });
|
|
const after = await f.snapshot();
|
|
expect(after.issues).toEqual(before.issues);
|
|
const fresh = await post({ ...body, idempotencyKey: "new-rest", title: "Another child" });
|
|
expect(fresh.response.status, JSON.stringify(fresh.data)).toBe(403);
|
|
});
|
|
|
|
});
|