mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 16:58:52 +01:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Product E2E checks the ordinary onboarding conversation and its delivered work. > - A native run can finish successfully while its task remains blocked for a human. > - The onboarding journey kept waiting for such a child until the campaign deadline. > - This pull request reports the committed child blocker when no owned work remains pending. > - The journey still requires the same independent checks before it can pass. ## Linked Issues or Issue Description **What happened?** An approved child can stop with a committed current-track blocker owned by the board. Its native run succeeds, but the child task stays blocked. The onboarding journey reports a deadline failure instead of the observed child blocker. **Expected behavior** Report `onboarding_child_task_blocked` when the latest child run commits that blocker at the child's current status version. Keep waiting for parent dependencies and pending native or workspace recovery. **Steps to reproduce** Use the existing onboarding fixture with an approved blocked child. Give its latest native run a succeeded status, committed finalization, blocked work disposition, and the matching task status version. The old journey waits until its deadline. The new journey fails at that settled blocker without downloading or accepting any delivery. Related public context: #18298. Public issue and PR searches found no duplicate blocked-child harness fix. This change reports the failure boundary; it does not repair artifact delivery. ## What Changed - Check the owned child's board blocker after the existing run and recovery checks. - Require a unique latest producer, native committed results, and the current task status version. - Preserve parent dependency waits and all artifact, document, download, link, scope, and budget checks. - Add a behavioral regression and controls for active work, native recovery, workspace replacement, stale versions, newer producers, and agent-owned blockers. - Document the distinct failure code and the existing control-plane writers for its metadata. ## Verification - The new behavioral regression failed on the old code with `onboarding_first_task_deadline`; 147 controls passed. - The current head passed all 148 onboarding tests, including the valid agent-owner object control. - The initial implementation passed 305 tests across all 10 Product E2E files. The later update changes only writer links and the owner control; its 148 focused tests and harness typecheck also pass. - `pnpm test:e2e:runner:typecheck` passed after building the isolated checkout's workspace declarations. - `git diff --check` passed. - All required CI checks passed on `1b3bd04d193fc98d0d9f2eb3b826a4ef4bdb6dea`, including full tests and build. They were not repeated locally. Greptile reviewed that exact head at 5/5. - The full Cloud journey remains unverified by these source and CI checks. This change does not claim that the underlying artifact delivery issue is fixed. ## Risks The early failure depends on the public task and run projections. Missing, stale, or ambiguous producer metadata keeps the existing bounded wait. A pending recovery or newer producer prevents the old blocker from ending the journey. No success requirement changes. ## Model Used OpenAI Codex, exact model ID `gpt-6.1-sol`, reasoning effort `ultra`. The model used source inspection, code editing, and local test tools. The runtime did not expose its context window size. Root supplied the model and effort metadata for this agent session. ## 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>
286 lines
21 KiB
TypeScript
286 lines
21 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { ONBOARDING_FIRST_TASK_ORIGIN_KIND } from "../../../packages/shared/src/constants.js";
|
|
import { type createConnectionProof, verifyConnectionArtifact, verifyConnectionRun } from "../connection-evidence.js";
|
|
import { connectionDelay } from "../connection-target.js";
|
|
import { activeRuns, firstTaskCompletionSettled, type Row } from "../first-task-scoring.js";
|
|
import { isBlockedUnstartedWake, isTerminalUnstartedWake } from "../non-execution-wake.js";
|
|
import { JourneyError, type JourneyContext, type OnboardingArtifactEvidence, type TargetSession } from "./types.js";
|
|
import { retainOnboardingArtifact } from "./artifact-evidence.js";
|
|
import { preAcceptanceTaskStatus, preAcceptanceViolations, type OnboardingPreAcceptanceEvidence } from "./onboarding-pre-acceptance.js";
|
|
import { isPendingNativeRecovery, isProviderUnstartedWorkspaceDeferral } from "./workspace-deferral.js";
|
|
|
|
export interface OnboardingFirstTaskInput {
|
|
context: JourneyContext;
|
|
agent: { id: string };
|
|
connectionId: string;
|
|
proof: ReturnType<typeof createConnectionProof>;
|
|
campaignId: string;
|
|
maxRuns: number;
|
|
deadlineAt: number;
|
|
own: TargetSession["own"];
|
|
observeRun(run: Row): Promise<void>;
|
|
onArtifact?(artifact: OnboardingArtifactEvidence): void;
|
|
onPreAcceptanceFailure?(evidence: OnboardingPreAcceptanceEvidence): void;
|
|
}
|
|
export interface OnboardingFirstTaskResult {
|
|
runIds: string[];
|
|
onboardingIssueId: string;
|
|
childIssueId: string;
|
|
artifact: OnboardingArtifactEvidence;
|
|
}
|
|
interface Snapshot {
|
|
tasks: Row[];
|
|
runs: Row[];
|
|
interactions: Row[];
|
|
comments: Row[];
|
|
documents: Row[];
|
|
attachments: Row[];
|
|
}
|
|
|
|
function fail(code: string): never { throw new JourneyError("failed", code); }
|
|
const runIssueId = (run: Row) => run.nativeIssueId ?? run.issueId ?? run.contextSnapshot?.issueId;
|
|
const isProposal = (row: Row) => ["request_confirmation", "request_checkbox_confirmation"].includes(row.kind);
|
|
const isPlanningDocument = (row: Row) => /^(?:plan|proposal)$/.test(row.key);
|
|
|
|
function sameWorkspaceRetry(source: Row, successor: Row): boolean {
|
|
const issueIds = (run: Row) => [run.nativeIssueId, run.issueId, run.contextSnapshot?.issueId].filter(value => value != null);
|
|
const sourceIssues = issueIds(source), successorIssues = issueIds(successor);
|
|
return source.id !== successor.id && source.companyId === successor.companyId && source.agentId === successor.agentId &&
|
|
sourceIssues.length > 0 && successorIssues.length > 0 && [...sourceIssues, ...successorIssues].every(id => id === sourceIssues[0]) &&
|
|
successor.retryOfRunId === source.id && successor.contextSnapshot?.retryOfRunId === source.id &&
|
|
successor.scheduledRetryReason === "workspace_busy" && successor.contextSnapshot?.retryReason === "workspace_busy" &&
|
|
successor.contextSnapshot?.wakeReason === "workspace_busy_retry" &&
|
|
Number.isSafeInteger(successor.scheduledRetryAttempt) &&
|
|
successor.scheduledRetryAttempt === (source.scheduledRetryReason === "workspace_busy" ? source.scheduledRetryAttempt ?? 0 : 0) + 1;
|
|
}
|
|
|
|
function unstartedScheduledWorkspaceRetry(run: Row): boolean {
|
|
return run.status === "scheduled_retry" && run.startedAt === null && run.finishedAt === null &&
|
|
Number.isFinite(Date.parse(run.scheduledRetryAt)) && run.runtimeMode === "legacy" && run.runtimeModeResolvedAt === null &&
|
|
["nativeIssueId", "nativeSessionId", "runnerInstanceId", "processPid", "processStartedAt", "sessionIdAfter"].every(key => run[key] == null) &&
|
|
run.lastOutputSeq === 0 && run.usageJson == null;
|
|
}
|
|
|
|
/** Exercise the ordinary onboarding conversation; APIs observe, never submit its actions. */
|
|
export async function onboardingFirstTask(input: OnboardingFirstTaskInput): Promise<OnboardingFirstTaskResult> {
|
|
const { context: { api, page, company }, agent, campaignId, proof } = input;
|
|
const isPendingRun = (run: Row) => activeRuns([run]).length > 0 || run.status === "scheduled_retry" ||
|
|
isPendingNativeRecovery(run, input.deadlineAt);
|
|
if (!Number.isSafeInteger(input.maxRuns) || input.maxRuns < 1) fail("invalid_onboarding_run_limit");
|
|
const title = `Artifact ${campaignId}`;
|
|
const seen = new Map<string, Row>();
|
|
const observed = new Map<string, string>();
|
|
const remaining = () => {
|
|
const ms = input.deadlineAt - Date.now();
|
|
if (ms <= 0) fail("onboarding_first_task_deadline");
|
|
return Math.min(ms, 30_000);
|
|
};
|
|
const wait = async () => { await connectionDelay(Math.min(1000, remaining())); };
|
|
const load = async (parentId?: string): Promise<Snapshot> => {
|
|
remaining();
|
|
const [tasks, rows] = await Promise.all([
|
|
api.get<Row[]>(`/api/companies/${company.id}/issues?limit=100&includeAgentCreatedTasks=true`),
|
|
api.get<Row[]>(`/api/companies/${company.id}/heartbeat-runs?limit=1000`),
|
|
]);
|
|
const runs = await Promise.all(rows.map(row => api.get<Row>(`/api/heartbeat-runs/${row.id}`)));
|
|
for (const run of runs) {
|
|
seen.set(run.id, run);
|
|
const revision = JSON.stringify([run.status, run.startedAt, run.finishedAt, run.usageJson, run.costUsd]);
|
|
if (observed.get(run.id) !== revision) {
|
|
await input.observeRun(run);
|
|
observed.set(run.id, revision);
|
|
}
|
|
}
|
|
if (seen.size > input.maxRuns || rows.length === 1000) fail("campaign_run_limit_exceeded");
|
|
if (tasks.length === 100) fail("onboarding_task_scope_overflow");
|
|
if (!parentId) return { tasks, runs, interactions: [], comments: [], documents: [], attachments: [] };
|
|
const [interactions, comments, documents, attachments] = await Promise.all([
|
|
api.get<Row[]>(`/api/issues/${parentId}/interactions`),
|
|
api.get<Row[]>(`/api/issues/${parentId}/comments?order=asc`),
|
|
Promise.all(tasks.map(async task => {
|
|
const summaries = await api.get<Row[]>(`/api/issues/${task.id}/documents`);
|
|
return Promise.all(summaries.map(async document => ({
|
|
...await api.get<Row>(`/api/issues/${task.id}/documents/${encodeURIComponent(document.key)}`),
|
|
issueId: task.id, key: document.key,
|
|
})));
|
|
})).then(rows => rows.flat()),
|
|
Promise.all(tasks.map(task => api.get<Row[]>(`/api/issues/${task.id}/attachments`))).then(rows => rows.flat()),
|
|
]);
|
|
return { tasks, runs, interactions, comments, documents, attachments };
|
|
};
|
|
const assertRuns = (snapshot: Snapshot, allowedIssues: string[]) => {
|
|
const runs = [...seen.values()];
|
|
let workspacePending = false;
|
|
const successors = new Map<string, Row>();
|
|
for (const source of runs.filter(isProviderUnstartedWorkspaceDeferral)) {
|
|
const linked = runs.filter(run => run.retryOfRunId === source.id || run.contextSnapshot?.retryOfRunId === source.id);
|
|
if (linked.length > 1 || (linked[0] && !sameWorkspaceRetry(source, linked[0]))) fail("onboarding_workspace_retry_lineage_mismatch");
|
|
if (linked[0]) successors.set(source.id, linked[0]);
|
|
else workspacePending = true;
|
|
}
|
|
for (const run of seen.values()) {
|
|
if (run.companyId !== company.id || run.agentId !== agent.id || !allowedIssues.includes(runIssueId(run))) fail("onboarding_run_scope_mismatch");
|
|
if (isProviderUnstartedWorkspaceDeferral(run)) {
|
|
const chain = new Set<string>();
|
|
let tail: Row | undefined = run;
|
|
while (tail && isProviderUnstartedWorkspaceDeferral(tail)) {
|
|
if (chain.has(tail.id)) fail("onboarding_workspace_retry_lineage_mismatch");
|
|
chain.add(tail.id); tail = successors.get(tail.id);
|
|
}
|
|
if (!tail || isPendingRun(tail)) workspacePending = true;
|
|
else if (tail.status !== "succeeded" || tail.runtimeMode !== "native") fail("onboarding_first_task_failed");
|
|
continue;
|
|
}
|
|
if (run.status === "scheduled_retry") {
|
|
const source = seen.get(run.retryOfRunId);
|
|
if (!source || !isProviderUnstartedWorkspaceDeferral(source) || !sameWorkspaceRetry(source, run) || !unstartedScheduledWorkspaceRetry(run)) fail("onboarding_workspace_retry_lineage_mismatch");
|
|
workspacePending = true;
|
|
continue;
|
|
}
|
|
if (isPendingNativeRecovery(run, input.deadlineAt)) { workspacePending = true; continue; }
|
|
if (activeRuns([run]).length || run.status === "succeeded" || isBlockedUnstartedWake(run) || isTerminalUnstartedWake(run, snapshot.tasks)) continue;
|
|
fail("onboarding_first_task_failed");
|
|
}
|
|
return workspacePending;
|
|
};
|
|
const assertBeforeAcceptance = (snapshot: Snapshot, parentId: string, checkpoint: OnboardingPreAcceptanceEvidence["checkpoint"]) => {
|
|
const observed = {
|
|
taskCount: snapshot.tasks.length, attachmentCount: snapshot.attachments.length,
|
|
documentCount: snapshot.documents.length, nonPlanningDocumentCount: snapshot.documents.filter(document => !isPlanningDocument(document)).length,
|
|
firstTaskMatchesCanonicalParent: snapshot.tasks[0]?.id === parentId,
|
|
firstTaskStatus: preAcceptanceTaskStatus(snapshot.tasks[0]),
|
|
canonicalParentStatus: preAcceptanceTaskStatus(snapshot.tasks.find(task => task.id === parentId)),
|
|
};
|
|
const violations = preAcceptanceViolations(observed);
|
|
if (violations.length) {
|
|
input.onPreAcceptanceFailure?.({ version: 1, checkpoint, violations, ...observed });
|
|
fail("onboarding_work_before_acceptance");
|
|
}
|
|
return assertRuns(snapshot, [parentId]);
|
|
};
|
|
|
|
let snapshot = await load();
|
|
while (!snapshot.tasks.length) { await wait(); snapshot = await load(); }
|
|
const parent = snapshot.tasks[0]!;
|
|
if (snapshot.tasks.length !== 1 || parent.originKind !== ONBOARDING_FIRST_TASK_ORIGIN_KIND ||
|
|
parent.assigneeAgentId !== agent.id || !String(parent.description).includes("/first-task")) fail("onboarding_canonical_first_task_missing");
|
|
await input.own({ kind: "issue", name: parent.title, id: parent.id });
|
|
snapshot = await load(parent.id);
|
|
if (seen.size) fail("onboarding_opening_started_model_work");
|
|
assertBeforeAcceptance(snapshot, parent.id, "before-opening-answer");
|
|
const opening = snapshot.interactions.filter(row => row.kind === "ask_user_questions" && row.status === "pending");
|
|
const questions = opening[0]?.payload?.questionSet?.questions ?? opening[0]?.payload?.questions ?? [];
|
|
const question = questions.find((row: Row) => row.id === "first-task-opening");
|
|
const option = question?.options?.find((row: Row) => row.id === "task" && row.freeText === true);
|
|
if (opening.length !== 1 || questions.length !== 1 || !option ||
|
|
!snapshot.comments.some(row => row.authorAgentId === agent.id && String(row.body ?? "").trim())) fail("onboarding_opening_not_presented");
|
|
await page.goto(`${api.origin}/${company.issuePrefix}/issues/${parent.identifier ?? parent.id}`, { timeout: remaining() });
|
|
// Persist the exact intended child before the opening reply can dispatch any model work.
|
|
await input.own({ kind: "issue", name: title });
|
|
await page.getByRole("radio", { name: option.label }).last().click({ timeout: remaining() });
|
|
const childBrief = [
|
|
"Complete this child's approved scope in this order:",
|
|
"1. Decode the following base64 into connection-input.json in its workspace, using actual tools.",
|
|
`Exact input bytes: ${Buffer.from(proof.input).toString("base64")}`,
|
|
'2. Write connection-proof.json containing exactly {"nonce":string,"count":number,"total":number,"sha256":string}. Copy nonce, count the input values, sum them, and compute sha256 of the exact input bytes including the final newline.',
|
|
'3. Publish connection-proof.json on this child using register_deliverable with filename "connection-proof.json", workspace-relative contentRef "connection-proof.json", and correct byteSize and SHA-256 for that file.',
|
|
"4. Use write_document to save a connection-proof document on this child including the filename connection-proof.json, input nonce, result, and the file link returned by register_deliverable. A Markdown workspace note is not a Paperclip task document and does not replace this step.",
|
|
"5. Report completion with paperclip_finish using the current completion contract. Include verification for checks actually run and omit optional schema. Use deliverable:<attachmentId> from register_deliverable in evidence and any file artifacts. Keep local input paths in verification details, not completion evidence or artifacts. Correct rejected arguments using the tool feedback and retry before declaring a blocker.",
|
|
"Do not hire agents, create additional tasks, or access credentials or unrelated files.",
|
|
].join("\n");
|
|
await page.getByTestId("question-other-answer-composer").last().locator('[contenteditable="true"],textarea').first().fill([
|
|
`I have one task in mind. Propose exactly one child task named "${title}" and wait for my approval before creating it or doing its work.`,
|
|
'Save that proposal with request_human_input using interactionKind "confirmation" (or "checkbox" bound to the saved plan revision when plan mode applies) and continuationPolicy "wake_assignee". Check that the tool returned a saved pending card. Correct rejected arguments from its feedback; a failed call is not a pending proposal.',
|
|
"Do not resolve, accept, or record approval yourself. This opening answer supplies scope, not approval. Wait for my actual accepted card decision before calling create_task; a rejection does not authorize the work.",
|
|
"After approval, assign it to yourself and let its own Paperclip execution produce the result. After it finishes, verify its saved document and published attachment.",
|
|
"In your final reply in this onboarding conversation, include actual Markdown links to the child task, its saved document, and connection-proof.json. Copy the published attachment's returned contentPath or downloadPath into the file link URL, including its actual attachment ID. A backticked filename, document key, or path is not a clickable link. Obtain the real references from the child's saved results; do not invent IDs or URLs. Then complete both tasks.",
|
|
"When you create the child, copy the entire brief between BEGIN CHILD BRIEF and END CHILD BRIEF VERBATIM into create_task.description. Preserve every step, tool name, parameter instruction, and exact encoded input. Do not summarize or paraphrase it.",
|
|
"BEGIN CHILD BRIEF",
|
|
childBrief,
|
|
"END CHILD BRIEF",
|
|
].join("\n"), { timeout: remaining() });
|
|
await page.getByRole("button", { name: opening[0]!.payload.submitLabel ?? "Continue", exact: true }).last().click({ timeout: remaining() });
|
|
|
|
let proposal: Row | undefined;
|
|
while (!proposal) {
|
|
snapshot = await load(parent.id);
|
|
const workspacePending = assertBeforeAcceptance(snapshot, parent.id, "awaiting-proposal");
|
|
const pending = snapshot.interactions.filter(row => row.status === "pending");
|
|
const proposals = pending.filter(isProposal);
|
|
if (proposals.length > 1 || pending.some(row => row.kind === "ask_user_questions" && row.id !== opening[0]!.id)) fail("onboarding_proposal_scope_unclear");
|
|
// Let the producing turn finish before acceptance; this journey does not test overlap.
|
|
if (proposals.length === 1 && seen.size && !workspacePending && ![...seen.values()].some(isPendingRun)) proposal = proposals[0];
|
|
else await wait();
|
|
}
|
|
const target = proposal.payload?.target;
|
|
const plan = target?.type === "issue_document" ? snapshot.documents.find(document => document.issueId === parent.id && document.key === target.key &&
|
|
(!target.issueId || target.issueId === parent.id) && document.latestRevisionId === target.revisionId &&
|
|
(!target.documentId || target.documentId === document.id)) : undefined;
|
|
if ((proposal.kind === "request_checkbox_confirmation" && (!plan || !isPlanningDocument(plan))) ||
|
|
![proposal.payload?.prompt, proposal.payload?.detailsMarkdown, plan?.body].some(text => typeof text === "string" && text.includes(title))) fail("onboarding_proposal_child_title_mismatch");
|
|
if (proposal.kind === "request_checkbox_confirmation") {
|
|
if (!proposal.payload?.options?.length) fail("onboarding_proposal_scope_unclear");
|
|
for (const item of proposal.payload.options) await page.getByRole("checkbox", { name: item.label, exact: true }).last().check({ timeout: remaining() });
|
|
}
|
|
await page.getByRole("button", { name: proposal.payload?.acceptLabel ?? (proposal.kind === "request_confirmation" ? "Approve" : "Confirm selection"), exact: true }).last().click({ timeout: remaining() });
|
|
for (;;) {
|
|
snapshot = await load(parent.id);
|
|
const status = snapshot.interactions.find(row => row.id === proposal!.id)?.status;
|
|
if (status === "accepted") break;
|
|
if (status !== "pending") fail("onboarding_proposal_not_accepted");
|
|
await wait();
|
|
}
|
|
|
|
let childId: string | undefined;
|
|
for (;;) {
|
|
const currentParent = snapshot.tasks.find(row => row.id === parent.id);
|
|
const children = snapshot.tasks.filter(row => row.id !== parent.id);
|
|
if (!currentParent || children.length > 1 || children.some(row => row.parentId !== parent.id || row.title !== title || row.assigneeAgentId !== agent.id)) fail("onboarding_approved_child_mismatch");
|
|
const child = children[0];
|
|
if (currentParent.status === "cancelled" || child?.status === "cancelled") fail("onboarding_first_task_failed");
|
|
if (!child && currentParent.status === "done") fail("onboarding_completed_without_child");
|
|
if (child && !childId) { childId = child.id; await input.own({ kind: "issue", name: title, id: child.id }); }
|
|
if (childId && child?.id !== childId) fail("onboarding_approved_child_mismatch");
|
|
const workspacePending = assertRuns(snapshot, [parent.id, ...(childId ? [childId] : [])]);
|
|
if (snapshot.attachments.some(row => row.issueId !== childId)) fail("onboarding_artifact_created_by_parent");
|
|
const pending = workspacePending || [...seen.values()].some(isPendingRun);
|
|
if (child?.status === "blocked" && child.unblockDescriptor?.owner === "board" && !pending) {
|
|
const childRuns = [...seen.values()].filter(run => runIssueId(run) === child.id)
|
|
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
|
const latest = childRuns[0];
|
|
const createdAt = latest && typeof latest.createdAt === "string" ? Date.parse(latest.createdAt) : NaN;
|
|
const result = latest?.resultJson;
|
|
if (Number.isFinite(createdAt) && childRuns.slice(1).every(run => Date.parse(run.createdAt) < createdAt) &&
|
|
latest?.runtimeMode === "native" && latest.nativeIssueId === child.id && latest.contextSnapshot?.issueId === child.id &&
|
|
latest.status === "succeeded" && latest.nativePhase === "committed" && result?.finalizationPhase === "committed" &&
|
|
result.prpReportedWorkDisposition === "blocked" && result.finalizationReasonCode === "current_track_blocker_waiting" &&
|
|
result.authoritativeDecision === "blocked" && result.issueStatusAfter === "blocked" &&
|
|
Number.isSafeInteger(child.statusVersion) && result.statusVersionAfter === child.statusVersion) fail("onboarding_child_task_blocked");
|
|
}
|
|
const settled = child && currentParent.status === "done" && firstTaskCompletionSettled(snapshot.tasks, [parent.id], parent.id) &&
|
|
!pending;
|
|
if (settled) {
|
|
const artifacts = snapshot.attachments.filter(row => row.issueId === child.id && row.originalFilename === "connection-proof.json");
|
|
if (artifacts.length !== 1) fail("onboarding_child_artifact_missing");
|
|
const artifact = artifacts[0]!;
|
|
const run = seen.get(artifact.originatingRunId);
|
|
if (artifact.createdByAgentId !== agent.id || !input.context.environmentId || !run || run.nativeIssueId !== child.id ||
|
|
run.contextSnapshot?.issueId !== child.id || !verifyConnectionRun(run, { agentId: agent.id, connectionId: input.connectionId, method: "api_key", runtimeMode: "native", environmentId: input.context.environmentId })) fail("artifact_run_attribution_mismatch");
|
|
const bytes = await api.bytes(`/api/attachments/${artifact.id}/content`);
|
|
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
if (artifact.byteSize !== bytes.byteLength || artifact.sha256 !== sha256 || !verifyConnectionArtifact(bytes, proof.expected)) fail("artifact_independent_check_failed");
|
|
const retained = retainOnboardingArtifact({ id: artifact.id, originatingRunId: run.id, bytes, proof });
|
|
input.onArtifact?.(retained);
|
|
if (!snapshot.documents.some(row => row.issueId === child.id && !isPlanningDocument(row) && row.updatedByAgentId === agent.id &&
|
|
typeof row.body === "string" && row.body.includes(proof.expected.nonce) && row.body.includes("connection-proof.json"))) fail("onboarding_child_document_missing");
|
|
if (!snapshot.comments.some(row => row.authorAgentId === agent.id && String(row.body).includes(artifact.id) &&
|
|
(String(row.body).includes(child.id) || String(row.body).includes(child.identifier ?? child.id)))) fail("onboarding_parent_result_links_missing");
|
|
await page.reload({ timeout: remaining() });
|
|
await page.locator(`a[href*="/api/attachments/${artifact.id}/content"]`).first().waitFor({ state: "visible", timeout: remaining() });
|
|
return { runIds: [...seen.keys()], onboardingIssueId: parent.id, childIssueId: child.id,
|
|
artifact: retained };
|
|
}
|
|
await wait(); snapshot = await load(parent.id);
|
|
}
|
|
}
|