diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index bfaee4b8443..218e1586134 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -376,6 +376,10 @@ Document-scoped activity may still route work when it is converted into an expli Freeform document approval text is not auto-acceptance. Plan approval, implementation approval, or review acceptance must flow through the explicit interaction, approval, execution-policy, assignment, or blocker primitives that define who owns the next move. +An interaction can be created by a run on another task in the same company, or by a run with no task. That run records the interaction's origin; its comments and results do not become context for the target task. Explicit resume and retry history takes precedence and must belong to the target task. + +New interactions exclude known comments from other tasks when recording their origins. At dispatch, older interactions may discard a copied origin only when both the producer's saved context and a comment on that producer's other task prove where it came from. Missing records, cross-company references, explicit wake comments, and unrelated origins still fail closed. + ### Comment interrupts and ownership handoffs A board comment can be an interrupt, an ownership change, both, or neither. Paperclip must keep those concepts separate in the product contract. diff --git a/server/src/services/execution-continuation-provenance.test.ts b/server/src/services/execution-continuation-provenance.test.ts new file mode 100644 index 00000000000..fd294b88ffd --- /dev/null +++ b/server/src/services/execution-continuation-provenance.test.ts @@ -0,0 +1,155 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { agents, companies, createDb, heartbeatRuns, issueComments, issueThreadInteractions, issues } from "@paperclipai/db"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js"; +import { buildExecutionContinuation, currentContinuationOrigins } from "./execution-continuation.js"; + +const support = await getEmbeddedPostgresTestSupport(); +(support.supported ? describe : describe.skip)("interaction producer task scope", () => { + let database: Awaited>; + let db: ReturnType; + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase("paperclip-continuation-provenance-"); + db = createDb(database.connectionString); + }, 30_000); + afterAll(async () => { await database?.cleanup(); }); + + async function fixture() { + const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), producerIssueId = randomUUID(); + const producerRunId = randomUUID(), interactionId = randomUUID(), targetCommentId = randomUUID(), producerCommentId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Provenance fixture", issuePrefix: `PRV${companyId.slice(0, 8)}` }); + await db.insert(agents).values({ id: agentId, companyId, name: "Executor", role: "engineer", adapterType: "paperclip_runner" }); + await db.insert(issues).values([ + { id: issueId, companyId, title: "Target task", status: "in_progress", assigneeAgentId: agentId }, + { id: producerIssueId, companyId, title: "Producer task", status: "in_progress", assigneeAgentId: agentId }, + ]); + await db.insert(issueComments).values([ + { id: targetCommentId, companyId, issueId, authorType: "user", authorUserId: "local-board", body: "Keep the target direction." }, + { id: producerCommentId, companyId, issueId: producerIssueId, authorType: "user", authorUserId: "local-board", body: "Unrelated producer direction." }, + ]); + const producerContext = { issueId: producerIssueId, commentId: producerCommentId }; + await db.insert(heartbeatRuns).values({ id: producerRunId, companyId, agentId, status: "succeeded", + contextSnapshot: producerContext, + resultJson: { summary: "Unrelated producer work.", apiToolReceipts: { saved: { state: "completed", operationId: "producer_operation", result: "Unrelated result." } } }, + }); + await db.insert(issueThreadInteractions).values({ id: interactionId, companyId, issueId, + kind: "ask_user_questions", status: "answered", sourceRunId: producerRunId, + originCommentIds: [producerCommentId, targetCommentId], + resolvedByUserId: "local-board", resolvedAt: new Date(), + payload: { version: 1, questions: [{ id: "scope", prompt: "Which scope?", selectionMode: "single", options: [{ id: "target", label: "Target" }] }] }, + result: { version: 1, answers: [{ questionId: "scope", optionIds: ["target"] }] }, + }); + const build = (context: Record = {}) => buildExecutionContinuation({ + db, companyId, issueId, agentId, context: { interactionId, wakeReason: "ask_user_questions.answered", ...context }, + summary: null, exposeLowTrustRaw: false, + }); + return { companyId, agentId, issueId, producerIssueId, producerRunId, interactionId, targetCommentId, producerCommentId, producerContext, build }; + } + + it("does not copy another task's comments into newly captured interaction origins", async () => { + const f = await fixture(); + expect(await currentContinuationOrigins(db, f.companyId, f.issueId, f.producerContext)).toEqual([f.targetCommentId]); + expect(await currentContinuationOrigins(db, f.companyId, f.issueId, { commentId: f.targetCommentId })).toEqual([f.targetCommentId]); + }); + + it("retains unknown and cross-company origins so dispatch can reject them", async () => { + const f = await fixture(), other = await fixture(), missing = randomUUID(); + const ids = [missing, "not-a-uuid", other.targetCommentId, f.producerCommentId]; + expect(await currentContinuationOrigins(db, f.companyId, f.issueId, { commentIds: ids })) + .toEqual([missing, "not-a-uuid", other.targetCommentId, f.targetCommentId]); + }); + + it("resumes a human answer with legacy producer origins without importing the producer task", async () => { + const f = await fixture(); + const envelope = await f.build(); + expect(envelope.trigger.sourceRunId).toBe(f.producerRunId); + expect(envelope.originCommentIds).toEqual([f.targetCommentId]); + expect(envelope.messages.map(row => row.id)).toEqual([f.targetCommentId]); + expect(envelope.objective).toBe("Keep the target direction."); + expect(envelope.humanResponses).toEqual([expect.objectContaining({ id: f.interactionId, + result: { answers: [{ questionId: "scope", optionIds: ["target"], otherText: undefined }] } })]); + expect(envelope.completedWork).toBeNull(); + expect(envelope.completedActions).toEqual([]); + expect(JSON.stringify(envelope)).not.toContain("Unrelated"); + }); + + it("accepts a taskless producer as provenance when the interaction has target-scoped origins", async () => { + const f = await fixture(); + await db.update(heartbeatRuns).set({ contextSnapshot: {} }).where(eq(heartbeatRuns.id, f.producerRunId)); + await db.update(issueThreadInteractions).set({ originCommentIds: [f.targetCommentId] }).where(eq(issueThreadInteractions.id, f.interactionId)); + const envelope = await f.build(); + expect(envelope.trigger.sourceRunId).toBe(f.producerRunId); + expect(envelope.completedWork).toBeNull(); + expect(envelope.messages).toHaveLength(1); + }); + + it.each(["retryOfRunId", "previousRunId", "interruptedRunId"])("prefers explicit %s history to the producer", async (key) => { + const f = await fixture(), resumeRunId = randomUUID(); + await db.insert(heartbeatRuns).values({ id: resumeRunId, companyId: f.companyId, agentId: f.agentId, + status: "interrupted", contextSnapshot: { issueId: f.issueId, commentId: f.targetCommentId }, + resultJson: { summary: "Target work.", apiToolReceipts: { saved: { state: "completed", operationId: "target_operation", result: "Target result." } } }, + }); + const envelope = await f.build({ [key]: resumeRunId }); + expect(envelope.trigger.sourceRunId).toBe(resumeRunId); + expect(envelope.completedWork).toBe("Target work."); + expect(envelope.completedActions).toEqual([expect.objectContaining({ runId: resumeRunId, operationId: "target_operation" })]); + expect(envelope.originCommentIds).toEqual([f.targetCommentId]); + expect(JSON.stringify(envelope)).not.toContain("Unrelated"); + }); + + it.each(["retryOfRunId", "previousRunId", "interruptedRunId"])("rejects explicit %s history from another task", async (key) => { + const f = await fixture(); + await expect(f.build({ [key]: f.producerRunId })).rejects.toThrow("continuation_source_context_missing"); + }); + + it("retains the explicit user continuation authorization requirement", async () => { + const f = await fixture(); + await expect(f.build({ explicitUserContinuation: { previousRunId: f.producerRunId } })) + .rejects.toThrow("continuation_user_authorization_missing"); + }); + + it.each(["sourceCommentId", "commentId"])("rejects an explicit foreign %s even when it matches the producer", async (key) => { + const f = await fixture(); + if (key === "sourceCommentId") await db.update(issueThreadInteractions) + .set({ sourceCommentId: f.producerCommentId }).where(eq(issueThreadInteractions.id, f.interactionId)); + await expect(f.build(key === "commentId" ? { commentId: f.producerCommentId } : {})) + .rejects.toThrow("continuation_source_context_missing"); + }); + + it.each(["missing", "cross-company", "unrecorded", "other-task"])("rejects %s origins instead of silently discarding them", async (kind) => { + const f = await fixture(); + let invalidId = randomUUID(); + if (kind === "cross-company") invalidId = (await fixture()).targetCommentId; + if (kind === "unrecorded") { + invalidId = f.producerCommentId; + await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.producerIssueId } }).where(eq(heartbeatRuns.id, f.producerRunId)); + } else { + if (kind === "other-task") { + const thirdIssueId = randomUUID(); + await db.insert(issues).values({ id: thirdIssueId, companyId: f.companyId, title: "Unrelated third task" }); + await db.insert(issueComments).values({ id: invalidId, companyId: f.companyId, issueId: thirdIssueId, body: "Third task." }); + } + await db.update(heartbeatRuns).set({ contextSnapshot: { ...f.producerContext, commentIds: [invalidId] } }).where(eq(heartbeatRuns.id, f.producerRunId)); + } + await db.update(issueThreadInteractions).set({ originCommentIds: [invalidId] }).where(eq(issueThreadInteractions.id, f.interactionId)); + await expect(f.build()).rejects.toThrow("continuation_source_context_missing"); + }); + + it("rejects a producer outside the company", async () => { + const f = await fixture(), other = await fixture(); + await db.update(issueThreadInteractions).set({ sourceRunId: other.producerRunId }).where(eq(issueThreadInteractions.id, f.interactionId)); + await expect(f.build()).rejects.toThrow("continuation_source_context_missing"); + }); + + it("rejects a missing explicit resume instead of falling back to the producer", async () => { + const f = await fixture(); + await expect(f.build({ retryOfRunId: randomUUID() })).rejects.toThrow("continuation_source_context_missing"); + }); + + it("rejects a missing target origin even when legacy producer origins are recoverable", async () => { + const f = await fixture(); + await db.delete(issueComments).where(eq(issueComments.id, f.targetCommentId)); + await expect(f.build()).rejects.toThrow("continuation_source_context_missing"); + }); +}); diff --git a/server/src/services/execution-continuation.ts b/server/src/services/execution-continuation.ts index 7488fe51fbf..82aa0753f90 100644 --- a/server/src/services/execution-continuation.ts +++ b/server/src/services/execution-continuation.ts @@ -69,6 +69,18 @@ export async function currentContinuationOrigins( issueId: string, context: unknown, ): Promise { + const candidates = continuationOriginCommentIds(context); + // A run may create an interaction on another task. Its own comments do not + // become authority on that task. Keep unknown references for dispatch to + // reject, rather than silently claiming complete context. + const foreignComments = candidates.length + ? await db.select({ id: issueComments.id }).from(issueComments).where(and( + eq(issueComments.companyId, companyId), + sql`${issueComments.issueId} != ${issueId}`, + inArray(sql`${issueComments.id}::text`, candidates), + )) + : []; + const foreignIds = new Set(foreignComments.map(row => row.id)); const [latest] = await db .select({ id: issueComments.id }) .from(issueComments) @@ -86,7 +98,7 @@ export async function currentContinuationOrigins( .limit(1); return [ ...new Set([ - ...continuationOriginCommentIds(context), + ...candidates.filter(id => !foreignIds.has(id)), ...(latest ? [latest.id] : []), ]), ]; @@ -144,33 +156,48 @@ export async function buildExecutionContinuation(input: { ); const explicitContinuation = object(input.context.explicitUserContinuation); const explicitUserSource = string(explicitContinuation.previousRunId); - const sourceRunId = + const resumeSourceRunId = explicitUserSource ?? - triggerInteraction?.sourceRunId ?? string(input.context.retryOfRunId) ?? string(input.context.previousRunId) ?? string(input.context.interruptedRunId); - const sourceRun = sourceRunId - ? ( - await db - .select({ context: heartbeatRuns.contextSnapshot, result: heartbeatRuns.resultJson }) - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.companyId, companyId), - eq(heartbeatRuns.id, sourceRunId), - sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`, - ), - ) - )[0] - : null; - if (sourceRunId && !sourceRun) + const producerRunId = triggerInteraction?.sourceRunId ?? null; + const sourceRunId = resumeSourceRunId ?? producerRunId; + const loadRun = async (id: string) => (await db + .select({ context: heartbeatRuns.contextSnapshot, result: heartbeatRuns.resultJson }) + .from(heartbeatRuns) + .where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, id))) + )[0]; + const candidate = sourceRunId ? await loadRun(sourceRunId) : null; + // An interaction producer is provenance. Explicit resume history must still + // belong to this task, and only task-scoped content can enter the envelope. + const sourceRun = object(candidate?.context).issueId === issueId ? candidate : null; + if ((sourceRunId && !candidate) || (resumeSourceRunId && !sourceRun)) throw new Error(explicitUserSource ? "continuation_user_authorization_missing" : "continuation_source_context_missing"); + const producer = producerRunId === sourceRunId ? candidate + : producerRunId ? await loadRun(producerRunId) : null; + if (producerRunId && !producer) throw new Error("continuation_source_context_missing"); + const producerIssueId = string(object(producer?.context).issueId); + const producerOrigins = new Set(continuationOriginCommentIds(producer?.context)); + const recordedOrigins = triggerInteraction?.originCommentIds ?? []; + const inheritedOrigins = recordedOrigins.filter(id => producerOrigins.has(id)); + // Older interactions copied the producer's origins without task scope. + // Ignore only references proven to be comments on that producer's other + // task. Missing rows, unrelated references, and explicit wake origins keep + // the existing fail-closed check below. + const inheritedForeignComments = producerIssueId && producerIssueId !== issueId && inheritedOrigins.length + ? await db.select({ id: issueComments.id }).from(issueComments).where(and( + eq(issueComments.companyId, companyId), + sql`${issueComments.issueId}::text = ${producerIssueId}`, + inArray(sql`${issueComments.id}::text`, inheritedOrigins), + )) + : []; + const inheritedForeignIds = new Set(inheritedForeignComments.map(row => row.id)); const originCommentIds = [ ...new Set([ ...continuationOriginCommentIds(input.context), ...continuationOriginCommentIds(sourceRun?.context), - ...(triggerInteraction?.originCommentIds ?? []), + ...recordedOrigins.filter(id => !inheritedForeignIds.has(id)), ...(triggerInteraction?.sourceCommentId ? [triggerInteraction.sourceCommentId] : []),