mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 22:13:59 +01:00
## Thinking Path > - Paperclip manages work done by AI agents. > - A completed delegated task must report its result to the originating chat. > - That report uses a real provider turn and must stay in cost accounting. > - Ordinary first-task proposals must also support the promised chat acceptance path. > - A revision response can reject an older proposal before it creates the replacement. > - The old rejection transition clears that active response's ownership and blocks its next tool. > - This change proves required completion work, guides ordinary proposal policy and preserves the revision turn under the existing locks. ## Linked Issues or Issue Description No separate issue exists. The original failures remain in [the complete Product run](https://github.com/paperclipai/paperclip/actions/runs/41915965118). The independent continuation repair is #19076. Related #12209 concerns prior-run confirmation binding. This change fixes the ownership reset during rejection, rather than changing binding authorization. **What happened?** The complete 20-case run finished with 13 passes and 7 failures. Three delegated-chat cases counted one required `chat_task_completed` provider turn as unexpected work. Two first-task agents chose optional human-only cards, then correctly received 403 when they tried conversational acceptance. A [reviewed two-case candidate run](https://github.com/paperclipai/paperclip/actions/runs/41931445311) then used the changed first-task skill. Both models inherited the ordinary resolver policy. Clarify/propose/accept passed all 12 predicates. Revise/accept failed after the new response rejected its predecessor's proposal. The saved rejection was followed by `paperclip_runner_tool_binding_not_authorized` on its next tools. The final `native_session_lease_lost` label followed those binding failures; this PR does not assume lease expiry caused them. Both cells cleaned up and published their original grades. **Expected behavior** Keep the existing ordinary-turn bounds and account for one independently proved completion notification. Inherit the normal resolver policy for ordinary proposals that accept chat replies. When an active response rejects an older proposal, save the rejection and retain that response's current ownership so it can create the revised proposal. Keep human, current-source and native completion-review rejection behavior and all real approval restrictions. **Steps to reproduce** The original full run used `4740d251`. The two-case candidate used trusted workflow `7d1e9b34` and target `74da56c4`. Run the focused provider-free controls below. The dual-run API regression first fails because rejection changes the issue to `todo` and clears the newer run's execution and checkout fields. ## What Changed - Prove one completion notification only for native Codex/local `multi-repository`, `clarify-reuse` and `plan-handoff`. Keep the ordinary bounds of 2, 3 and 4. Preserve actual totals of 3, 4 and 5, all run IDs and all usage. - Read those declared totals from the current catalog, which now includes the completion reply. Check the total once and prove that exactly one turn is the required notification. Do not add another turn while waiting. The required-server controls use the actual catalog tasks. - Bind the notification to the same company, agent, conversation generation, completed child, single delivery, accounted native run, saved reply and rendered result link. Reject extra work, duplicate deliveries, unknown events and mutations, including native work without a Paperclip tool call. - Collect workspace operations through the real run API for these three proofs. Keep other collector calls unchanged. Keep the proof controls in the existing required server test lane. - Stop idle polling on a permanent completion-proof error. Continue to retry transient observations within the original 240-second window. - Guide ordinary chat-accepting first-task proposals to omit optional `resolverPolicy` and inherit server/company defaults. Preserve explicit and governed human-only restrictions. Do not lower or recreate saved restricted cards. - Under the existing locked issue and interaction rows, preserve the active responding run when it rejects a different source run's proposal. Change only the ordinary requeue predicate. Keep the saved rejection, notifications, activity and existing Telemetry calls. Keep global status, lease and tool-authority guards unchanged. - Extend the existing native API controls with the actual dual-run rejection/revision sequence and policy, provenance, human, current-source and native completion-review countercontrols. No new workflow or test framework is needed. ## Verification - Current combined head is `a8567ded67e5ab811675390e9a5c5ed2194aee2f`, integrated cleanly on `fb96537e28c4c7bfced4939909cb334392320868`. The upstream completion-reply waiter is preserved. [Current required CI](https://github.com/paperclipai/paperclip/actions/runs/41940780852) passes. Actual test checkout `ff93c034d7bb40f3df7bc75cf51828fd07bb0d29` merges this head into `9c6993f0aab7ebb22a26d78f5d227accb35a6ad1`. - Fresh Greptile is 5/5 on exact head `a8567ded`, with no blocking finding. All addressed review threads are resolved. The separate automated reviewer emitted a paused-work response; that response is not a review approval. - Current required server CI actually passed all 11 native confirmation API cases, 46 completion-proof controls and 13 onboarding asset checks. Canonical typecheck, Build, the native package/server suites, all browser and serialized shards, `ci / e2e` and `ci / verify` pass. No current-head CI retry was needed. - Local dual-run RED: the unmodified transition returned HTTP 200 and saved rejection, then changed the issue to `todo` and cleared the newer run's execution/checkout fields. Whole setup and check took 28.95 seconds; the case took 596 ms. - Local treatment: all 11 real native/API cases pass. The same-session response retains execution/checkout ownership, reads task context and persists the recorded-shape revised confirmation. Its stored lease receipt stays intact and one rejection activity is saved. Human-only rejection, current-source rejection, human rejection with/without a newer owner, Plan/Ask, foreign provenance, authentication and formal approval controls pass. - All 59 existing confirmation resolver controls pass in 16.62 seconds. These cover cancellation, lost ownership, company/user scope, policy changes, transactional rollback, concurrent retries and stale revisions. - All 46 completion controls pass in 4.07 seconds. The two added clock controls preserve the original idle deadline and prove immediate permanent-proof failure plus transient observation retry. The existing live collector-to-proof and native mutation negatives remain in this required server file. - The first current-catalog integration check failed 5 controls in 2.70 seconds. The old consumer added the already-declared reply again and could accept an extra ordinary turn. The corrected consumer passes all 46 controls using the actual catalog. The existing full 20-case catalog/reporting compatibility check also passes in 4.93 seconds. Case identities, configured models and reporting match the declared 20. - The API controls used one owned loopback PostgreSQL instance through existing supported test hooks. It was stopped and removed. Earlier missing-library/setup failures and one corrected current-source test expectation remain retained; they are not counted as product failures or passes. - The unchanged first-task skill has SHA-256 `b789090996e7925676590c006418eb8b57f4514e646f6bca18ffd7b2b8e864de`. Both candidate runtime copies matched it. The inherited policy was observed in both models; one complete acceptance journey passed. This does not qualify the new rejection repair. - The three retained real completion streams pass the stronger local proof, including native items, workspace observations and recorded DOM links. Their original failed grades stay unchanged. - Earlier related checks passed 153 hiring/non-execution controls and 13 asset checks. Their source is unchanged. Required discovery includes the completion, API and asset files exactly once. - Prior candidate `74da56c4` passed all required CI, including canonical typecheck and build, in [CI run 41929148958](https://github.com/paperclipai/paperclip/actions/runs/41929148958). Its fresh Greptile was 5/5. This is prior-head evidence. - The intermediate `053150d9` head also received Greptile 5/5. Its [CI run](https://github.com/paperclipai/paperclip/actions/runs/41940014165) and review stay retained as superseded evidence. They do not qualify the final catalog correction. - A clean full local typecheck/build is not claimed because the reused dependency cache has stale adapter-utils/Runner declarations. No source or lock workaround was made. Fresh canonical CI must pass before merge. - The original full-plan result remains 13/20. The separate two-case probe remains 1/2. A fresh complete saved-plan run against delivered source is required for acceptance. No case, assertion, model, budget, timeout or failed grade is changed. Focused commands: ```sh # From server/; database URLs point only to an owned disposable local instance. node ../node_modules/vitest/vitest.mjs run src/__tests__/chat-completion-proof.test.ts PAPERCLIP_TEST_DATABASE_URL="$OWNED_TEST_DATABASE_URL" node ../node_modules/vitest/vitest.mjs run src/services/native-runtime/confirmation-comment-api.integration.test.ts PAPERCLIP_TEST_POSTGRES_ADMIN_URL="$OWNED_TEST_DATABASE_URL" node ../node_modules/vitest/vitest.mjs run src/__tests__/confirmation-comment-resolution.test.ts ``` ## Risks - The ownership exception applies only to an agent acting as the current execution run against a different non-null source run. Human, unowned and current-source requeue behavior must stay intact. Native completion-review handling remains separate. - The completion proof fails closed on new native tools or event shapes. A new shape needs review. - A stored unchanged lease receipt is supporting local evidence. The final real journey must prove full revision, execution and delivery. - The new head still needs normal protected merge approval. The optional queued Paperclip review is not a passing review. No causal repair of unrelated CI intermittency is claimed. ## Model Used OpenAI `gpt-6.1-sol` in Codex, with `max` reasoning effort. The runtime reports a 475,000-token context window. Used reasoning, code editing, shell tools and provider-free controls. ## 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>
153 lines
10 KiB
TypeScript
153 lines
10 KiB
TypeScript
type Row = Record<string, any>;
|
|
|
|
export interface ChatCompletionEvidence {
|
|
companyId: string;
|
|
agentId: string;
|
|
companyPrefix: string;
|
|
baseURL: string;
|
|
issue: Row;
|
|
child: Row;
|
|
tasks: Row[];
|
|
comments: Row[];
|
|
runEvidence: Array<{ runId: string; events: Row[]; workspaceOperations?: Row[] }>;
|
|
renderedLinks: Array<{ commentId: string; href: string }>;
|
|
}
|
|
|
|
const completionCases = new Set(["multi-repository", "clarify-reuse", "plan-handoff"]);
|
|
export function needsChatCompletionProof(suite: string, task: string | undefined, profile?: string, environment?: string) {
|
|
return suite === "agent-chat" && completionCases.has(task ?? "")
|
|
&& profile === "runner-codex" && environment === "local";
|
|
}
|
|
|
|
const requireProof = (condition: unknown, code: string) => {
|
|
if (!condition) throw new Error(`Chat completion proof: ${code}`);
|
|
};
|
|
const time = (value: unknown) => typeof value === "string" ? Date.parse(value) : NaN;
|
|
const scopeIssue = (run: Row) => run.nativeIssueId ?? run.contextSnapshot?.issueId;
|
|
const completionEventTypes = new Set([
|
|
"legacy.case_handoff_cleanup_intent", "lifecycle", "run.startup.reuse", "run.performance.span",
|
|
"native.process_start_requested", "native.process_identity_recorded", "session.resumed",
|
|
"session.capabilities.updated", "session.goal.snapshot", "session.goal.cleared", "harness.diagnostic",
|
|
"provider.notice.recorded", "turn.submitted", "turn.accepted", "turn.started", "turn.completed",
|
|
"item.started", "item.delta", "item.completed", "tool.execution.started", "tool.execution.completed",
|
|
"run.result.proposed", "run.result.accepted", "run.terminal", "instruction_save", "run.presentation.resolved",
|
|
]);
|
|
const completionItemKinds = new Set([
|
|
"thread_lineage", "model", "goal_cleared", "userMessage", "reasoning", "agentMessage",
|
|
"dynamicToolCall", "verification", "usage",
|
|
]);
|
|
|
|
/** One required delivery is real paid work, not a row to hide from accounting.
|
|
* Only these three isolated native journeys use this independent proof.
|
|
* The catalog total includes this one turn; the caller keeps that bound exact.
|
|
*/
|
|
export function verifyChatCompletion(runs: readonly Row[], evidence: ChatCompletionEvidence | undefined) {
|
|
requireProof(evidence, "evidence_missing");
|
|
const { companyId, agentId, issue, child, tasks, comments } = evidence!;
|
|
const generation = issue.conversationSessionGeneration;
|
|
requireProof(issue.companyId === companyId && issue.conversationAgentId === agentId
|
|
&& Number.isSafeInteger(generation) && generation >= 0, "source_scope");
|
|
requireProof(child.id !== issue.id && child.companyId === companyId && child.status === "done"
|
|
&& !child.conversationAgentId && typeof child.assigneeAgentId === "string"
|
|
&& Number.isFinite(time(child.completedAt)), "child_done");
|
|
requireProof(tasks.length === 1 && tasks[0]?.id === child.id && tasks[0]?.companyId === companyId, "extra_work");
|
|
requireProof(new Set(runs.map(r => r.id)).size === runs.length && runs.every(r => typeof r.id === "string" && r.id.length > 0 && r.companyId === companyId
|
|
&& [issue.id, child.id].includes(scopeIssue(r)) && r.contextSnapshot?.issueId === scopeIssue(r)
|
|
&& r.agentId === (scopeIssue(r) === issue.id ? agentId : child.assigneeAgentId)
|
|
&& r.runtimeMode === "native" && r.status === "succeeded" && typeof r.nativeSessionId === "string" && r.nativeSessionId.length > 0
|
|
&& Number.isFinite(time(r.startedAt)) && Number.isFinite(time(r.processStartedAt))
|
|
&& Number.isFinite(time(r.finishedAt)) && time(r.finishedAt) >= time(r.startedAt)
|
|
&& r.usageJson?.accountingReceiptReady === true
|
|
&& !r.contextSnapshot?.conversationReset && !r.resultJson?.conversationReset), "actual_provider_runs");
|
|
const notifications = runs.filter(r => r.contextSnapshot?.wakeReason === "chat_task_completed"
|
|
|| r.contextSnapshot?.chatCompletionDeliveryIds?.length || r.contextSnapshot?.chatCompletionUpdates?.length);
|
|
requireProof(notifications.length === 1, "notification_count");
|
|
const run = notifications[0]!, context = run.contextSnapshot;
|
|
requireProof(scopeIssue(run) === issue.id && run.agentId === agentId
|
|
&& run.invocationSource === "automation" && run.triggerDetail === "system"
|
|
&& context.wakeReason === "chat_task_completed" && context.wakeSource === "automation"
|
|
&& context.conversationSessionGeneration === generation
|
|
&& time(run.startedAt) >= time(child.completedAt), "notification_scope");
|
|
requireProof(Array.isArray(context.chatCompletionDeliveryIds) && context.chatCompletionDeliveryIds.length === 1
|
|
&& typeof context.chatCompletionDeliveryIds[0] === "string" && context.chatCompletionDeliveryIds[0].length > 0
|
|
&& Array.isArray(context.chatCompletionUpdates) && context.chatCompletionUpdates.length === 1, "delivery_count");
|
|
const update = context.chatCompletionUpdates[0];
|
|
requireProof(update.id === child.id && update.status === "done" && update.identifier === child.identifier
|
|
&& time(update.completedAt) === time(child.completedAt), "delivery_child");
|
|
const childRuns = runs.filter(r => scopeIssue(r) === child.id);
|
|
requireProof(childRuns.length === 1 && time(childRuns[0]!.startedAt) <= time(child.completedAt), "child_execution");
|
|
|
|
const replies = comments.filter(c => c.createdByRunId === run.id);
|
|
requireProof(replies.length === 1, "reply_count");
|
|
const reply = replies[0]!;
|
|
requireProof(reply.companyId === companyId && reply.issueId === issue.id && reply.authorAgentId === agentId
|
|
&& reply.authorType === "agent" && !reply.deletedAt
|
|
&& (reply.conversationSessionGeneration == null || reply.conversationSessionGeneration === generation)
|
|
&& time(reply.createdAt) >= time(run.startedAt) && typeof reply.body === "string" && reply.body.trim(), "reply_scope");
|
|
const link = evidence!.renderedLinks.find(candidate => {
|
|
if (candidate.commentId !== reply.id) return false;
|
|
try {
|
|
const url = new URL(candidate.href, evidence!.baseURL);
|
|
if (url.origin !== new URL(evidence!.baseURL).origin) return false;
|
|
const parts = url.pathname.split("/").filter(Boolean).map(decodeURIComponent);
|
|
const index = parts.indexOf("issues");
|
|
return (index === 0 || index === 1 && parts[0] === evidence!.companyPrefix)
|
|
&& parts.length === index + 2 && [child.id, child.identifier].filter(Boolean).includes(parts[index + 1]);
|
|
} catch { return false; }
|
|
});
|
|
requireProof(link, "rendered_result_access");
|
|
|
|
const streams = evidence!.runEvidence.filter(e => e.runId === run.id);
|
|
requireProof(streams.length === 1 && streams[0]!.events.length > 0, "events_missing");
|
|
const stream = streams[0]!;
|
|
requireProof(stream.events.every(e => typeof e.eventType === "string" && e.companyId === companyId && e.runId === run.id && e.agentId === agentId), "event_scope");
|
|
// Native harness work need not use a Paperclip tool. Unknown event types and
|
|
// item kinds (commands, file changes, searches) cannot earn this allowance.
|
|
requireProof(stream.events.every(e => completionEventTypes.has(e.eventType)), "additional_native_work");
|
|
const items = stream.events.filter(e => e.eventType.startsWith("item."));
|
|
for (const event of items) {
|
|
const prp = event.payload?.prpEvent;
|
|
requireProof(prp?.schema === "paperclip.prp.event.v1" && prp.runId === run.id
|
|
&& prp.eventType === event.eventType && completionItemKinds.has(prp.payload?.kind), "additional_native_work");
|
|
if (prp.payload.kind !== "dynamicToolCall" && prp.payload.item != null) {
|
|
requireProof(prp.payload.item.type === prp.payload.kind, "additional_native_work");
|
|
}
|
|
}
|
|
requireProof(stream.events.some(e => e.eventType === "run.result.accepted"
|
|
&& e.payload?.prpEvent?.schema === "paperclip.prp.event.v1" && e.payload.prpEvent.runId === run.id
|
|
&& e.payload.prpEvent.eventType === e.eventType), "accepted_result_missing");
|
|
requireProof(Array.isArray(stream.workspaceOperations) && stream.workspaceOperations.every(o => o.phase === "workspace_finalize"
|
|
&& o.heartbeatRunId === run.id && o.companyId === companyId && o.status === "succeeded" && !o.command), "additional_workspace_work");
|
|
const allowedTools = new Set(["get_task", "list_task_documents", "get_task_document", "paperclip_finish"]);
|
|
const tools = stream.events.filter(e => e.eventType.startsWith("tool.execution."));
|
|
const starts = new Map<string, string>(), finishes = new Map<string, string>();
|
|
for (const event of tools) {
|
|
const prp = event.payload?.prpEvent, tool = prp?.payload;
|
|
requireProof(prp?.schema === "paperclip.prp.event.v1" && prp.runId === run.id && prp.eventType === event.eventType
|
|
&& tool?.schema === "paperclip.tool.execution.v1" && allowedTools.has(tool.name)
|
|
&& typeof tool.executionId === "string" && tool.executionId.length > 0, "additional_tool_work");
|
|
const started = event.eventType === "tool.execution.started";
|
|
requireProof(started || event.eventType === "tool.execution.completed", "tool_outcome");
|
|
requireProof(tool.status === (started ? "running" : "completed"), "tool_outcome");
|
|
const target = started ? starts : finishes;
|
|
requireProof(started || starts.get(tool.executionId) === tool.name, "tool_completion_order");
|
|
requireProof(!target.has(tool.executionId), "duplicate_tool_execution");
|
|
target.set(tool.executionId, tool.name);
|
|
}
|
|
requireProof(starts.size === finishes.size && [...starts].every(([id, name]) => finishes.get(id) === name)
|
|
&& [...finishes.values()].filter(name => name === "paperclip_finish").length === 1, "tool_completion");
|
|
for (const event of items) {
|
|
const payload = event.payload.prpEvent.payload;
|
|
if (payload.kind !== "dynamicToolCall") continue;
|
|
const item = payload.item;
|
|
requireProof(item && (event.eventType === "item.started" && item.type === "tool_use"
|
|
&& allowedTools.has(item.name) && starts.get(item.id) === item.name
|
|
|| event.eventType === "item.completed" && item.type === "tool_result"
|
|
&& item.tool_use_id === item.id && finishes.has(item.id)), "unbound_native_tool");
|
|
}
|
|
return { ordinaryProviderRuns: runs.length - 1, totalProviderRuns: runs.length,
|
|
allRunIds: runs.map(r => r.id), notificationRunId: run.id,
|
|
deliveryId: context.chatCompletionDeliveryIds[0] as string, childId: child.id as string,
|
|
replyId: reply.id as string, renderedResultHref: link!.href };
|
|
}
|