mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-10 20:50:08 +02:00
## Thinking Path > - Paperclip manages work for AI agents. > - The runtime claims eligible assigned tasks before it starts an agent. > - The wake tells the agent when the runtime already holds that claim. > - The legacy skill still requires another checkout in every case. > - This PR makes the skill honor the current task and run claim. > - Manual checkout and server ownership checks remain in place for other cases. ## Linked Issues or Issue Description **Where is the issue?** `skills/paperclip/SKILL.md`, in the scoped wake procedure and Step 5. **What's wrong?** The wake can say that the harness already checked out the issue. The skill still tells the agent that it must call checkout. These instructions conflict. **Suggested fix** Skip the second checkout only when the runtime wake explicitly confirms the claim for this issue and run. Retain manual checkout when that statement is absent or the agent selects another task. Refs #14948 for the existing shared prompt reduction. ## What Changed - Honor the explicit runtime claim in the scoped wake procedure and Step 5. - Keep context reads, status writes, deliverable handling and conflict rules. - Add checks for normal and resumed wake text and excluded automatic claims. - Retain successful checkout HTTP activity for legacy stock-task evals. Bind each receipt to the exact company, task, agent and run. Keep this observation separate from the original task grades. ## Verification - Checkout observation calibration: nine tests pass. - Focused skill, wake and database ownership tests: in progress. - Full repository build, typecheck and tests: in progress. - Planned live comparison: the existing assigned-skill document case on legacy Codex and Claude. One attempt per variant and profile. No automatic retries. The baseline and candidate share the observation code and task oracle. - Live results are pending. This draft does not claim behavioral qualification. ## Risks - Agents may misread prompt guidance. The API still enforces ownership; the text grants no new authority. - The exception is specific to the current issue and run. It does not remove ordinary legacy completion writes or authorize another task. - Activity measures successful checkout HTTP calls. Failed attempts require separate run-log inspection. Missing or mismatched observations cannot count as zero calls. - One trial per profile cannot establish general reliability, speed or cost trends. ## Model Used OpenAI Codex, GPT-6 family. The exact model build and context window are not exposed in this session. Used code editing, shell tools and test execution. ## 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 - [ ] 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 - [ ] All Paperclip CI gates are green - [ ] 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>
252 lines
16 KiB
TypeScript
252 lines
16 KiB
TypeScript
import { observeCheckoutActivity } from "./checkout-activity.js";
|
|
import type { Page } from "@playwright/test";
|
|
import { randomUUID } from "node:crypto";
|
|
import { contextIntegrityScenario, isAssignedSkillContext } from "./context-integrity-cases.js";
|
|
import { gradeContextIntegrity, type ContextIntegrityCheckpoint } from "./context-integrity-scoring.js";
|
|
import { pollUntil, type RunnerApi } from "./api.js";
|
|
import { createTaskThroughUi } from "./user-actions.js";
|
|
import { release as releaseContextCommentGate, waitUntilHeld } from "./context-comment-gate.js";
|
|
import { captureLoadedContinuation } from "./continuation-screenshot.js";
|
|
import { emitContextIntegrityFinalEvidence } from "./context-integrity-evidence.js";
|
|
import type { LiveFixtureValues } from "./live-fixtures.js";
|
|
import type { MatrixExecution } from "./types.js";
|
|
|
|
type Row = Record<string, any>;
|
|
|
|
export type ContextIntegrityRunLogEvidence =
|
|
| { status: "available"; content: unknown }
|
|
| { status: "unavailable"; reason: "not_found"; statusCode: 404 };
|
|
|
|
/** A run can be visible before its log file exists; only that 404 is optional. */
|
|
export async function readContextIntegrityRunLog(
|
|
api: Pick<RunnerApi, "request">,
|
|
runId: string,
|
|
): Promise<ContextIntegrityRunLogEvidence> {
|
|
const response = await api.request.get(
|
|
`/api/heartbeat-runs/${runId}/log?limitBytes=1048576`,
|
|
);
|
|
if (response.status() === 404) {
|
|
return { status: "unavailable", reason: "not_found", statusCode: 404 };
|
|
}
|
|
if (!response.ok()) {
|
|
throw new Error(`Run ${runId} log returned ${response.status()}`);
|
|
}
|
|
return { status: "available", content: await response.json() };
|
|
}
|
|
|
|
|
|
function containsSkillReference(value: unknown, runtimeName: string): boolean {
|
|
if (typeof value === "string") {
|
|
return value === runtimeName || value === `/${runtimeName}` || value === `$${runtimeName}` ||
|
|
value.includes(`/${runtimeName}/SKILL.md`);
|
|
}
|
|
if (Array.isArray(value)) return value.some((entry) => containsSkillReference(entry, runtimeName));
|
|
if (!value || typeof value !== "object") return false;
|
|
return Object.entries(value).some(([key, entry]) =>
|
|
/^(skill|skillInput|skillInputs|skillName|toolCall|toolInput|input|arguments|params|path|filePath|name|resource|resources)$/i.test(key) &&
|
|
containsSkillReference(entry, runtimeName),
|
|
);
|
|
}
|
|
|
|
function containsExplicitSkillInput(value: unknown, runtimeName: string): boolean {
|
|
if (!value || typeof value !== "object") return false;
|
|
if (Array.isArray(value)) return value.some((entry) => containsExplicitSkillInput(entry, runtimeName));
|
|
const row = value as Record<string, unknown>;
|
|
const eventKind = [row.eventType, row.kind, row.type, row.name]
|
|
.filter((entry): entry is string => typeof entry === "string")
|
|
.map((entry) => entry.toLowerCase());
|
|
const nested = [row.payload, row.data, row.event, row.item, row.prpEvent]
|
|
.some((entry) => containsExplicitSkillInput(entry, runtimeName));
|
|
const isSkillEvent = eventKind.some((kind) => /skill|resource_loaded/.test(kind));
|
|
const isReadOrToolEvent = eventKind.some((kind) =>
|
|
/tool_call|tool_started|tool_completed|file_read|file_read_completed/.test(kind),
|
|
);
|
|
return nested ||
|
|
(isSkillEvent && containsSkillReference(row, runtimeName)) ||
|
|
(isReadOrToolEvent && containsSkillReference({
|
|
input: row.input,
|
|
arguments: row.arguments,
|
|
params: row.params,
|
|
toolCall: row.toolCall,
|
|
toolInput: row.toolInput,
|
|
path: row.path,
|
|
filePath: row.filePath,
|
|
name: row.name,
|
|
}, runtimeName));
|
|
}
|
|
|
|
export async function runContextIntegrityFlow(input: {
|
|
page: Page;
|
|
api: RunnerApi;
|
|
fixtures: LiveFixtureValues;
|
|
execution: MatrixExecution;
|
|
nonce: string;
|
|
deadlineAt: number;
|
|
capture(id: string, label: string, file: string): Promise<void>;
|
|
evidence(name: string, value: unknown): Promise<void>;
|
|
observe(issue: Row, runs: Row[], checks: ReturnType<typeof gradeContextIntegrity>): void;
|
|
}) {
|
|
const { page, api, fixtures, execution, nonce } = input;
|
|
const scenario = contextIntegrityScenario(execution.task.id, nonce) as ReturnType<typeof contextIntegrityScenario> & {
|
|
assignedSkill?: { key: string; runtimeName: string; markdown: string };
|
|
};
|
|
const companyPath = `/api/companies/${fixtures.company.id}`;
|
|
const budgetGuard = {
|
|
companyMonthlyCents: 1_000,
|
|
agentMonthlyCents: 1_000,
|
|
currency: "USD",
|
|
hardStop: true,
|
|
} as const;
|
|
let issue: Row | undefined;
|
|
let runs: Row[] = [];
|
|
let skillRequestText = "";
|
|
let assignedVersionId: string | null = null;
|
|
const checkpoints: ContextIntegrityCheckpoint[] = [];
|
|
|
|
if (isAssignedSkillContext(scenario.id)) {
|
|
await api.patch("/api/instance/settings/experimental", { enableBetaSkills: true });
|
|
const markdown = `---\nname: ${scenario.skillKey}\ndescription: Context integrity output procedure.\n---\n\n# Context integrity output procedure\n\nWrite exactly one task document whose body contains the marker ${scenario.marker}. Finish the task after saving that document.`;
|
|
if (!markdown.startsWith(`---\nname: ${scenario.skillKey}\ndescription:`) || markdown.includes("\\n")) {
|
|
throw new Error("Context-integrity skill payload must contain real frontmatter newlines");
|
|
}
|
|
const skill = await api.post<Row>(`${companyPath}/skills`, {
|
|
name: scenario.skillName,
|
|
slug: scenario.skillKey,
|
|
description: "A bounded context-integrity output procedure.",
|
|
markdown,
|
|
idempotencyKey: `context-integrity-${nonce}`,
|
|
});
|
|
await api.post(`/api/agents/${fixtures.agent.id}/skills/sync?companyId=${fixtures.company.id}`, {
|
|
desiredSkills: [{ key: skill.key ?? skill.slug ?? scenario.skillKey, versionId: skill.currentVersionId ?? skill.versionId ?? null }],
|
|
mode: "add",
|
|
});
|
|
const assigned = await api.get<Row>(`/api/agents/${fixtures.agent.id}/skills?companyId=${fixtures.company.id}`);
|
|
const desired = (assigned.desiredSkillEntries as Array<Row> | undefined)?.find((entry) => entry.key === (skill.key ?? skill.slug ?? scenario.skillKey));
|
|
if (!desired?.versionId) throw new Error("Context-integrity skill was not assigned with a pinned version");
|
|
if (String(desired.versionId) !== String(skill.currentVersionId ?? skill.versionId ?? "")) throw new Error("Context-integrity skill assignment does not match the created current version");
|
|
assignedVersionId = String(desired.versionId);
|
|
scenario.assignedSkill = { key: String(skill.key ?? skill.slug ?? scenario.skillKey), runtimeName: String(skill.slug ?? scenario.skillKey), markdown };
|
|
}
|
|
|
|
async function refresh() {
|
|
if (!issue) return;
|
|
issue = await api.get<Row>(`/api/issues/${issue.id}`);
|
|
const listed = await api.get<Row[]>(`${companyPath}/heartbeat-runs?limit=100`);
|
|
const detailed = await Promise.all(listed.map((run) => api.get<Row>(`/api/heartbeat-runs/${run.id}`)));
|
|
runs = detailed.filter((run) => run.issueId === issue!.id || run.nativeIssueId === issue!.id || run.contextSnapshot?.issueId === issue!.id || run.contextSnapshot?.taskId === issue!.id);
|
|
}
|
|
async function snapshot(phase: ContextIntegrityCheckpoint["phase"]) {
|
|
await refresh();
|
|
const [comments, documents, skillRows, queuedComments, assignedState] = await Promise.all([
|
|
api.get<Row[]>(`/api/issues/${issue!.id}/comments?order=asc`),
|
|
api.get<Row[]>(`/api/issues/${issue!.id}/documents`),
|
|
api.get<Row[]>(`${companyPath}/skills`),
|
|
api.get<Row>(`/api/issues/${issue!.id}/queued-comments`),
|
|
isAssignedSkillContext(scenario.id) ? api.get<Row>(`/api/agents/${fixtures.agent.id}/skills?companyId=${fixtures.company.id}`) : Promise.resolve({} as Row),
|
|
]);
|
|
const detailedDocuments = await Promise.all(documents.map((document) => api.get<Row>(`/api/issues/${issue!.id}/documents/${encodeURIComponent(String(document.key))}`)));
|
|
const assignedSkill = isAssignedSkillContext(scenario.id)
|
|
? skillRows.find((skill) => skill.key === scenario.assignedSkill?.key || skill.slug === scenario.assignedSkill?.key)
|
|
: undefined;
|
|
const desiredSkill = (assignedState.desiredSkillEntries as Array<Row> | undefined)?.find((entry) => entry.key === scenario.assignedSkill?.key);
|
|
const runEvents = await Promise.all(runs.map((run) => api.get<Row[]>(`/api/heartbeat-runs/${run.id}/events?limit=1000`)));
|
|
const runLogs = await Promise.all(runs.map((run) => readContextIntegrityRunLog(api, run.id)));
|
|
const skillInvocationEvidence = isAssignedSkillContext(scenario.id) && runEvents.some((events) => events.some((event) => containsExplicitSkillInput(event, String(assignedSkill?.slug ?? scenario.skillKey))));
|
|
skillRequestText = isAssignedSkillContext(scenario.id) ? String(issue?.description ?? "") : "";
|
|
checkpoints.push({ phase, issue: { id: issue!.id, status: String(issue!.status), identifier: String(issue!.identifier ?? ""), issuePrefix: String(fixtures.company.issuePrefix ?? ""), appOrigin: new URL(page.url()).origin }, comments, queuedComments, documents: detailedDocuments as Array<{ key: string; body?: string | null }>, runs, runEvents: runEvents.flat(), runLogs, assignedSkill: assignedSkill ? { key: String(desiredSkill?.key ?? assignedSkill.key ?? assignedSkill.slug), runtimeName: String(assignedSkill.slug ?? ""), versionId: desiredSkill?.versionId === assignedVersionId ? assignedVersionId : null, markdown: String(assignedSkill.markdown ?? scenario.assignedSkill?.markdown ?? "") } : undefined, skillRequestText: isAssignedSkillContext(scenario.id) ? skillRequestText : undefined, skillInvocationEvidence });
|
|
const checks = gradeContextIntegrity({ id: scenario.id, marker: scenario.marker, comments: scenario.comments, checkpoints });
|
|
input.observe(issue!, runs, checks);
|
|
await input.evidence("context-integrity.json", { schema: "paperclip.context-integrity.v1", scenario, budgetGuard, checkpoints, checks });
|
|
}
|
|
async function settle(before: Set<string>) {
|
|
await pollUntil({
|
|
label: `context-integrity ${scenario.id} settled`,
|
|
deadlineAt: input.deadlineAt,
|
|
load: async () => { await refresh(); return { issue, runs }; },
|
|
accept: (state) => state.runs.some((run) => !before.has(run.id) && ["succeeded", "failed", "timed_out", "cancelled"].includes(run.status)) && state.runs.filter((run) => !before.has(run.id)).every((run) => ["succeeded", "failed", "timed_out", "cancelled"].includes(run.status)),
|
|
reject: (state) => state.runs.some((run) => !before.has(run.id) && ["failed", "timed_out", "cancelled"].includes(run.status)) ? "provider run failed" : undefined,
|
|
intervalMs: 1_000,
|
|
});
|
|
}
|
|
try {
|
|
await api.patch("/api/instance/settings/experimental", { enableClassicTaskInterface: false });
|
|
await api.patch(`/api/companies/${fixtures.company.id}/budgets`, {
|
|
budgetMonthlyCents: budgetGuard.companyMonthlyCents,
|
|
});
|
|
await api.patch(`/api/agents/${fixtures.agent.id}/budgets`, {
|
|
budgetMonthlyCents: budgetGuard.agentMonthlyCents,
|
|
});
|
|
const taskPrompt = isAssignedSkillContext(scenario.id)
|
|
? `${scenario.prompt}\n\nUse /${scenario.skillKey} for this request.`
|
|
: scenario.prompt;
|
|
skillRequestText = taskPrompt;
|
|
const createdTask = await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt: taskPrompt, workMode: "standard" });
|
|
issue = await pollUntil({ label: "context-integrity task created", deadlineAt: input.deadlineAt, load: async () => (await api.get<Row[]>(`${companyPath}/issues?limit=100`)).find((row) => row.id === createdTask.issueId), accept: Boolean });
|
|
if (!issue) throw new Error("Missing context-integrity task");
|
|
if (scenario.id === "ordered-comment-continuation") {
|
|
await pollUntil({
|
|
label: "context-integrity initial run started",
|
|
deadlineAt: input.deadlineAt,
|
|
load: async () => { await refresh(); return runs; },
|
|
accept: (currentRuns) => currentRuns.some((run) => run.status === "running" && (run.issueId === issue!.id || run.nativeIssueId === issue!.id || run.contextSnapshot?.issueId === issue!.id || run.contextSnapshot?.taskId === issue!.id)),
|
|
});
|
|
await waitUntilHeld(issue.id, input.deadlineAt);
|
|
let gateReleased = false;
|
|
try {
|
|
await snapshot("initial");
|
|
const initialCheckpoint = checkpoints.at(-1);
|
|
if (!initialCheckpoint?.runs.some((run) => run.status === "running") || initialCheckpoint.documents.length === 0) {
|
|
throw new Error("Context-integrity gate did not expose a running initial run with a committed document");
|
|
}
|
|
const initialRunIds = new Set(runs.map((run) => run.id));
|
|
for (let index = 0; index < scenario.comments.length; index += 1) {
|
|
await api.post(`/api/issues/${issue.id}/comments`, { body: scenario.comments[index], clientRequestId: randomUUID() });
|
|
}
|
|
await snapshot("comment-3");
|
|
const queuedEntries = checkpoints.at(-1)?.queuedComments?.entries;
|
|
const queuedRows = Array.isArray(queuedEntries) ? queuedEntries.map((entry) => {
|
|
const comment = (entry as Row).comment as Row | undefined;
|
|
return { id: String(comment?.id ?? ""), body: String(comment?.body ?? "") };
|
|
}) : [];
|
|
if (queuedRows.length < scenario.comments.length || scenario.comments.some((body, index) => queuedRows[index]?.body !== body) || queuedRows.slice(0, scenario.comments.length).some((row) => !row.id) || new Set(queuedRows.slice(0, scenario.comments.length).map((row) => row.id)).size !== scenario.comments.length) {
|
|
throw new Error("Context-integrity comment gate observed an incomplete or reordered deferred queue");
|
|
}
|
|
await releaseContextCommentGate(issue.id);
|
|
gateReleased = true;
|
|
await settle(initialRunIds);
|
|
} finally {
|
|
if (!gateReleased) await releaseContextCommentGate(issue.id);
|
|
}
|
|
} else {
|
|
await settle(new Set());
|
|
await snapshot("initial");
|
|
}
|
|
await snapshot("final");
|
|
if (execution.suite.id === "stock-harness" && execution.profile.generation === "legacy") {
|
|
// Successful checkout HTTP calls emit activity even for an idempotent
|
|
// same-run claim. Server pre-dispatch checkout does not use that route.
|
|
// Keep this observation separate from the unchanged task-outcome oracle.
|
|
let checkoutActivity: unknown;
|
|
try {
|
|
checkoutActivity = observeCheckoutActivity({
|
|
companyId: fixtures.company.id, issueId: issue.id, agentId: fixtures.agent.id,
|
|
runs, activity: await api.get<Row[]>(`/api/issues/${issue.id}/activity`),
|
|
});
|
|
} catch {
|
|
checkoutActivity = { schema: "paperclip.checkout-activity.v1", status: "unavailable" };
|
|
}
|
|
await input.evidence("checkout-activity.json", checkoutActivity);
|
|
}
|
|
const checks = gradeContextIntegrity({ id: scenario.id, marker: scenario.marker, comments: scenario.comments, checkpoints });
|
|
const failures = checks.filter((check) => !check.passed);
|
|
if (failures.length) throw new Error(`Context-integrity matcher failures: ${failures.map((failure) => `${failure.id}: ${failure.detail}`).join("; ")}`);
|
|
await page.goto(`/${fixtures.company.issuePrefix}/issues/${issue.identifier ?? issue.id}`, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
|
await captureLoadedContinuation(page, String(issue.title), () => emitContextIntegrityFinalEvidence({ evidence: input.evidence, capture: input.capture, issue, runs, checkpoints, checks, runEvents: checkpoints.at(-1)?.runEvents, runLogs: checkpoints.at(-1)?.runLogs }));
|
|
return { issue, runs, checks };
|
|
} finally {
|
|
const checks = gradeContextIntegrity({ id: scenario.id, marker: scenario.marker, comments: scenario.comments, checkpoints });
|
|
if (issue) input.observe(issue, runs, checks);
|
|
await input.evidence("context-integrity.json", { schema: "paperclip.context-integrity.v1", scenario, budgetGuard, checkpoints, checks });
|
|
}
|
|
}
|