Merge current master lifecycle and CI fixes into Claude behavior

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* codex/claude-runner-protocol:
  fix(grok): avoid routine wakes for proactive admission (#16422)
  feat(ci): profile full CODEOWNER PR suites in Honeycomb (#16378)
  test: bound command snapshot forwarding checks (#16421)
  fix(agents): report actionable lifecycle failure steps (#16418)
This commit is contained in:
DottaandPaperclip committed 2026-10-15 07:23:22 -05:00
commit bd3ceab2b2
50 files changed
+1630 -60

No files matched your search

+1
View File
@@ -1,6 +1,7 @@
# Replace @cryppadotta if a different maintainer or team should own release infrastructure.
.github/** @cryppadotta @devinfoley @nickyleach @forgottendev
scripts/ci-profile/** @cryppadotta @devinfoley @nickyleach @forgottendev
scripts/release*.sh @cryppadotta @devinfoley @nickyleach @forgottendev
scripts/release-*.mjs @cryppadotta @devinfoley @nickyleach @forgottendev
scripts/create-github-release.sh @cryppadotta @devinfoley @nickyleach @forgottendev
+240
View File
@@ -0,0 +1,240 @@
import assert from "node:assert/strict";
import test from "node:test";
import { mkdtempSync, mkdirSync, readFileSync, existsSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { execFileSync, spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { buildTrace } from "../../../scripts/ci-profile/trace.mjs";
import { sendEvents } from "../../../scripts/ci-profile/export.mjs";
import { associatedProfilePr, ciOwners, trustedOwnerIds } from "../../../scripts/ci-profile/authorize.mjs";
const at = (seconds) => new Date(1_800_000_000_000 + seconds * 1000).toISOString();
const run = {
id: 42, run_attempt: 1, run_number: 7, status: "completed", conclusion: "failure",
repository: { id: 1170821064, full_name: "paperclipai/paperclip" }, head_repository: { id: 99 },
actor: { id: 123, type: "User" }, triggering_actor: { id: 123, type: "User" },
head_sha: "abc", head_branch: "branch", created_at: at(0), run_started_at: at(0),
html_url: "https://github.com/paperclipai/paperclip/actions/runs/42",
};
const job = (id, name, start, end) => ({
id, run_id: 42, run_attempt: 1, name: `ci / ${name}`, status: "completed",
conclusion: "success", created_at: at(1), started_at: at(start), completed_at: at(end),
labels: ["ubuntu-latest"], steps: [
{ number: 1, name: "Initialize CI profile", started_at: at(start), completed_at: at(start + 1), conclusion: "success" },
{ number: 2, name: "Tests", started_at: at(start + 1), completed_at: at(end), conclusion: "success" },
],
});
const jobs = [job(1, "first", 2, 12), job(2, "second", 3, 20)];
const record = (fields) => ({ version: 1, runId: "42", attempt: "1", invocation: "invocation", job: "first", framework: "vitest", ...fields });
test("one wall-clock trace includes parallel jobs, wait, and every step", () => {
const trace = buildTrace(run, jobs);
assert.equal(trace.wallMs, 20000);
assert.equal(trace.events[0].data.duration_ms, 20000);
assert.equal(trace.events.filter((event) => event.data["ci.kind"] === "job").length, 2);
assert.equal(trace.events.filter((event) => event.data["ci.kind"] === "step").length, 4);
assert.equal(trace.events.filter((event) => event.data["ci.kind"] === "wait").length, 2);
assert.equal(new Set(trace.events.map((event) => event.data["trace.trace_id"])).size, 1);
assert.equal(trace.events.find((event) => event.data.name === "ci / second").data["ci.finishes_last"], true);
assert.equal(trace.events[0].data.error, true);
});
test("file/test hierarchy keeps runner timestamps and rejects unrelated artifact data", () => {
const records = [
record({ kind: "job" }), record({ kind: "job", job: "second" }),
record({ kind: "start" }), record({ kind: "end" }),
record({ kind: "file", id: "file", name: "a.test.ts", file: "a.test.ts", start: Date.parse(at(4)), duration: 6000, status: "passed" }),
record({ kind: "test", id: "case", parent: "file", name: "runs", file: "a.test.ts", start: Date.parse(at(5)), duration: 100, status: "passed", secret: "must not leave artifact" }),
record({ kind: "test", id: "foreign", runId: "41", name: "wrong run" }),
];
const trace = buildTrace(run, jobs, records);
const file = trace.events.find((event) => event.data.name === "a.test.ts");
const entry = trace.events.find((event) => event.data.name === "runs");
assert.equal(entry.time, at(5));
assert.equal(entry.data["trace.parent_id"], file.data["trace.span_id"]);
assert.equal(file.data["trace.parent_id"], trace.events.find((event) => event.data.name === "Tests").data["trace.span_id"]);
assert.equal(trace.events.some((event) => event.data.name === "wrong run"), false);
assert.doesNotMatch(JSON.stringify(trace), /must not leave artifact/);
assert.equal(trace.events[0].data["ci.profile_complete"], false);
assert.match(trace.warnings.join(), /1 invalid/);
});
test("short files belong to the latest containing step despite second-precision boundaries", () => {
const records = [record({ kind: "file", id: "file", name: "short.test.ts", file: "short.test.ts",
start: Date.parse(at(3)) + 100, duration: 50, status: "passed" })];
const trace = buildTrace(run, jobs, records);
const file = trace.events.find((event) => event.data.name === "short.test.ts");
const step = trace.events.find((event) => event.data.name === "Tests");
assert.equal(file.data["trace.parent_id"], step.data["trace.span_id"]);
});
test("cancelled reporters and missing shards remain explicit", () => {
const trace = buildTrace({ ...run, conclusion: "cancelled" }, jobs, [record({ kind: "job" }), record({ kind: "start" })]);
assert.equal(trace.events[0].data["ci.profile_complete"], false);
assert.match(trace.warnings.join(), /Missing timing artifacts for 1 jobs/);
assert.match(trace.warnings.join(), /1 test invocations/);
});
test("a completely absent reporter invocation cannot hide behind a successful sibling", () => {
const records = [
record({ kind: "job" }), record({ kind: "job", job: "second" }),
record({ kind: "expected" }), record({ kind: "start" }), record({ kind: "end" }),
record({ kind: "file", id: "file", name: "a.test.ts", file: "a.test.ts", start: Date.parse(at(4)), duration: 100, status: "passed" }),
record({ kind: "expected", invocation: "reporter-never-loaded" }),
];
const trace = buildTrace(run, jobs, records);
assert.equal(trace.events[0].data["ci.profile_complete"], false);
assert.equal(trace.events[0].data["ci.expected_invocations"], 2);
assert.equal(trace.events[0].data["ci.reported_invocations"], 1);
assert.match(trace.warnings.join(), /1 test invocations/);
});
test("coverage is conservative after failures and does not merge framework invocation IDs", () => {
const records = [
record({ kind: "job" }), record({ kind: "job", job: "second" }),
record({ kind: "expected" }), record({ kind: "start" }), record({ kind: "end" }),
];
assert.equal(buildTrace({ ...run, conclusion: "success" }, jobs, records).events[0].data["ci.profile_complete"], true);
assert.equal(buildTrace(run, jobs, records).events[0].data["ci.profile_complete"], false);
const trace = buildTrace({ ...run, conclusion: "success" }, jobs, [
...records, record({ framework: "node", kind: "expected" }),
]);
assert.equal(trace.events[0].data["ci.profile_complete"], false);
assert.equal(trace.events[0].data["ci.expected_invocations"], 2);
assert.equal(trace.events[0].data["ci.reported_invocations"], 1);
});
test("trusted policy commands work with both current and pre-profile checkouts", () => {
const source = readFileSync(new URL("../../workflows/pr-trusted.yml", import.meta.url), "utf8");
const block = source.split(" - name: Select compatible Node test command\n")[1].split("\n - name:")[0];
const script = block.split(" run: |\n")[1].split("\n").map((line) => line.replace(/^ {10}/, "")).join("\n");
const directory = mkdtempSync(path.join(tmpdir(), "ci-profile-legacy-"));
try {
writeFileSync(path.join(directory, "probe.test.cjs"), 'require("node:test")("old checkout works", () => {});');
for (const current of [false, true]) {
if (current) {
mkdirSync(path.join(directory, "scripts"));
symlinkSync(fileURLToPath(new URL("../../../scripts/ci-profile", import.meta.url)), path.join(directory, "scripts/ci-profile"));
}
const env = { ...process.env, GITHUB_ENV: path.join(directory, "env"), PAPERCLIP_CI_PROFILE: "false" };
delete env.NODE_TEST_CONTEXT;
const result = spawnSync("bash", ["-c", `${script}\nsource "$GITHUB_ENV"\nnode "$CI_NODE_TEST" probe.test.cjs`], {
cwd: directory, env, encoding: "utf8",
});
assert.equal(result.status, 0, result.stdout + result.stderr);
assert.match(readFileSync(env.GITHUB_ENV, "utf8"), current ? /CI_NODE_TEST=scripts\/ci-profile\/node-test.mjs/ : /CI_NODE_TEST=--test/);
}
} finally { rmSync(directory, { recursive: true, force: true }); }
});
test("reruns use separate IDs and clocks and cannot mix job attempts", () => {
const rerun = { ...run, run_attempt: 2, run_started_at: at(2) };
assert.throws(() => buildTrace(rerun, jobs), /another run or attempt/);
const trace = buildTrace(rerun, jobs.map((entry) => ({ ...entry, run_attempt: 2 })));
assert.equal(trace.wallMs, 18000);
assert.notEqual(trace.traceId, buildTrace(run, jobs).traceId);
assert.equal(trace.events[0].data["ci.partial_rerun"], true);
assert.throws(() => buildTrace({ ...run, status: "in_progress" }, jobs), /complete/);
});
test("partial reruns exclude old jobs that GitHub relabels with the new attempt number", () => {
const rerun = { ...run, run_attempt: 2, run_started_at: at(30) };
const inventory = [
{ ...job(1, "carried build", 2, 12), run_attempt: 2 },
{ ...job(3, "retried tests", 32, 50), run_attempt: 2, created_at: at(31) },
];
const trace = buildTrace(rerun, inventory);
assert.equal(trace.wallMs, 20000);
assert.equal(trace.jobCount, 1);
assert.equal(trace.events[0].data["ci.carried_job_count"], 1);
assert.equal(trace.events.some((event) => event.data.name === "ci / carried build"), false);
assert.equal(trace.events[0].data["ci.profile_complete"], false);
assert.match(trace.warnings.join(), /1 completed jobs carried over/);
assert.throws(() => buildTrace(rerun, inventory.slice(0, 1)), /No jobs executed/);
});
test("label alone cannot authorize outsiders, bots, stale revisions or outsider reruns", () => {
const pr = { user: { id: 123, type: "User" }, base: { repo: run.repository }, head: { repo: { id: 99 }, sha: "abc" }, labels: [{ name: "profile-ci" }] };
assert.equal(associatedProfilePr(run, [pr], [123]), pr);
for (const changed of [
{ ...pr, labels: [] },
{ ...pr, head: { ...pr.head, sha: "next" } },
{ ...pr, head: { ...pr.head, repo: { id: 100 } } },
{ ...pr, user: { id: 999, type: "User" } },
{ ...pr, user: { id: 123, type: "Bot" } },
{ ...pr, base: { repo: { ...run.repository, id: 100 } } },
]) assert.equal(associatedProfilePr(run, [changed], [123]), undefined);
assert.equal(associatedProfilePr({ ...run, actor: { id: 999, type: "User" } }, [pr], [123]), undefined);
assert.equal(associatedProfilePr({ ...run, triggering_actor: { id: 999, type: "User" } }, [pr], [123]), undefined);
assert.equal(associatedProfilePr({ ...run, repository: { ...run.repository, id: 100 } }, [pr], [123]), undefined);
assert.equal(associatedProfilePr(run, [pr], []), undefined);
});
test("profiling uses only infrastructure CODEOWNERS resolved at trusted master", () => {
const source = '.github/** @owner @second\ndoc/** @outsider\n';
assert.deepEqual(ciOwners(source), ["owner", "second"]);
for (const invalid of ['', '.github/**', '.github/** @org/team', '.github/** @owner\n/.github/** @other']) {
assert.throws(() => ciOwners(invalid), /CODEOWNERS/);
}
const endpoints = [];
const sha = "a".repeat(40);
assert.deepEqual(trustedOwnerIds((endpoint) => {
endpoints.push(endpoint);
if (endpoint.endsWith('git/ref/heads/master')) return { object: { sha } };
if (endpoint.endsWith(`CODEOWNERS?ref=${sha}`)) return { encoding: 'base64', content: Buffer.from(source).toString('base64') };
return { id: endpoint === '/users/owner' ? 123 : 456, type: 'User' };
}), [123, 456]);
assert.deepEqual(endpoints, [
'/repos/paperclipai/paperclip/git/ref/heads/master',
`/repos/paperclipai/paperclip/contents/.github/CODEOWNERS?ref=${sha}`, '/users/owner', '/users/second',
]);
});
test("export checks per-event ingestion results, including partial failures", async () => {
const events = Array.from({ length: 501 }, () => ({ data: {} }));
const sizes = [];
await sendEvents(events, "test-only", "paperclip-ci", async (url, options) => {
assert.equal(url, "https://api.honeycomb.io/1/batch/paperclip-ci");
assert.equal(options.redirect, "error");
const size = JSON.parse(options.body).length;
sizes.push(size);
return { ok: true, json: async () => Array.from({ length: size }, () => ({ status: 202 })) };
});
assert.deepEqual(sizes, [500, 1]);
await assert.rejects(sendEvents([events[0]], "test-only", "paperclip-ci", async () => ({ ok: true, json: async () => [{ status: 400 }] })), /every span/);
});
test("workflow exporter runs trusted code and keeps keys out of the PR jobs", () => {
const workflow = readFileSync(new URL("../../workflows/ci-profile.yml", import.meta.url), "utf8");
assert.match(workflow, /ref: \$\{\{ github.sha \}\}/);
assert.match(workflow, /startsWith\(github.event.workflow_run.display_title/);
assert.match(workflow, /environment: ci-profiling/);
assert.doesNotMatch(workflow, /head_sha|secrets: inherit|contents: write|pull-requests: write|pnpm install/);
const pr = readFileSync(new URL("../../workflows/pr-trusted.yml", import.meta.url), "utf8");
assert.doesNotMatch(pr, /HONEYCOMB.*KEY|secrets\./);
assert.match(pr, /if: always\(\) && env.PAPERCLIP_CI_PROFILE == 'true'/);
assert.doesNotMatch(pr, /PAPERCLIP_CI_PROFILE:.*labels/);
assert.equal(pr.match(/PAPERCLIP_CI_PROFILE: \$\{\{ needs.gate.outputs.profile_ci \}\}/g).length, 9);
// upload-artifact excludes files inside dot-directories by default. Keep
// this opt-in scoped to timing NDJSON, never the workspace or other secrets.
assert.equal(pr.match(/path: \.ci-profile\/\*\.ndjson\n {10}include-hidden-files: true/g).length, 9);
const routing = readFileSync(new URL("../../workflows/ci-routing.yml", import.meta.url), "utf8");
assert.match(routing, /PROFILE_CI: \$\{\{ steps.profile.outputs.profile_ci \}\}/);
assert.match(routing, /\/git\/ref\/heads\/master/);
assert.match(routing, /authorize\.mjs\?ref=\$TRUSTED_PROFILE_SHA/);
});
test("artifact parsing never extracts paths or executes files and rejects oversized records", () => {
const directory = mkdtempSync(path.join(tmpdir(), "ci-artifact-test-"));
const archive = path.join(directory, "artifact.zip");
const reader = fileURLToPath(new URL("../../../scripts/ci-profile/read-artifact.py", import.meta.url));
try {
execFileSync("python3", ["-c", "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w'); z.writestr('../escape.ndjson', '{\"kind\":\"test\"}\\n'); z.close()", archive]);
const result = execFileSync("python3", [reader, archive], { encoding: "utf8" });
assert.deepEqual(JSON.parse(result), { kind: "test" });
assert.equal(existsSync(path.join(directory, "escape.ndjson")), false);
execFileSync("python3", ["-c", "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w'); z.writestr('large.ndjson', 'x'*33000); z.close()", archive]);
assert.notEqual(spawnSync("python3", [reader, archive]).status, 0);
} finally { rmSync(directory, { recursive: true, force: true }); }
});
+12
View File
@@ -97,6 +97,8 @@ const stackedBase = {
const routeCases = [
["allowlisted PR", {}, awsRunner],
["allowlisted profile label", { EVENT_ACTION: "labeled", EVENT_LABEL_NAME: "profile-ci" }, awsRunner],
["unrelated label", { EVENT_ACTION: "labeled", EVENT_LABEL_NAME: "documentation" }],
["validated stacked base", stackedBase, awsRunner],
["lagging PR API base snapshot", { TEST_PR_BASE_SHA: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }, awsRunner],
["disabled AWS", { AWS_CI_ENABLED: "false" }],
@@ -144,6 +146,16 @@ for (const [name, stack, base, expected] of scopeCases) {
}
const files = (...paths) => JSON.stringify(paths.map((filename) => ({ filename })));
test("profile-ci overrides both middle-stack and release-notes-only skips", () => {
for (const overrides of [
{ STACK_JSON: JSON.stringify({ position: 2, size: 3, base: { ref: "master" } }), PR_BASE_REF: "stack-1" },
{ STACK_JSON: "null", PR_BASE_REF: "master", TEST_COMPARE_FILES: files("releases/v2026.1015.0.md") },
]) {
assert.equal(executeStep("Select PR CI scope", { ...overrides, PROFILE_CI: "false" }).full_ci, "false");
assert.equal(executeStep("Select PR CI scope", { ...overrides, PROFILE_CI: "true" }).full_ci, "true");
}
});
const manyReleaseNotes = JSON.stringify(Array.from({ length: 300 }, (_, index) => ({
filename: `releases/beta/v2026.1012.${index}-beta.0.md`,
})));
+41
View File
@@ -0,0 +1,41 @@
name: CI profile
on:
workflow_run:
workflows: [PR]
types: [completed]
permissions:
actions: read
contents: read
pull-requests: read
jobs:
export:
if: >-
github.repository == 'paperclipai/paperclip' &&
github.event.workflow_run.event == 'pull_request' &&
startsWith(github.event.workflow_run.display_title, 'CI profile · PR #')
runs-on: ubuntu-latest
timeout-minutes: 10
# This environment permits only the master branch. Do not use a repository
# secret: an in-repository PR must not be able to request this credential.
environment: ci-profiling
steps:
# workflow_run is privileged. Never check out its head SHA or run code
# from downloaded artifacts. This checkout is the trusted default branch.
- name: Checkout trusted exporter
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.sha }}
persist-credentials: false
sparse-checkout: scripts/ci-profile
- name: Export selected complete CI attempt
env:
GH_TOKEN: ${{ github.token }}
HONEYCOMB_CI_API_KEY: ${{ secrets.HONEYCOMB_CI_API_KEY }}
HONEYCOMB_CI_DATASET: paperclip-ci
HONEYCOMB_TEAM: paperclip
HONEYCOMB_ENVIRONMENT: test
run: node scripts/ci-profile/export.mjs
+39 -1
View File
@@ -8,6 +8,8 @@ on:
value: ${{ jobs.gate.outputs.runner }}
full_ci:
value: ${{ jobs.gate.outputs.full_ci }}
profile_ci:
value: ${{ jobs.gate.outputs.profile_ci }}
permissions:
actions: read
@@ -22,6 +24,7 @@ jobs:
outputs:
runner: ${{ steps.route.outputs.runner }}
full_ci: ${{ steps.scope.outputs.full_ci }}
profile_ci: ${{ steps.profile.outputs.profile_ci }}
steps:
- name: Validate PR identity and select runner
@@ -35,6 +38,7 @@ jobs:
EVENT_REPOSITORY: ${{ github.repository }}
EVENT_REPOSITORY_ID: ${{ github.repository_id }}
EVENT_ACTION: ${{ github.event.action }}
EVENT_LABEL_NAME: ${{ github.event.label.name }}
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
EVENT_PR_AUTHOR_ID: ${{ github.event.pull_request.user.id }}
EVENT_SENDER_ID: ${{ github.event.sender.id }}
@@ -76,7 +80,10 @@ jobs:
[[ "$EVENT_REPOSITORY_ID" == '1170821064' ]] || fail_closed 'unexpected repository ID'
[[ "$EVENT_BASE_REPOSITORY_ID" == '1170821064' ]] || fail_closed 'unexpected base repository ID'
[[ -n "$EVENT_BASE_REF" ]] || fail_closed 'missing base branch'
[[ "$EVENT_ACTION" =~ ^(opened|reopened|synchronize)$ ]] || fail_closed 'unsupported pull_request action'
[[ "$EVENT_ACTION" =~ ^(opened|reopened|synchronize|labeled)$ ]] || fail_closed 'unsupported pull_request action'
if [[ "$EVENT_ACTION" == 'labeled' && "${EVENT_LABEL_NAME:-}" != 'profile-ci' ]]; then
fail_closed 'only the profile-ci label can request a profiling run'
fi
jq -e '
type == "array" and
@@ -215,6 +222,31 @@ jobs:
echo "runner=$aws_runner" >> "$GITHUB_OUTPUT"
echo '::notice title=AWS CI routing::Using an ephemeral RunsOn Fleet runner'
- name: Authorize CODEOWNERS CI profiling
id: profile
shell: bash
env:
GH_TOKEN: ${{ github.token }}
PROFILE_REQUESTED: ${{ contains(github.event.pull_request.labels.*.name, 'profile-ci') }}
run: |
set -euo pipefail
if [[ "$PROFILE_REQUESTED" != 'true' ]]; then
echo 'profile_ci=false' >> "$GITHUB_OUTPUT"
exit 0
fi
# Fetch only from canonical master, never the PR ref. Pin both the
# authorization program and CODEOWNERS to the same trusted revision.
if ! TRUSTED_PROFILE_SHA="$(gh api /repos/paperclipai/paperclip/git/ref/heads/master --jq .object.sha)" ||
[[ ! "$TRUSTED_PROFILE_SHA" =~ ^[0-9a-f]{40}$ ]] ||
! gh api -H 'Accept: application/vnd.github.raw+json' \
"/repos/paperclipai/paperclip/contents/scripts/ci-profile/authorize.mjs?ref=$TRUSTED_PROFILE_SHA" \
> "$RUNNER_TEMP/ci-profile-authorize.mjs"; then
echo 'profile_ci=false' >> "$GITHUB_OUTPUT"
exit 0
fi
export TRUSTED_PROFILE_SHA
node "$RUNNER_TEMP/ci-profile-authorize.mjs"
- name: Select PR CI scope
id: scope
shell: bash
@@ -223,6 +255,7 @@ jobs:
EVENT_NAME: ${{ github.event_name }}
EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha }}
EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PROFILE_CI: ${{ steps.profile.outputs.profile_ci }}
STACK_JSON: ${{ toJSON(github.event.pull_request.stack) }}
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
@@ -293,6 +326,11 @@ jobs:
reason='release notes only'
fi
if [[ "${PROFILE_CI:-false}" == 'true' ]]; then
full_ci='true'
reason='profile-ci requests the complete PR suite'
fi
echo "full_ci=$full_ci" >> "$GITHUB_OUTPUT"
echo "::notice title=PR CI scope::$reason; full_ci=$full_ci"
+200 -7
View File
@@ -11,6 +11,11 @@ permissions:
contents: read
pull-requests: read
# Test reporters write local artifacts only. The workflow_run exporter owns
# the Honeycomb credential, so fork PRs cannot execute with it.
env:
PAPERCLIP_CI_PROFILE_DIR: ${{ github.workspace }}/.ci-profile
concurrency:
group: pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
@@ -24,6 +29,10 @@ jobs:
runs-on: ${{ needs.gate.outputs.runner }}
timeout-minutes: 10
env:
PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }}
PAPERCLIP_CI_PROFILE_JOB: policy
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -31,6 +40,20 @@ jobs:
persist-credentials: false
fetch-depth: 0
- name: Initialize CI profile
continue-on-error: true
if: env.PAPERCLIP_CI_PROFILE == 'true'
run: node scripts/ci-profile/initialize.mjs
- name: Select compatible Node test command
run: |
# A rerun can use this trusted workflow with a pre-feature checkout.
if [[ -f scripts/ci-profile/node-test.mjs ]]; then
echo 'CI_NODE_TEST=scripts/ci-profile/node-test.mjs' >> "$GITHUB_ENV"
else
echo 'CI_NODE_TEST=--test' >> "$GITHUB_ENV"
fi
- name: Block manual lockfile edits
if: >-
github.head_ref != 'chore/refresh-lockfile' &&
@@ -76,28 +99,28 @@ jobs:
run: node ./scripts/check-no-git-push.mjs
- name: Test no-git-push check
run: node --test ./scripts/check-no-git-push.test.mjs
run: node "$CI_NODE_TEST" ./scripts/check-no-git-push.test.mjs
- name: Validate feature module boundaries
run: pnpm check:module-boundaries
- name: Test feature module boundary check
run: node --test ./scripts/check-module-boundaries.test.mjs
run: node "$CI_NODE_TEST" ./scripts/check-module-boundaries.test.mjs
- name: Test PR quality-gate scripts
run: node --test '.github/scripts/tests/*.test.mjs'
run: node "$CI_NODE_TEST" '.github/scripts/tests/*.test.mjs'
- name: Test general-server shard partition
run: node --test ./scripts/__tests__/run-vitest-stable-shard.test.mjs
run: node "$CI_NODE_TEST" ./scripts/__tests__/run-vitest-stable-shard.test.mjs
- name: Test e2e shard partition
run: node --test ./scripts/__tests__/e2e-shard.test.mjs
run: node "$CI_NODE_TEST" ./scripts/__tests__/e2e-shard.test.mjs
- name: Test release verify workflow wiring
run: node --test ./scripts/__tests__/release-verify-workflow.test.mjs ./scripts/cloud-source-verification.test.mjs ./scripts/standard-image-contract.test.mjs
run: node "$CI_NODE_TEST" ./scripts/__tests__/release-verify-workflow.test.mjs ./scripts/cloud-source-verification.test.mjs ./scripts/standard-image-contract.test.mjs
- name: Test standalone package build concurrency
run: node --test ./scripts/__tests__/build-standalone-concurrency.test.mjs
run: node "$CI_NODE_TEST" ./scripts/__tests__/build-standalone-concurrency.test.mjs
- name: Validate release package manifest
run: node ./scripts/release-package-map.mjs check
@@ -113,6 +136,17 @@ jobs:
- name: Validate dependency resolution
run: pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile
- name: Upload CI profile timings
continue-on-error: true
if: always() && env.PAPERCLIP_CI_PROFILE == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ci-profile-${{ github.run_attempt }}-${{ github.job }}-${{ strategy.job-index || 0 }}
path: .ci-profile/*.ndjson
include-hidden-files: true
if-no-files-found: warn
retention-days: 14
typecheck_release_registry:
name: Typecheck + Release Registry
needs: [gate]
@@ -120,12 +154,21 @@ jobs:
runs-on: ${{ needs.gate.outputs.runner }}
timeout-minutes: 20
env:
PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }}
PAPERCLIP_CI_PROFILE_JOB: Typecheck + Release Registry
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Initialize CI profile
continue-on-error: true
if: env.PAPERCLIP_CI_PROFILE == 'true'
run: node scripts/ci-profile/initialize.mjs
- name: Setup Node.js for pnpm bootstrap
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
@@ -283,6 +326,17 @@ jobs:
- name: Verify release registry test coverage
run: pnpm run test:release-registry
- name: Upload CI profile timings
continue-on-error: true
if: always() && env.PAPERCLIP_CI_PROFILE == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ci-profile-${{ github.run_attempt }}-${{ github.job }}-${{ strategy.job-index || 0 }}
path: .ci-profile/*.ndjson
include-hidden-files: true
if-no-files-found: warn
retention-days: 14
general_tests:
name: General tests (${{ matrix.group_label }})
needs: [gate]
@@ -398,12 +452,21 @@ jobs:
- group: general-workspaces-c
group_label: workspaces-c
env:
PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }}
PAPERCLIP_CI_PROFILE_JOB: General tests (${{ matrix.group_label }})
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Initialize CI profile
continue-on-error: true
if: env.PAPERCLIP_CI_PROFILE == 'true'
run: node scripts/ci-profile/initialize.mjs
- name: Setup Node.js for pnpm bootstrap
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
@@ -456,6 +519,17 @@ jobs:
pnpm test:run:general -- --group '${{ matrix.group }}'
fi
- name: Upload CI profile timings
continue-on-error: true
if: always() && env.PAPERCLIP_CI_PROFILE == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ci-profile-${{ github.run_attempt }}-${{ github.job }}-${{ strategy.job-index || 0 }}
path: .ci-profile/*.ndjson
include-hidden-files: true
if-no-files-found: warn
retention-days: 14
docker_context_integrity:
name: Docker context integrity
needs: gate
@@ -463,6 +537,10 @@ jobs:
runs-on: ${{ needs.gate.outputs.runner }}
timeout-minutes: 15
env:
PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }}
PAPERCLIP_CI_PROFILE_JOB: Docker context integrity
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -472,6 +550,11 @@ jobs:
# Not every runner the gate can select ships the Buildx plugin —
# the image-build workflows set it up explicitly, so this lane does
# too rather than failing before it checks anything.
- name: Initialize CI profile
continue-on-error: true
if: env.PAPERCLIP_CI_PROFILE == 'true'
run: node scripts/ci-profile/initialize.mjs
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
@@ -485,6 +568,17 @@ jobs:
- name: Run generated-file drift checks against the Docker build context
run: docker buildx build --file .github/docker-context-checks.Dockerfile .
- name: Upload CI profile timings
continue-on-error: true
if: always() && env.PAPERCLIP_CI_PROFILE == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ci-profile-${{ github.run_attempt }}-${{ github.job }}-${{ strategy.job-index || 0 }}
path: .ci-profile/*.ndjson
include-hidden-files: true
if-no-files-found: warn
retention-days: 14
verify:
# Preserve the legacy required-check name while the underlying work runs in parallel.
name: verify
@@ -558,12 +652,21 @@ jobs:
command: test:typescript:vitest --shard=2/2
release_crate: true
env:
PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }}
PAPERCLIP_CI_PROFILE_JOB: Verify Paperclip Runner (${{ matrix.lane_label }})
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Initialize CI profile
continue-on-error: true
if: env.PAPERCLIP_CI_PROFILE == 'true'
run: node scripts/ci-profile/initialize.mjs
- name: Setup Node.js for pnpm bootstrap
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
@@ -732,6 +835,17 @@ jobs:
# read the shard flag as a test filter.
run: pnpm --filter @paperclipai/paperclip-runner ${{ matrix.command }}
- name: Upload CI profile timings
continue-on-error: true
if: always() && env.PAPERCLIP_CI_PROFILE == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ci-profile-${{ github.run_attempt }}-${{ github.job }}-${{ strategy.job-index || 0 }}
path: .ci-profile/*.ndjson
include-hidden-files: true
if-no-files-found: warn
retention-days: 14
build:
name: Build
needs: [gate]
@@ -739,12 +853,21 @@ jobs:
runs-on: ${{ needs.gate.outputs.runner }}
timeout-minutes: 20
env:
PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }}
PAPERCLIP_CI_PROFILE_JOB: Build
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Initialize CI profile
continue-on-error: true
if: env.PAPERCLIP_CI_PROFILE == 'true'
run: node scripts/ci-profile/initialize.mjs
- name: Setup Node.js for pnpm bootstrap
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
@@ -948,6 +1071,17 @@ jobs:
fi
node scripts/verify-grok-npm-install.mjs
- name: Upload CI profile timings
continue-on-error: true
if: always() && env.PAPERCLIP_CI_PROFILE == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ci-profile-${{ github.run_attempt }}-${{ github.job }}-${{ strategy.job-index || 0 }}
path: .ci-profile/*.ndjson
include-hidden-files: true
if-no-files-found: warn
retention-days: 14
verify_serialized_server:
name: Verify serialized server suites (${{ matrix.shard_label }})
needs: [gate]
@@ -994,12 +1128,21 @@ jobs:
shard_count: 9
shard_label: 9/9
env:
PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }}
PAPERCLIP_CI_PROFILE_JOB: Verify serialized server suites (${{ matrix.shard_label }})
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Initialize CI profile
continue-on-error: true
if: env.PAPERCLIP_CI_PROFILE == 'true'
run: node scripts/ci-profile/initialize.mjs
- name: Setup Node.js for pnpm bootstrap
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
@@ -1046,6 +1189,17 @@ jobs:
- name: Run serialized server test shard
run: pnpm test:run:serialized -- --shard-index ${{ matrix.shard_index }} --shard-count ${{ matrix.shard_count }}
- name: Upload CI profile timings
continue-on-error: true
if: always() && env.PAPERCLIP_CI_PROFILE == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ci-profile-${{ github.run_attempt }}-${{ github.job }}-${{ strategy.job-index || 0 }}
path: .ci-profile/*.ndjson
include-hidden-files: true
if-no-files-found: warn
retention-days: 14
canary_dry_run:
name: Canary Dry Run
needs: [gate]
@@ -1053,12 +1207,21 @@ jobs:
runs-on: ${{ needs.gate.outputs.runner }}
timeout-minutes: 20
env:
PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }}
PAPERCLIP_CI_PROFILE_JOB: Canary Dry Run
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Initialize CI profile
continue-on-error: true
if: env.PAPERCLIP_CI_PROFILE == 'true'
run: node scripts/ci-profile/initialize.mjs
- name: Setup Node.js for pnpm bootstrap
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
@@ -1234,6 +1397,16 @@ jobs:
# build` output, which the Build lane also produces, so it moved there:
# see "Verify built-in Grok from a clean public npm install" in `build`.
- name: Upload CI profile timings
continue-on-error: true
if: always() && env.PAPERCLIP_CI_PROFILE == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ci-profile-${{ github.run_attempt }}-${{ github.job }}-${{ strategy.job-index || 0 }}
path: .ci-profile/*.ndjson
include-hidden-files: true
if-no-files-found: warn
retention-days: 14
e2e_shards:
name: e2e shard (${{ matrix.shard_label }})
@@ -1281,12 +1454,21 @@ jobs:
shard_count: 8
shard_label: 8/8
env:
PAPERCLIP_CI_PROFILE: ${{ needs.gate.outputs.profile_ci }}
PAPERCLIP_CI_PROFILE_JOB: e2e shard (${{ matrix.shard_label }})
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Initialize CI profile
continue-on-error: true
if: env.PAPERCLIP_CI_PROFILE == 'true'
run: node scripts/ci-profile/initialize.mjs
- name: Setup Node.js for pnpm bootstrap
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
@@ -1376,6 +1558,17 @@ jobs:
tests/e2e/test-results/
retention-days: 14
- name: Upload CI profile timings
continue-on-error: true
if: always() && env.PAPERCLIP_CI_PROFILE == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ci-profile-${{ github.run_attempt }}-${{ github.job }}-${{ strategy.job-index || 0 }}
path: .ci-profile/*.ndjson
include-hidden-files: true
if-no-files-found: warn
retention-days: 14
e2e:
# Preserve the legacy required-check name while the specs run sharded
# across the matrix above (same pattern as the `verify` aggregate).
+4
View File
@@ -1,7 +1,10 @@
name: PR
run-name: >-
${{ contains(github.event.pull_request.labels.*.name, 'profile-ci') && (github.event.action != 'labeled' || github.event.label.name == 'profile-ci') && format('CI profile · PR #{0}', github.event.pull_request.number) || '' }}
on:
pull_request:
types: [opened, synchronize, reopened, labeled]
permissions:
actions: read
@@ -10,6 +13,7 @@ permissions:
jobs:
ci:
if: github.event.action != 'labeled' || github.event.label.name == 'profile-ci'
# Master requires CODEOWNERS review for .github/**, including this workflow.
# The AWS runner group permits pr-trusted.yml@refs/heads/master.
uses: paperclipai/paperclip/.github/workflows/pr-trusted.yml@master
+3
View File
@@ -89,3 +89,6 @@ test-results/
# Local native Runner test/session state
.paperclip-runner-prp/
# Opt-in CI timing artifacts
.ci-profile/
+4
View File
@@ -75,6 +75,10 @@ When changing the workflow path or branch, authorize the new reference before
updating the caller. Retain older authorized SHA references while queued runs or
supported reruns still use them.
For a full wall-clock profile of all main PR CI jobs, add `profile-ci` to the PR.
See [CI profiling](ci-profiling.md) for the Honeycomb waterfall, coverage,
operator setup, and activation requirements.
## Start Dev
From repo root:
+147
View File
@@ -0,0 +1,147 @@
# Profile the complete PR suite
Add the `profile-ci` label to a PR authored by a CI CODEOWNER. Adding the label starts a fresh `PR` workflow
run. Further pushes while the label remains also run with profiling enabled.
The run is named **CI profile · PR #number**. It runs all lanes of the main PR
suite, including middle-of-stack and release-notes-only PRs that normally skip
most lanes.
After the run finishes, open its Summary and follow **Open the Honeycomb
waterfall**. The separate **CI profile** workflow exports the trace after all
jobs finish. Its summary has the exact time range, job/span counts, and coverage
warnings. Keep the label on the PR and its head unchanged until export finishes.
Remove the label to stop profiling subsequent pushes. To profile the same
revision again, remove and re-add the label or rerun all jobs.
The PR author, original workflow actor, and rerun actor must all be individual
users in the trusted `master` branch's `.github/**` CODEOWNERS rule. GitHub user
IDs are resolved from that rule; PR edits to CODEOWNERS cannot grant access.
A label on an outside contributor's PR does not enable profiling, even when a
maintainer applies it. Failed identity lookups disable profiling.
Ordinary PRs do not write timing artifacts or export traces. The exporter job is
skipped for ordinary run names. Adding another label does not run the CI matrix.
Profiling preserves the existing trusted AWS runner routing. Eligible PR jobs
use `paperclip-public-pr-x64`; the routing gate uses `paperclip-ci-routing-x64`.
The existing fail-closed GitHub-hosted fallback still applies if AWS routing
cannot be authorized. Only the separate post-run Honeycomb exporter uses
`ubuntu-latest` directly; it is outside the measured PR suite.
## Read the waterfall
The workflow root measures elapsed time from run creation to the last job's
completion. For reruns, it starts at that attempt's `run_started_at` instead of
the original creation time. Every job is a child on the same wall clock, with
its steps and available test details beneath it. All spans use a sample rate of
one. There is no sampling within the selected attempt. For a compact Gantt view,
open a job row's menu and choose **Collapse spans at this depth**. Expand an
individual job to inspect its steps and test files.
The expandable hierarchy is **workflow → job → step → test filename → tests**.
Vitest, Playwright, and Node file rows use repository-relative filenames, also
available in `test.file`. Separate files retain their own children even when
individual test names repeat across files.
- Compare the jobs that finish last (`ci.finishes_last`) with other overlapping
work. A long job that finishes early is not necessarily delaying CI.
- Expand setup, dependency installation, cache restore, Rust compilation, builds,
and tests to find repeated or slow work.
- `ci.execution_ms` measures a job's execution. `ci.start_offset_ms` shows when
it started relative to the whole run. Do not add parallel job durations to
calculate elapsed time.
- Waiting spans cover job creation to runner start. Dependency waits can occur
before GitHub creates a job, so they appear as gaps from the workflow start.
GitHub does not expose a separate ready timestamp; do not interpret every gap
as runner queue time or claim this is a computed dependency critical path.
GitHub job/step timestamps have second precision. Vitest and Playwright record
test timestamps at the runner. Node's test reporter records completion receipt
time and duration (`test.clock=reporter-receipt`); buffered IPC delivery can shift
those small test spans. Vitest file spans include its test/hook execution and
carry collection/setup durations as fields. Playwright file spans cover the
first through last test attempt in that file. Rust and standalone script checks
retain their full job/step timings; stable libtest does not expose per-test wall
timestamps. The trace is CI timing data, not application tracing.
Scope includes all main PR lanes: policy, typecheck/release registry, general and
serialized tests, Runner checks, Docker context checks, build, canary dry run,
browser E2E, and aggregate checks. Separate Storybook, image, deployment, release,
and live-provider eval workflows are outside this trace.
## Failure, coverage, and reruns
The exporter uses GitHub's attempt-specific job API and paginates jobs and
artifacts. It includes failed and cancelled jobs and uploads available test
timings with `always()`. A force-cancelled or failed setup job may leave no
artifact. Coverage warnings and `ci.profile_complete=false` make missing
artifacts, interrupted reporters, rejected data, and absent test details visible.
Test launchers write an expected-invocation record before starting each Vitest,
Playwright, or Node process. Each reporter must write matching start/end records,
so a missing reporter cannot hide behind another successful invocation in the
same job. Compare `ci.expected_invocations` with `ci.reported_invocations`.
Failed or cancelled workflows are conservatively incomplete because a failed
command can prevent later tests or steps from running.
Each attempt gets a different trace ID. Rerunning failed jobs alone produces an
attempt trace for those jobs; it does not replay completed jobs. Reruns are marked
`ci.partial_rerun=true` conservatively. Use a fresh label-triggered run for an
unambiguous full-suite baseline. GitHub may carry earlier successful jobs into
the new attempt's job inventory. Jobs completed before the new attempt started
are excluded and counted in `ci.carried_job_count`.
If export fails, inspect **CI profile** and rerun that exporter after fixing the
cause. A partial HTTP export may already have sent some spans; retries use the
same IDs but the Events API does not promise deduplication. Timing artifacts
remain available for 14 days. Invalid/oversized artifacts stop export instead of
silently truncating the suite. No logs, environment dumps, error messages, stack
traces, or credentials are included in the exported field allowlist.
## Operator setup and activation
Use the existing Honeycomb `paperclip` team, `test` environment, and a separate
service dataset named `paperclip-ci`. Create GitHub environment `ci-profiling` with **Selected branches and tags**:
allow only the branch `master`, with no PR-ref or tag patterns. Store environment
secret `HONEYCOMB_CI_API_KEY` with a dedicated ingest-only key for the Honeycomb
`test` environment. Do not create a repository- or organization-level secret of
that name: the environment restriction prevents in-repository PR workflows from
requesting the key. Tests still execute the PR branch; only the separate exporter
runs from `master`. It needs event
ingest permission; dataset creation permission is needed only if the dataset
has not been created yet. Do not store the environment configuration key in CI.
Create the repository label `profile-ci` if it does not exist.
`ci-profile.yml` runs from the trusted default-branch revision. It checks out only
the exporter, installs no dependencies, and parses PR artifacts as bounded JSON
without extracting or executing their contents. The PR jobs never receive the
Honeycomb key. The exporter independently verifies CI CODEOWNER identities,
the current label, repository, and exact tested PR revision before reading
artifacts or sending data. Its credential is sent only in an HTTPS header to
`api.honeycomb.io`; redirects are rejected. Exported fields are allowlisted.
`CODEOWNERS` also covers `scripts/ci-profile/**`, so changes to the privileged
exporter require infrastructure-owner review.
The new trusted workflow and routing definitions must merge to master before
the label-triggered journey is active. A PR uses `pr-trusted.yml@master` and
`ci-routing.yml@master`; do not change those trust references just to test this
feature before merge.
## Local qualification
```sh
node --test .github/scripts/tests/ci-profile.test.mjs .github/scripts/tests/ci-routing.test.mjs
node --test scripts/ci-profile/reporters.test.mjs
actionlint .github/workflows/{pr,pr-trusted,ci-routing,ci-profile}.yml
node scripts/ci-profile/export.mjs --run-id=RUN_ID --attempt=1 --historical --dry-run --output=/tmp/ci-trace.json
```
`--historical` is local-only and permits a deliberate export of an existing
unlabeled run. It has complete GitHub job/step timing but cannot reconstruct
test details that were never recorded. Omit `--dry-run` and supply
`HONEYCOMB_CI_API_KEY` to send the selected baseline to Honeycomb. It is labeled
as historical/incomplete test coverage; it is not proof of the label-triggered
workflow.
References: [Honeycomb trace fields](https://docs.honeycomb.io/get-started/best-practices/organizing-data/),
[Events API](https://docs.honeycomb.io/api/events/create-events),
[GitHub workflow_run](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#workflow_run).
+5 -2
View File
@@ -29,8 +29,11 @@ paste it into Paperclip's authorization page. Keep it out of Bot chat.
Assign ordinary tasks to the agent. Read its progress, answer its question cards,
and review its documents and files in the task thread. The Bot can initiate a
new task from its Grok conversation through the admitted work-session tools.
An immediate proactive claim does not send the same assignment back through the
routine.
The originating conversation checks the same request while admission is queued.
Neither immediate nor delayed Bot-initiated admission sends that assignment back
through the routine. Grok keeps the request ID stable until admission succeeds
or fails. Human-assigned work and continuations after an answered question still
use their normal assignment wakes.
## Files and completion
@@ -1,6 +1,6 @@
# Grok Bot production readiness
Status as of October 15: a real assigned task now reaches canonical completion. Qualification found an active-session wake hazard; its correction is implemented and passing local checks. Production acceptance is incomplete. The current release goal and matrix below supersede the earlier dated campaign entries. Historical successes and failures remain evidence for the revisions actually tested.
Status as of October 15: real assigned and Bot-initiated tasks reach canonical completion. The active-session wake correction is merged. A real proactive test found a delayed-admission echo; its correction is under verification. Production acceptance is incomplete. The current release goal and matrix below supersede the earlier dated campaign entries. Historical successes and failures remain evidence for the revisions actually tested.
## Current production finish line — October 14
@@ -12,12 +12,12 @@ There is no testing call, case-count, token or spending cap. The former six-case
| --- | --- | --- |
| Ordinary onboarding works | Actual Grok UI, fixed agent consent, saved setup, wrong-key behavior, authenticated challenge, refresh, reconnect and revocation | Resumed real Grok authorization, saved setup Done, authenticated routine challenge and automatic refresh passed on the October 15 serving revision. Runtime reconnect copy fix is merged; verify its published UI and fresh production setup next. |
| Assigned work reaches a usable result | One actual managed workflow: progress, question and answer, document, downloaded file/hash, canonical completion or review and cleanup | A preliminary comparison task on serving Core ff0 completed progress → question → answer → report/CSV → canonical Done, with both cleanup receipts. The cited CSV downloads with the exact publication hash. Two distinct CSV uploads accompanied an active-session result wake; correct that wake behavior and requalify the final candidate. |
| Bot initiates governed work | Real-client task creation/claim/completion with stable receipts and no outbound echo or duplicate effects | Unverified. Run after the assigned-work gate. |
| Bot initiates governed work | Real-client task creation/claim/completion with stable receipts and no outbound echo or duplicate effects | A real idle-conversation request created one task and one managed run, saved a 100-word report, reached accepted Done and cleaned up its lease. A delayed-admission wake and two acceptance attempts were also recorded. Functional completion passed; no-echo acceptance failed. Requalify the correction. |
| Recovery and authority are reliable | Healthy work past the prior five-minute failure window; actual controller restart preserving assignment/session/process and no duplicate wake; Stop, pause, reassignment, revocation, stale calls, queues and cleanup | The old hold was inspected and cleared. The successful comparison continuation ended below five minutes. Healthy work beyond that window, an actual restart and active Stop remain unverified; protocol tests are separate evidence. |
| One identifiable reviewed candidate passes source gates | Focused protocol/auth/Runner/UI tests, full CI/typecheck/tests/build/token/Storybook evidence and exact artifacts | The active-session wake correction follows origin/master. Initial local checks used d6e4b36c8c049b85813caf3e3cbf1f8d87e4e068; CI and review must verify the final PR head. Three regressions failed before the fix. Focused tests, repository typecheck/build, token gates and Storybook checks pass. Full CI, review and publication of this correction remain required; prior candidate receipts are historical. |
| One identifiable reviewed candidate passes source gates | Focused protocol/auth/Runner/UI tests, full CI/typecheck/tests/build/token/Storybook evidence and exact artifacts | Active-session wake correction #16394 merged with 55 passing checks, two optional skips and review 5/5. The proactive echo correction started from master 7fc2dc8b3236e1149ae32756b6153a503623404b and was rebased onto freshly fetched master 250429942e27d568f20d3bc7dddcfc84a666480a before submission. Two regression cases reproduce the real delayed-admission wake before the fix. All 214 focused protocol/auth/question checks pass, including 80 Grok and Dot checks. Repository typecheck/build, token gates, the 2,731-story structure and Storybook build pass. Fresh CI, review, publication and the real no-echo repeat remain required. |
| Production opt-in is delivered and smoke-tested | Staging acceptance, correct served Core/distribution/Runner, normal editable opt-in controls/defaults, actual production Bot workflow and inspectable task/output links | Cloud opt-in policy and release-guide corrections are merged. Staging Cloud provenance and minimal feature values were verified, with Dot disabled and defaults unchanged. The corrected wake candidate, remaining real-client cases, ordinary production opt-in controls and production smoke are outstanding. |
The critical path is to review and publish the active-session wake correction, verify its served identity, then complete the remaining real-client acceptance cases. The selected staging workspace remains the test destination. Preserve one source manifest per case; do not combine incompatible runs into one green release.
The critical path is to review and publish the proactive-admission echo correction, verify its served identity, then complete the remaining real-client acceptance cases. The selected staging workspace remains the test destination. Preserve one source manifest per case; do not combine incompatible runs into one green release.
Real Grok reconnect restored the same agent, plugin and webhook-only routine. Saved setup reached Done after both delivery and authenticated MCP acknowledgement. After the initial access token expired, Grok refreshed it and acknowledged another challenge without reauthorization. Grok/MCP/Runner were enabled with Dot disabled; setup allocated no managed Runner. The operator used the existing saved-invitation path to get the secure pairing-code copy control before the Runtime copy correction published. Record that as assisted recovery, not proof of unassisted fresh onboarding. The copy correction is merged in #16373 after 55 passing CI checks, two optional skips and review 5/5.
@@ -27,7 +27,11 @@ An unread asynchronous tool result also caused a webhook during accepted work. T
Core d73d7a8651db0897cb50adbca42731bcb8aed550 and its canonical private Cloud composition passed publication. One single-workspace request passed preparation but was blocked by an existing shared rollout; it created no campaign. The existing rollout later released the reservation. That prepared source is now superseded by the wake correction. Keep its receipts as history and do not resume its older deployment request. Select a new immutable reviewed source after this fix passes CI and review. Production opt-in policy and release-guide changes are merged, but production rollout and smoke remain outstanding.
Desktop control stopped responding after the successful exchanges. Resetting the tool did not restore it, and the user has been asked to reopen Codex. Browser control and webhook/MCP work remain usable; native Bot-initiated prompts and fresh production setup require restored desktop control or ordinary human setup actions. The dedicated production workspace has no qualification agent grant or smoke task.
Desktop control recovered on October 15. One ordinary message in the actual Grok conversation initiated the proactive source investigation; the operator created no preliminary task and sent no extra nudge. Serving Core 11d69d04c7531cf7eb37f7994b7112797e145837 includes #16394. The Bot created one task and one run, saved a 100-word report, submitted an accepted canonical result and reached Done. Its managed lease has a successful cleanup receipt. The exact public PR marker matches the saved control value, was absent from the operator prompt and appears in the saved report with its source. No Paperclip proxy operation occurred. The report records native GitHub API use; a complete provider tool transcript is unavailable.
The proactive run also received an authenticated delayed-admission webhook while its original conversation was waiting. Two acceptance requests and two title writes were recorded. This fails the no-echo release condition despite successful task completion. Two local regressions reproduce the extra webhook for a delayed offer and a prepared intent recovered after restart. The correction keeps admission in the original caller's polling path and rejects old prepared proactive wakes. Human assignments and question continuations retain their ordinary dispatch paths. Source and real-client requalification are required.
Normal staging automation has advanced beyond the older prepared candidate. No shared CI routing switch was changed. The earlier temporary fallback request is no longer needed for the already served successor. The dedicated production workspace still has no qualification agent grant or smoke task.
Keep native providers and person-purpose MCP connections unchanged. No browser automation belongs in normal operation. Defer localhost relays, internal Grok APIs, marketplace distribution and Paperclip-connected third-party tools. Completion of a Paperclip work session does not assert remote Grok termination.
@@ -0,0 +1,61 @@
# Opt-in CI profiling
Goal: label a PR `profile-ci`, run the complete main PR suite, and open one
Honeycomb waterfall showing the full elapsed timeline. Use that evidence to
decide which CI work to speed up or remove. CI optimizations themselves are a
follow-up, not part of this change.
## Acceptance
| Case | Required behavior | Evidence |
| --- | --- | --- |
| Unlabeled PR | Existing scope and commands; no profile export | Routing and exporter tests |
| Labeled CI CODEOWNER PR, including stacked/release-notes-only | All main PR lanes run | Routing tests and full-run job inventory |
| Completed profile | One trace per run attempt; every job and step uses actual timestamps | Honeycomb waterfall reconciled against GitHub job API |
| Parallel work | Shared wall clock, with waiting and execution distinguishable | Overlapping lanes and root duration checked against GitHub |
| Tests | Vitest and Playwright file/test timings under their job/step; Node test timings where supported | Real reporter smoke runs and hierarchy checks |
| Failure/cancellation/rerun | Preserve completed timing data; mark missing coverage; attempts stay separate | Exporter regression cases |
| Credentials | Master-only environment owns ingest secret; trusted exporter verifies CI CODEOWNER author and actors; PR jobs only write timing artifacts | Workflow review, hostile-identity cases, and environment settings |
Scope: the main `PR` workflow and every lane of `pr-trusted.yml`, including
policy, build, typecheck/release-registry, general/serialized test shards,
Runner checks, canary, and browser E2E. Separate release/deploy/live-provider
workflows remain outside this first trace. Rust checks are visible as complete
job/step timings; stable libtest does not provide per-test wall-clock reports.
No sampling inside a selected run.
## State
- Branch: `codex/ci-honeycomb-profiling` in the existing `76aa` worktree.
- PR: https://github.com/paperclipai/paperclip/pull/16378
- Baseline: GitHub run `38671274294`, 48 jobs, 479 seconds from creation to last
job completion. Raw GitHub evidence is in `/tmp/paperclip-ci-profile-evidence`.
- Honeycomb: existing `paperclip` team / `test` environment; dedicated `paperclip-ci`
dataset. Authorized local configuration key ingested the historical baseline;
verified 731 spans and 7m 59s in the [live Honeycomb waterfall](https://ui.honeycomb.io/paperclip/environments/test/datasets/paperclip-ci/trace/oaPMzw8zGwG).
- Implemented label selection, full-suite override, test timing reporters, trusted
post-run exporter, direct waterfall link, and missing-data diagnostics.
- Verified: 48-job / 731-span historical reconstruction; 508 workflow/reporter/shard
checks; real root-config probe with 13 tests and one file span; full Node 24
typecheck and build. The local full Vitest run was stopped after a setup-token
timeout and a distribution-plugin-catalog fixture failure. The setup-token
suite passed on focused rerun; the directory-symlink removal failure reproduces
with the unchanged base configuration and test/service files on this macOS
Node 24.11.0 host. The PR body records the latest remote CI and review results.
- Created and verified the repository `profile-ci` label. Dedicated Honeycomb
ingest key `paperclip-ci-profile` has no dataset-creation permission. It is
stored only as `HONEYCOMB_CI_API_KEY` in the `ci-profiling` GitHub environment,
restricted to the `master` branch. Temporary key file removed; both credentials
scanned absent from repository files and branch history.
- Added CODEOWNER author/actor authorization and rejection tests, plus CODEOWNERS
coverage of the privileged exporter.
- Review fixes: retain `node --test` for older checked-out revisions; record expected
invocations outside reporters to detect wholly missing reporters. The shard
inventory now writes synchronously before exit after reproducing truncation
on both the unchanged baseline and working branch.
- Real partial-rerun evidence showed GitHub carrying old successful jobs into
the new attempt's inventory with the new attempt number. Exclude jobs whose
completion precedes the attempt start, and record their count explicitly.
- Delivery: reviewable PR and live Honeycomb evidence. Activation of new trusted
workflow definitions requires merge to master; do not claim pre-merge evidence
proves the post-merge label-triggered journey.
+2
View File
@@ -138,6 +138,8 @@ API: `present` — representative operation IDs:
- `POST /api/agents/{id}/pause`.
- `POST /api/agents/{id}/resume`.
- `POST /api/agents/{id}/terminate`.
- `POST /api/agents/{id}/lifecycle/retry`.
- `GET /api/agents/{id}` returns the safe failed phase/step and action in `lifecycleError`.
Agent access: `partial` — REST / native `call_api` resume under agent_config:update
change-grant authorization; pause and termination remain board-only.
+6
View File
@@ -88,6 +88,12 @@ Automated: [instruction service](../../server/src/__tests__/agent-instructions-s
Manual: Save a harmless instruction edit, reload, and verify its revision and effect in the next eligible run. Pause and resume the disposable agent; confirm pause prevents new work and does not erase previous runs.
For failed setup, use an unavailable model or invalid command on a disposable agent.
Confirm the profile names the failed verification step and gives a relevant action.
Check that it explains automatic retry and offers **Retry now**. Fix the configuration,
retry, and reload; the failure must disappear only after readiness commits.
Provider output and credentials must not appear in the lifecycle message.
An explicit runner change is a configuration change, with normal revisions and
session invalidation. Incompatible settings must produce a field-specific error;
selecting another runner must not silently discard them or change an active run's
+2 -2
View File
@@ -61,11 +61,11 @@
"smoke:posthog-live": "node scripts/smoke/posthog-live.mjs",
"smoke:pipelines-tutorial": "./scripts/smoke/pipelines-tutorial-smoke.sh",
"smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs",
"test:release-registry": "node --test scripts/__tests__/canary-startup-playwright.test.mjs scripts/__tests__/cursor-public-install-sandbox.test.mjs scripts/__tests__/grok-public-install-sandbox.test.mjs scripts/__tests__/public-install-closure.test.mjs scripts/__tests__/prefetch-child.test.mjs scripts/__tests__/stage-bundled-packages.test.mjs scripts/__tests__/pack-staged-packages.test.mjs scripts/__tests__/retain-runner-qualification-packages.test.mjs scripts/__tests__/runner-release-crate-cache.test.mjs scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/release-registry-versions.test.mjs scripts/link-plugin-dev-sdk.test.js scripts/acpx-patch-packaging.test.mjs scripts/service-onboard-smoke.test.mjs scripts/docker-onboard-smoke.test.mjs scripts/preview-artifacts.test.mjs scripts/cloud-migrator-artifacts.test.mjs",
"test:release-registry": "node scripts/ci-profile/node-test.mjs scripts/ci-profile/reporters.test.mjs scripts/__tests__/canary-startup-playwright.test.mjs scripts/__tests__/cursor-public-install-sandbox.test.mjs scripts/__tests__/grok-public-install-sandbox.test.mjs scripts/__tests__/public-install-closure.test.mjs scripts/__tests__/prefetch-child.test.mjs scripts/__tests__/stage-bundled-packages.test.mjs scripts/__tests__/pack-staged-packages.test.mjs scripts/__tests__/retain-runner-qualification-packages.test.mjs scripts/__tests__/runner-release-crate-cache.test.mjs scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/release-registry-versions.test.mjs scripts/link-plugin-dev-sdk.test.js scripts/acpx-patch-packaging.test.mjs scripts/service-onboard-smoke.test.mjs scripts/docker-onboard-smoke.test.mjs scripts/preview-artifacts.test.mjs scripts/cloud-migrator-artifacts.test.mjs",
"storybook-visual:baseline": "node scripts/storybook-visual-baseline.mjs",
"test:storybook-visual": "node scripts/storybook-visual-baseline.mjs download && node scripts/storybook-visual-baseline.mjs verify && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts",
"test:storybook-visual:update": "node scripts/storybook-visual-baseline.mjs download && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts --update-snapshots && node scripts/storybook-visual-baseline.mjs pack",
"test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts",
"test:e2e": "node scripts/ci-profile/run.mjs playwright npx playwright test --config tests/e2e/playwright.config.ts",
"test:e2e:runner": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/launch.ts",
"test:e2e:runner:image-id": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/daytona-image-content.ts",
"test:e2e:runner:judge-first-task": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/first-task-judge.ts",
@@ -427,7 +427,11 @@ describe("sandbox managed runtime", () => {
const local = path.join(root, "local"), remote = path.join(root, "remote");
await mkdir(local);
await writeFile(path.join(local, "file.txt"), "before");
const capture = vi.spyOn(restoreMerge, "captureDirectorySnapshotOffThread");
const mergeDirectoryWithBaseline = restoreMerge.mergeDirectoryWithBaseline;
const capture = vi.spyOn(restoreMerge, "captureDirectorySnapshotOffThread")
.mockImplementation(restoreMerge.captureDirectorySnapshot);
const merge = vi.spyOn(restoreMerge, "mergeDirectoryWithBaseline")
.mockImplementation(input => mergeDirectoryWithBaseline({ ...input, workspaceSnapshotIsolation: undefined }));
try {
const prepared = await prepareCommandManagedRuntime({
runner: makeInlineSpawnRunner(),
@@ -442,9 +446,11 @@ describe("sandbox managed runtime", () => {
expect(await readFile(path.join(remote, "file.txt"), "utf8")).toBe("before");
await writeFile(path.join(remote, "file.txt"), "after");
await prepared.restoreWorkspace();
expect(merge).toHaveBeenCalled();
expect(merge.mock.calls.every(([input]) => input.workspaceSnapshotIsolation === isolation)).toBe(true);
expect(await readFile(path.join(local, "file.txt"), "utf8")).toBe("after");
} finally { capture.mockRestore(); }
}, 30_000);
} finally { capture.mockRestore(); merge.mockRestore(); }
});
it.each(["host_current", "adopt_remote", "durable_seed"] as const)("stages and restores both project repositories with independent Git histories (%s)", async (mode) => {
const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-multi-repo-"));
+1 -1
View File
@@ -17,7 +17,7 @@
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "pnpm run build && node --test test/*.test.mjs"
"test": "pnpm run build && node ../../scripts/ci-profile/node-test.mjs test/*.test.mjs"
},
"engines": {
"node": ">=24.11.0"
+1 -1
View File
@@ -81,7 +81,7 @@
"typecheck:browser": "tsc -p tsconfig.browser.json --noEmit",
"test": "pnpm run test:typescript && pnpm run test:rust",
"test:typescript": "pnpm run test:typescript:prep && vitest run",
"test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-model-selection-package-contract.test.mjs test/acpx-message-boundaries-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/cursor-provisioner-layout.test.mjs scripts/release-provider-pack.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs scripts/installed-package-pack.test.mjs scripts/runner-package-executable.test.mjs scripts/stage-runner-binary.test.mjs scripts/build-inputs.test.mjs",
"test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node ../../scripts/ci-profile/node-test.mjs test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-model-selection-package-contract.test.mjs test/acpx-message-boundaries-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs test/acpx-pi-receipt-package-contract.test.mjs test/acpx-response-delivery-package-contract.test.mjs scripts/candidate-provider-pack.test.mjs scripts/cursor-provisioner-layout.test.mjs scripts/release-provider-pack.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs scripts/installed-package-pack.test.mjs scripts/runner-package-executable.test.mjs scripts/stage-runner-binary.test.mjs scripts/build-inputs.test.mjs",
"test:typescript:vitest": "node ./scripts/run-pr-vitest-lane.mjs",
"test:rust": "cargo test --release --manifest-path runner/Cargo.toml --locked --workspace",
"test:codex": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --test codex_provider",
@@ -24,6 +24,7 @@
import { spawnSync } from "node:child_process";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { invocationEnv } from "../../../scripts/ci-profile/record.mjs";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const workspaceRoot = resolve(packageRoot, "../..");
@@ -62,6 +63,7 @@ for (const planned of plannedCommands) {
const result = spawnSync(planned.command, planned.args, {
cwd: planned.cwd,
stdio: "inherit",
env: planned.args[0] === "exec" && planned.args[1] === "vitest" ? invocationEnv("vitest") : process.env,
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
+4 -1
View File
@@ -1,7 +1,7 @@
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
import { configDefaults, defineConfig } from "vitest/config";
const packageRoot = fileURLToPath(new URL(".", import.meta.url));
@@ -13,6 +13,9 @@ export default defineConfig({
],
},
test: {
...(process.env.PAPERCLIP_CI_PROFILE === "true"
? { reporters: [...configDefaults.reporters, resolve(packageRoot, "../../scripts/ci-profile/vitest-reporter.mjs")] }
: {}),
environment: "node",
include: [
"src/**/*.test.ts",
+74
View File
@@ -0,0 +1,74 @@
import { execFileSync } from "node:child_process";
import { appendFileSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repository = "paperclipai/paperclip";
const repositoryId = 1170821064;
export const getGithub = (endpoint) => JSON.parse(execFileSync("gh", ["api", endpoint], {
encoding: "utf8", maxBuffer: 4 * 1024 * 1024, timeout: 30_000,
}));
// This is deliberately the CI infrastructure owner list, not the union of
// unrelated documentation owners. Unsupported teams/ambiguous rules deny access.
export function ciOwners(contents) {
const rules = contents.split(/\r?\n/).map((line) => line.split("#")[0].trim().split(/\s+/))
.filter(([pattern]) => pattern === ".github/**" || pattern === "/.github/**");
if (rules.length !== 1 || rules[0].length < 2
|| rules[0].slice(1).some((owner) => !/^@[a-z\d](?:[a-z\d-]*[a-z\d])?$/i.test(owner))) {
throw new Error("CI CODEOWNERS must have one explicit .github/** rule with individual GitHub users");
}
return rules[0].slice(1).map((owner) => owner.slice(1));
}
export function trustedOwnerIds(get = getGithub, trustedSha) {
const sha = trustedSha || get(`/repos/${repository}/git/ref/heads/master`).object.sha;
if (!/^[a-f\d]{40}$/.test(sha)) throw new Error("Invalid trusted CODEOWNERS revision");
const file = get(`/repos/${repository}/contents/.github/CODEOWNERS?ref=${sha}`);
if (file.encoding !== "base64") throw new Error("Cannot read trusted CODEOWNERS");
return ciOwners(Buffer.from(file.content, "base64").toString("utf8")).map((login) => {
const user = get(`/users/${login}`);
if (user.type !== "User" || !Number.isSafeInteger(user.id) || user.id <= 0) {
throw new Error("Cannot resolve a CI CODEOWNER identity");
}
return user.id;
});
}
export function authorizedActors(run, ownerIds) {
const allowed = (user) => user?.type === "User" && ownerIds.includes(user.id);
return run.repository?.id === repositoryId && run.repository?.full_name === repository
&& allowed(run.actor) && allowed(run.triggering_actor);
}
export function associatedProfilePr(run, pulls, ownerIds) {
if (!authorizedActors(run, ownerIds)) return undefined;
return pulls.find((pr) => pr.base.repo.id === repositoryId && pr.base.repo.full_name === repository
&& pr.head.repo?.id === run.head_repository?.id && pr.head.sha === run.head_sha
&& pr.user?.type === "User" && ownerIds.includes(pr.user.id)
&& pr.labels.some((label) => label.name === "profile-ci"));
}
async function main() {
let enabled = false;
try {
const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
if (event.pull_request?.labels?.some((label) => label.name === "profile-ci")) {
const runId = process.env.GITHUB_RUN_ID;
const attempt = process.env.GITHUB_RUN_ATTEMPT;
if (!/^\d+$/.test(runId) || !/^[1-9]\d*$/.test(attempt)) throw new Error("Invalid CI run identity");
const run = getGithub(`/repos/${repository}/actions/runs/${runId}/attempts/${attempt}`);
const pr = getGithub(`/repos/${repository}/pulls/${event.pull_request.number}`);
enabled = run.event === "pull_request" && run.path === ".github/workflows/pr.yml"
&& Boolean(associatedProfilePr(run, [pr], trustedOwnerIds(getGithub, process.env.TRUSTED_PROFILE_SHA)));
}
} catch {
console.log("::notice::CI profiling authorization could not be verified; profiling disabled.");
}
appendFileSync(process.env.GITHUB_OUTPUT, `profile_ci=${enabled}\n`);
console.log(`::notice::CI CODEOWNERS profiling authorization: ${enabled}`);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch(() => { console.error("CI profiling authorization failed"); process.exitCode = 1; });
}
+120
View File
@@ -0,0 +1,120 @@
import { execFileSync } from "node:child_process";
import { appendFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { buildTrace, traceUrl } from "./trace.mjs";
import { associatedProfilePr, authorizedActors, trustedOwnerIds } from "./authorize.mjs";
const repository = "paperclipai/paperclip";
const repoPath = `/repos/${repository}`;
const maxBytes = 32 * 1024 * 1024;
const gh = (args) => execFileSync("gh", ["api", ...args], { maxBuffer: maxBytes, timeout: 60_000 });
const get = (endpoint) => JSON.parse(gh([endpoint]).toString());
const pages = (endpoint, field) => JSON.parse(gh(["--paginate", "--slurp", endpoint]).toString())
.flatMap((page) => field ? page[field] : page);
export async function sendEvents(events, apiKey, dataset, fetcher = fetch) {
if (!apiKey) throw new Error("HONEYCOMB_CI_API_KEY is not configured");
for (let offset = 0; offset < events.length; offset += 500) {
const batch = events.slice(offset, offset + 500);
const response = await fetcher(`https://api.honeycomb.io/1/batch/${encodeURIComponent(dataset)}`, {
method: "POST", headers: { "X-Honeycomb-Team": apiKey, "Content-Type": "application/json" },
body: JSON.stringify(batch), redirect: "error", signal: AbortSignal.timeout(30_000),
});
if (!response.ok) throw new Error(`Honeycomb export failed (HTTP ${response.status})`);
const results = await response.json();
if (!Array.isArray(results) || results.length !== batch.length || results.some((result) => result.status !== 202)) {
throw new Error("Honeycomb did not accept every span in the batch");
}
}
}
async function main() {
const args = process.argv.slice(2);
const arg = (name) => args.find((value) => value.startsWith(`${name}=`))?.slice(name.length + 1);
const event = process.env.GITHUB_EVENT_PATH ? JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")) : null;
const runId = event?.workflow_run?.id ?? arg("--run-id");
const attempt = event?.workflow_run?.run_attempt ?? arg("--attempt");
if (!/^\d+$/.test(String(runId)) || !/^[1-9]\d*$/.test(String(attempt))) {
throw new Error("Provide --run-id and --attempt, or a workflow_run event");
}
const run = get(`${repoPath}/actions/runs/${runId}/attempts/${attempt}`);
if (run.repository?.full_name !== repository || run.event !== "pull_request"
|| run.path !== ".github/workflows/pr.yml" || run.status !== "completed") {
throw new Error("Only completed main PR workflow attempts can be profiled");
}
// GitHub often leaves workflow_run.pull_requests empty for fork PRs. Resolve
// association through the head commit, then validate repo and exact head.
const pulls = pages(`${repoPath}/commits/${run.head_sha}/pulls?per_page=100`);
const historical = args.includes("--historical");
if (historical && process.env.GITHUB_ACTIONS) throw new Error("Historical export is a local-only operation");
const ownerIds = historical ? [] : trustedOwnerIds(get);
if (process.env.GITHUB_ACTIONS) {
const exporterId = process.env.GITHUB_RUN_ID;
const exporterAttempt = process.env.GITHUB_RUN_ATTEMPT;
if (!/^\d+$/.test(exporterId) || !/^[1-9]\d*$/.test(exporterAttempt)) throw new Error("Invalid exporter run identity");
const exporter = get(`${repoPath}/actions/runs/${exporterId}/attempts/${exporterAttempt}`);
if (!authorizedActors(exporter, ownerIds)) {
console.log("Exporter actor is not a CI CODEOWNER; no data exported.");
return;
}
}
const pr = historical ? undefined : associatedProfilePr(run, pulls, ownerIds);
if (!pr && !historical) {
console.log("No CODEOWNERS-authorized profile-ci PR at this revision; no data exported.");
return;
}
const jobs = pages(`${repoPath}/actions/runs/${runId}/attempts/${attempt}/jobs?per_page=100`, "jobs");
const artifacts = pages(`${repoPath}/actions/runs/${runId}/artifacts?per_page=100`, "artifacts")
.filter((artifact) => artifact.name.startsWith(`ci-profile-${attempt}-`));
// The run name snapshots selection at dispatch. A label added while an
// ordinary run finishes must not retroactively select that earlier run.
if (!historical && !run.display_title.startsWith("CI profile · PR #")) {
console.log("This attempt was not selected for profiling; no data exported.");
return;
}
const directory = mkdtempSync(path.join(tmpdir(), "paperclip-ci-export-"));
const records = [];
let bytes = 0;
try {
for (const artifact of artifacts) {
if (artifact.expired || artifact.size_in_bytes > maxBytes) throw new Error("Timing artifact is expired or too large");
const archive = path.join(directory, `${artifact.id}.zip`);
writeFileSync(archive, gh([`${repoPath}/actions/artifacts/${artifact.id}/zip`]));
const decoded = execFileSync("python3", [fileURLToPath(new URL("./read-artifact.py", import.meta.url)), archive], {
encoding: "utf8", maxBuffer: maxBytes, timeout: 30_000,
});
bytes += Buffer.byteLength(decoded);
if (bytes > 128 * 1024 * 1024) throw new Error("Run timing artifacts exceed 128 MiB; refusing to truncate the trace");
for (const line of decoded.trim().split("\n").filter(Boolean)) records.push(JSON.parse(line));
}
} finally {
rmSync(directory, { recursive: true, force: true });
}
const trace = buildTrace(run, jobs, records);
trace.events[0].data["ci.profile_source"] = historical ? "historical" : "label";
const dataset = process.env.HONEYCOMB_CI_DATASET || "paperclip-ci";
const url = traceUrl(process.env.HONEYCOMB_TEAM || "paperclip", process.env.HONEYCOMB_ENVIRONMENT || "test", dataset, trace);
if (arg("--output")) writeFileSync(arg("--output"), JSON.stringify(trace, null, 2));
if (!args.includes("--dry-run")) {
// Publish the completed root only after every child has been accepted.
// A failed partial export must not advertise complete coverage.
await sendEvents(trace.events.slice(1), process.env.HONEYCOMB_CI_API_KEY, dataset);
await sendEvents([trace.events[0]], process.env.HONEYCOMB_CI_API_KEY, dataset);
}
const summary = [
`### CI profile${pr ? ` for PR #${pr.number}` : " (historical job/step baseline)"}`,
"", `[Open the full CI waterfall in Honeycomb](${url})`, "",
`${trace.jobCount} jobs · ${(trace.wallMs / 1000).toFixed(1)} seconds elapsed · ${trace.events.length} spans`,
"", `GitHub run: ${run.html_url}/attempts/${attempt}`, "",
...trace.warnings.map((warning) => `- ${warning}`),
"", "The workflow root measures elapsed time. Parallel job durations must not be summed as elapsed time.", "",
].join("\n");
console.log(summary);
if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => { console.error(error.message); process.exitCode = 1; });
}
+17
View File
@@ -0,0 +1,17 @@
import { recorder } from "./record.mjs";
import { appendFileSync } from "node:fs";
import { createHash } from "node:crypto";
recorder("profile")({ kind: "job", start: Date.now() });
if (process.env.PAPERCLIP_CI_PROFILE_JOB === "policy" && process.env.GITHUB_STEP_SUMMARY) {
const key = `${process.env.GITHUB_REPOSITORY}:${process.env.GITHUB_RUN_ID}:${process.env.GITHUB_RUN_ATTEMPT}`;
const traceId = createHash("sha256").update(key).digest("hex").slice(0, 32);
// This job does not know when the last parallel lane will finish. A bounded
// search window includes queued jobs; the exporter writes the exact range.
const now = Math.floor(Date.now() / 1000);
const params = new URLSearchParams({ trace_id: traceId, trace_start_ts: String(now - 86400), trace_end_ts: String(now + 86400) });
appendFileSync(process.env.GITHUB_STEP_SUMMARY,
`### Full CI profile\n\n[Open the Honeycomb waterfall](https://ui.honeycomb.io/paperclip/environments/test/datasets/paperclip-ci/trace?${params})\n\n` +
"The trace becomes available after this attempt completes and the CI profile exporter finishes. Keep the profile-ci label on the PR until then.\n");
}
+41
View File
@@ -0,0 +1,41 @@
import path from "node:path";
import { recorder, relativeFile } from "./record.mjs";
export default async function* reporter(source) {
const record = recorder("node");
record({ kind: "start" });
let index = 0;
const files = new Map();
for await (const event of source) {
// complete is emitted as execution finishes; pass/fail are buffered in
// declaration order and cannot be used to place parallel tests in time.
if (event.type !== "test:complete") continue;
const data = event.data;
const duration = data.details?.duration_ms;
if (!data.file || !Number.isFinite(duration)) continue;
const file = relativeFile(data.file);
const end = Date.now();
const start = end - duration;
const previous = files.get(file);
files.set(file, {
start: Math.min(previous?.start ?? start, start),
end: Math.max(previous?.end ?? end, end),
failed: previous?.failed || (!data.skip && !data.details.passed),
});
// File completion events may use the CLI's relative argument as their
// name while data.file is absolute. They are containers, not test cases.
if (path.resolve(data.name) === path.resolve(data.file) || data.details.type === "suite") continue;
record({
kind: "test", parent: file,
id: String(index++), name: data.name, file, start, duration,
status: data.skip ? "skipped" : data.details.passed ? "passed" : "failed",
clock: "reporter-receipt",
});
}
for (const [file, timing] of files) {
record({ kind: "file", id: file, name: file, file,
start: timing.start, duration: timing.end - timing.start,
status: timing.failed ? "failed" : "passed", clock: "reporter-receipt" });
}
record({ kind: "end" });
}
+16
View File
@@ -0,0 +1,16 @@
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { invocationEnv } from "./record.mjs";
const args = ["--test"];
if (process.env.PAPERCLIP_CI_PROFILE === "true") {
args.push("--test-reporter=spec", "--test-reporter-destination=stdout",
`--test-reporter=${fileURLToPath(new URL("./node-reporter.mjs", import.meta.url))}`,
"--test-reporter-destination=stdout");
}
const result = spawnSync(process.execPath, [...args, ...process.argv.slice(2)], {
stdio: "inherit", env: invocationEnv("node"),
});
if (result.error) console.error(result.error.message);
if (result.signal) process.kill(process.pid, result.signal);
else process.exitCode = result.status ?? 1;
@@ -0,0 +1,32 @@
import { recorder, relativeFile } from "./record.mjs";
export default class CiProfileReporter {
record = recorder("playwright");
files = new Map();
onBegin() { this.record({ kind: "start" }); }
onTestEnd(test, result) {
const file = relativeFile(test.location.file);
const start = result.startTime.getTime();
const end = start + result.duration;
const parent = `${test.parent.project()?.name || ""}:${file}`;
const previous = this.files.get(parent);
this.files.set(parent, {
kind: "file", id: parent, name: file, file,
start: Math.min(previous?.start ?? start, start),
end: Math.max(previous?.end ?? end, end),
status: previous?.status === "failed" || ["failed", "timedOut"].includes(result.status)
? "failed" : "passed",
});
this.record({
kind: "test", id: `${test.id}:${result.retry}`, parent,
name: test.titlePath().slice(1).join(" > "), file,
start, duration: result.duration, status: result.status, retries: result.retry,
});
}
onEnd() {
for (const { end, ...file } of this.files.values()) {
this.record({ ...file, duration: end - file.start });
}
this.record({ kind: "end" });
}
}
+23
View File
@@ -0,0 +1,23 @@
"""Read timing JSON from an untrusted PR artifact without extracting any files."""
import json
import sys
import zipfile
MAX_BYTES = 32 * 1024 * 1024
with zipfile.ZipFile(sys.argv[1]) as archive:
entries = archive.infolist()
if len(entries) > 5000 or sum(item.file_size for item in entries) > MAX_BYTES:
raise ValueError("CI timing artifact exceeds the supported size")
for item in entries:
if item.is_dir():
continue
if not item.filename.endswith(".ndjson"):
raise ValueError("CI timing artifact contains an unexpected file")
with archive.open(item) as stream:
for line in stream:
if len(line) > 32 * 1024:
raise ValueError("CI timing record exceeds the supported size")
value = json.loads(line)
if not isinstance(value, dict):
raise ValueError("CI timing record must be an object")
print(json.dumps(value, separators=(",", ":")))
+44
View File
@@ -0,0 +1,44 @@
import { appendFileSync, mkdirSync } from "node:fs";
import path from "node:path";
import { randomUUID } from "node:crypto";
// PR processes write timing data only. They never receive a Honeycomb key.
export function recorder(framework, environment = process.env) {
const enabled = environment.PAPERCLIP_CI_PROFILE === "true";
const directory = environment.PAPERCLIP_CI_PROFILE_DIR;
const invocation = environment.PAPERCLIP_CI_PROFILE_INVOCATION || randomUUID();
const file = directory && path.join(directory, `${framework}-${invocation}.ndjson`);
let warned = false;
return (record) => {
if (!enabled || !file) return;
try {
mkdirSync(directory, { recursive: true });
appendFileSync(file, JSON.stringify({
version: 1,
framework,
invocation,
job: environment.PAPERCLIP_CI_PROFILE_JOB,
runId: environment.GITHUB_RUN_ID,
attempt: environment.GITHUB_RUN_ATTEMPT,
...record,
}) + "\n");
} catch {
// Profiling must not change a test's result. The exporter reports gaps.
if (!warned) console.error("CI profile: unable to write test timing data");
warned = true;
}
};
}
// The launcher records intent before starting the test runner. A reporter that
// never loads or never writes anything must still leave a detectable gap.
export function invocationEnv(framework, environment = process.env) {
if (environment.PAPERCLIP_CI_PROFILE !== "true") return environment;
const env = { ...environment, PAPERCLIP_CI_PROFILE_INVOCATION: randomUUID() };
recorder(framework, env)({ kind: "expected" });
return env;
}
export function relativeFile(file) {
return path.relative(process.env.GITHUB_WORKSPACE || process.cwd(), file).replaceAll("\\", "/");
}
+73
View File
@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
const root = fileURLToPath(new URL("../../", import.meta.url));
const scripts = path.join(root, "scripts/ci-profile");
for (const framework of ["vitest", "playwright", "node"]) {
test(`${framework} reports every file with its own child tests and preserves failing exit codes`, () => {
const directory = realpathSync(mkdtempSync(path.join(tmpdir(), "ci-profile-smoke-")));
try {
symlinkSync(path.join(root, "node_modules"), path.join(directory, "node_modules"), "dir");
const output = path.join(directory, "timings");
const fixture = framework === "node"
? 'import { test } from "node:test";'
: framework === "vitest" ? 'import { test } from "vitest";' : 'import { test } from "@playwright/test";';
const filenames = ["probe.test.mjs", "second.test.mjs"];
for (const filename of filenames) writeFileSync(path.join(directory, filename), `${fixture}
test("passing", async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
test("failing", async () => { throw new Error("expected probe failure"); });
`);
let args;
if (framework === "node") {
args = [path.join(scripts, "node-test.mjs"), ...filenames];
} else {
const config = framework === "vitest"
? { test: { include: ["*.test.mjs"], reporters: [path.join(scripts, "vitest-reporter.mjs")] } }
: { testDir: directory, testMatch: "*.test.mjs", workers: 2, retries: 0, reporter: [[path.join(scripts, "playwright-reporter.mjs")]] };
const configFile = path.join(directory, `${framework}.config.mjs`);
writeFileSync(configFile, `export default ${JSON.stringify(config)};`);
args = framework === "vitest"
? [path.join(root, "node_modules/vitest/vitest.mjs"), "run", "--config", configFile, "--root", directory]
: [path.join(root, "node_modules/@playwright/test/cli.js"), "test", "--config", configFile];
args = [path.join(scripts, "run.mjs"), framework, process.execPath, ...args];
}
const start = Date.now();
const env = { ...process.env };
delete env.NODE_TEST_CONTEXT;
const result = spawnSync(process.execPath, args, {
cwd: directory, encoding: "utf8", timeout: 30_000,
env: { ...env, PAPERCLIP_CI_PROFILE: "true", PAPERCLIP_CI_PROFILE_DIR: output,
PAPERCLIP_CI_PROFILE_JOB: "probe", GITHUB_RUN_ID: "1", GITHUB_RUN_ATTEMPT: "1", GITHUB_WORKSPACE: directory },
});
const end = Date.now();
assert.equal(result.status, 1, result.stdout + result.stderr);
const records = readdirSync(output).flatMap((file) => readFileSync(path.join(output, file), "utf8").trim().split("\n").map(JSON.parse));
const cases = records.filter((record) => record.kind === "test");
assert.equal(cases.length, 4, JSON.stringify(records));
assert.ok(cases.some((record) => record.status === "passed"));
assert.ok(cases.some((record) => record.status === "failed"));
for (const record of cases) {
assert.ok(record.start >= start && record.start <= end, JSON.stringify(record));
assert.ok(record.duration >= 0 && record.start + record.duration <= end + 10);
assert.ok(filenames.includes(record.file));
}
assert.ok(records.some((record) => record.kind === "end"));
const expected = records.filter((record) => record.kind === "expected");
assert.equal(expected.length, 1);
assert.ok(records.every((record) => record.invocation === expected[0].invocation));
const files = records.filter((record) => record.kind === "file");
assert.deepEqual(files.map((record) => record.name).sort(), filenames);
for (const file of files) {
const children = cases.filter((record) => record.parent === file.id);
assert.equal(children.length, 2);
assert.ok(children.every((record) => record.file === file.file));
}
} finally { rmSync(directory, { recursive: true, force: true }); }
});
}
+9
View File
@@ -0,0 +1,9 @@
import { spawnSync } from "node:child_process";
import { invocationEnv } from "./record.mjs";
const [framework, command, ...args] = process.argv.slice(2);
if (!["vitest", "playwright"].includes(framework) || !command) throw new Error("Provide a framework and test command");
const result = spawnSync(command, args, { stdio: "inherit", env: invocationEnv(framework) });
if (result.error) console.error(result.error.message);
if (result.signal) process.kill(process.pid, result.signal);
else process.exitCode = result.status ?? 1;
+176
View File
@@ -0,0 +1,176 @@
import { createHash } from "node:crypto";
const millis = (value) => Date.parse(value);
const id = (value, size = 16) => createHash("sha256").update(value).digest("hex").slice(0, size);
const failed = (status) => ["failure", "failed", "timed_out", "timedOut", "cancelled", "interrupted"].includes(status);
const text = (value) => typeof value === "string" && value.length <= 4096;
export function buildTrace(run, jobs, records = []) {
if (run.status !== "completed" || !jobs.length || jobs.some((job) => job.status !== "completed")) {
throw new Error("A complete workflow attempt and its full job inventory are required");
}
if (jobs.some((job) => job.run_id !== run.id || job.run_attempt !== run.run_attempt)) {
throw new Error("Job inventory contains another run or attempt");
}
const key = `${run.repository.full_name}:${run.id}:${run.run_attempt}`;
const traceId = id(key, 32);
const rootId = id(`${key}:root`);
const start = millis(run.run_attempt === 1 ? run.created_at : run.run_started_at);
// The attempt jobs API can relabel carried-over successful jobs with the
// new attempt number. Their completion clocks still precede this attempt.
const carriedJobs = run.run_attempt > 1 ? jobs.filter((job) => millis(job.completed_at) < start) : [];
jobs = jobs.filter((job) => !carriedJobs.includes(job));
if (!jobs.length) throw new Error("No jobs executed in this workflow attempt");
const end = Math.max(...jobs.map((job) => millis(job.completed_at)).filter(Number.isFinite));
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) throw new Error("Invalid run timing");
const events = [];
const warnings = [];
if (carriedJobs.length) warnings.push(`${carriedJobs.length} completed jobs carried over from earlier attempts were excluded`);
const shared = {
"service.name": "paperclip-ci",
"trace.trace_id": traceId,
"ci.repository": run.repository.full_name,
"ci.run_id": String(run.id),
"ci.attempt": run.run_attempt,
"ci.commit": run.head_sha,
"ci.branch": run.head_branch,
"ci.url": run.html_url,
};
const span = (spanId, parent, name, from, to, fields) => {
if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return;
events.push({
time: new Date(from).toISOString(), samplerate: 1,
data: { ...shared, "trace.span_id": spanId, ...(parent ? { "trace.parent_id": parent } : {}),
name, duration_ms: to - from, ...fields },
});
};
span(rootId, null, `PR CI #${run.run_number || run.id} · attempt ${run.run_attempt}`, start, end, {
"ci.kind": "workflow", "ci.status": run.conclusion, error: failed(run.conclusion),
"ci.job_count": jobs.length,
"ci.carried_job_count": carriedJobs.length,
"ci.wall_ms": end - start,
// A partial rerun intentionally does not re-execute successful jobs.
"ci.partial_rerun": run.run_attempt > 1,
});
const roots = new Map();
const byName = new Map();
for (const job of jobs) {
const jobId = id(`${key}:job:${job.id}`);
roots.set(job.id, jobId);
byName.set(job.name.replace(/^ci \/ /, ""), job);
const created = Math.max(start, millis(job.created_at) || start);
const started = Math.max(created, millis(job.started_at) || created);
const completed = Math.max(started, millis(job.completed_at) || started);
// Include allocation wait in the job container, then distinguish it from
// execution. Dependency wait may precede job creation; it stays visible as
// a gap from the workflow start. GitHub does not expose a separate readyAt.
span(jobId, rootId, job.name, Math.min(created, started), completed, {
"ci.kind": "job", "ci.job": job.name, "ci.job_id": String(job.id),
"ci.status": job.conclusion, error: failed(job.conclusion),
"ci.runner": job.runner_name, "ci.runner_labels": (job.labels || []).join(","),
"ci.start_offset_ms": started - start,
"ci.execution_ms": Math.max(0, completed - started),
"ci.finishes_last": completed === end,
"ci.job_url": job.html_url,
});
if (started > created) span(id(`${jobId}:wait`), jobId, "Waiting for runner / scheduling", created, started, {
"ci.kind": "wait", "ci.job": job.name,
});
for (const step of job.steps || []) {
const from = millis(step.started_at);
const to = millis(step.completed_at);
if (from < start || to > end) continue; // skipped steps may use epoch timestamps
span(id(`${jobId}:step:${step.number}`), jobId, step.name, from, to, {
"ci.kind": "step", "ci.job": job.name, "ci.step": step.name,
"ci.status": step.conclusion, error: failed(step.conclusion),
});
}
}
const receipts = new Set();
const invocations = new Map();
const valid = [];
let rejected = 0;
for (const record of records) {
const job = byName.get(record?.job);
if (!job || record.version !== 1 || String(record.runId) !== String(run.id)
|| String(record.attempt) !== String(run.run_attempt) || !text(record.invocation)) {
rejected++; continue;
}
if (record.kind === "job") { receipts.add(job.id); continue; }
const invocation = `${job.id}:${record.framework}:${record.invocation}`;
if (["expected", "start", "end"].includes(record.kind)) {
const state = invocations.get(invocation) || {};
state[record.kind] = true;
invocations.set(invocation, state);
continue;
}
if (!["file", "test"].includes(record.kind) || !text(record.id) || !text(record.name)
|| !text(record.file) || !["vitest", "playwright", "node"].includes(record.framework)
|| !Number.isFinite(record.start) || !Number.isFinite(record.duration) || record.duration < 0
|| record.start < millis(job.started_at) - 1000
|| record.start + record.duration > millis(job.completed_at) + 1000) {
rejected++; continue;
}
valid.push({ record, job, invocation });
}
const recordId = ({ record, invocation }) => id(`${key}:${invocation}:${record.kind}:${record.id}`);
const files = new Map(valid.filter(({ record }) => record.kind === "file")
.map((entry) => [`${entry.invocation}:${entry.record.id}`, recordId(entry)]));
for (const entry of valid) {
const { record, job, invocation } = entry;
const jobId = roots.get(job.id);
// GitHub steps are ordered and have second-precision timestamps. Prefer
// the latest containing step: rounding may also make a preceding step fit
// a short file/test. Preserve the runner's own clock.
const step = job.steps?.findLast((candidate) => record.start >= millis(candidate.started_at)
&& record.start + record.duration <= millis(candidate.completed_at) + 1000
&& candidate.conclusion !== "skipped");
const parent = files.get(`${invocation}:${record.parent}`)
|| (step ? id(`${jobId}:step:${step.number}`) : jobId);
span(recordId(entry), parent, record.name, record.start, record.start + record.duration, {
"ci.kind": record.kind, "ci.job": job.name, "test.framework": record.framework,
"test.file": record.file, "test.status": typeof record.status === "string" ? record.status.slice(0, 32) : "unknown", error: failed(record.status),
"test.retries": Number.isFinite(record.retries) ? record.retries : 0,
"test.clock": record.clock === "reporter-receipt" ? "reporter-receipt" : "runner",
...(Number.isFinite(record.collectMs) ? { "test.collect_ms": record.collectMs } : {}),
...(Number.isFinite(record.setupMs) ? { "test.setup_ms": record.setupMs } : {}),
});
}
const expectedReceipts = jobs.filter((job) => job.steps?.some((step) => step.name === "Initialize CI profile"));
const missing = expectedReceipts.filter((job) => !receipts.has(job.id));
if (missing.length) warnings.push(`Missing timing artifacts for ${missing.length} jobs`);
const incomplete = [...invocations.values()].filter((value) => !value.expected || !value.start || !value.end).length;
if (incomplete) warnings.push(`${incomplete} test invocations did not finish reporting`);
if (rejected) warnings.push(`${rejected} invalid timing records ignored`);
if (run.conclusion !== "success") warnings.push("Workflow did not succeed; later tests or steps may not have run");
if (!expectedReceipts.length) warnings.push("Job/step timings only; no test reporter collection steps were present");
const root = events[0].data;
const expectedFramework = (name) => {
if (/General tests|Verify serialized server suites|Verify Paperclip Runner \(vitest/.test(name)) return "vitest";
if (/e2e shard/.test(name)) return "playwright";
if (/\/ policy$|Typecheck \+ Release Registry|Verify Paperclip Runner \(static checks/.test(name)) return "node";
return null;
};
const missingTests = jobs.filter((job) => {
const framework = expectedFramework(job.name);
return framework && !valid.some((entry) => entry.job.id === job.id && entry.record.framework === framework);
});
if (expectedReceipts.length && missingTests.length) warnings.push(`Missing test detail for ${missingTests.length} test jobs (including jobs that failed before testing)`);
root["ci.test_count"] = valid.filter(({ record }) => record.kind === "test").length;
root["ci.file_count"] = valid.filter(({ record }) => record.kind === "file").length;
root["ci.expected_invocations"] = [...invocations.values()].filter((value) => value.expected).length;
root["ci.reported_invocations"] = [...invocations.values()].filter((value) => value.expected && value.start && value.end).length;
root["ci.profile_complete"] = warnings.length === 0 && run.run_attempt === 1;
root["ci.coverage_warnings"] = warnings.join("; ");
return { traceId, events, warnings, wallMs: end - start, jobCount: jobs.length };
}
export function traceUrl(team, environment, dataset, trace) {
const params = new URLSearchParams({
trace_id: trace.traceId,
trace_start_ts: String(Math.floor(Date.parse(trace.events[0].time) / 1000)),
trace_end_ts: String(Math.ceil((Date.parse(trace.events[0].time) + trace.wallMs) / 1000)),
});
return `https://ui.honeycomb.io/${encodeURIComponent(team)}/environments/${encodeURIComponent(environment)}/datasets/${encodeURIComponent(dataset)}/trace?${params}`;
}
+31
View File
@@ -0,0 +1,31 @@
import { recorder, relativeFile } from "./record.mjs";
import { experimental_getRunnerTask } from "vitest/node";
export default class CiProfileReporter {
record = recorder("vitest");
onTestRunStart() { this.record({ kind: "start" }); }
onTestRunEnd() { this.record({ kind: "end" }); }
onTestCaseResult(test) {
const timing = test.diagnostic();
if (!timing?.startTime) return;
this.record({
kind: "test", id: test.id, parent: test.module.id,
name: test.fullName, file: relativeFile(test.module.moduleId),
start: timing.startTime, duration: timing.duration,
status: test.result().state, retries: timing.retryCount,
});
}
onTestModuleEnd(module) {
const timing = module.diagnostic();
// ModuleDiagnostic has no startTime in Vitest 5. Use the worker's
// timestamp, not the time the controller receives this reporter event.
const start = experimental_getRunnerTask(module).result?.startTime;
if (!start) return;
this.record({
kind: "file", id: module.id, name: relativeFile(module.moduleId),
file: relativeFile(module.moduleId), start,
duration: timing.duration, status: module.state(),
collectMs: timing.collectDuration, setupMs: timing.setupDuration,
});
}
}
+5 -3
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadShardDurations, selectGeneralServerShard } from "./general-server-shard.mjs";
import { invocationEnv } from "./ci-profile/record.mjs";
import { assertSelectedTests, partitionTestLines } from "./test-line-shard.mjs";
@@ -435,7 +436,7 @@ function runVitest(args, label, testShard = null) {
}
const result = spawnSync("pnpm", ["exec", "vitest", "run", ...sourceOnlyVitestArgs, ...args], {
cwd: repoRoot,
env,
env: invocationEnv("vitest", env),
stdio: "inherit",
});
if (result.error) {
@@ -598,7 +599,8 @@ if (options.dryRun) {
options.mode === serializedModeName
? selectSerializedSuites(routeTests, options.shardIndex, options.shardCount)
: routeTests;
console.log(
// The inventory exceeds pipe buffers; flush it before the immediate exit.
writeFileSync(1,
JSON.stringify(
{
mode: options.mode,
@@ -8,6 +8,22 @@ describe("saved harness verification", () => {
adapterType: "test", status, testedAt: new Date().toISOString(),
checks: codes.map(code => ({ code, level: "info", message: code })),
});
it.each([
["opencode_hello_probe_auth_required", "harness_auth_failed"],
["opencode_hello_probe_model_unavailable", "harness_model_unavailable"],
["opencode_command_unresolvable", "harness_command_unavailable"],
["opencode_cwd_invalid", "harness_cwd_invalid"],
["opencode_hello_probe_timed_out", "harness_probe_timed_out"],
["opencode_hello_probe_unexpected_output", "harness_probe_unexpected_output"],
["private provider error", "harness_test_failed"],
])("classifies %s without keeping provider output", (checkCode, code) => {
try {
assertHarnessTestPassed(result("fail", [checkCode]));
expect.fail("Expected verification to fail");
} catch (error) {
expect(error).toMatchObject({ code, message: "The harness test failed" });
}
});
it.each([
["fail", []], ["warn", ["claude_hello_probe_auth_required"]],
["warn", ["gemini_hello_probe_timed_out"]], ["pass", ["claude_hello_probe_skipped_custom_command"]],
+3
View File
@@ -243,6 +243,9 @@ describe("durable Dot Runner integration", () => {
try {
const requests = await Promise.all([1, 2, 3].map(() => f.broker.requestTurn(f.principal, "Create hello and assign it to my owner", requestId)));
expect(requests[1]).toEqual(requests[0]); expect(requests[2]).toEqual(requests[0]);
expect(requests[0]!.instruction).toContain(provider === "grok_bot"
? "Queued admission does not start another Grok routine"
: "Events also notify queued work");
const intake = await db.select().from(issues).where(eq(issues.id, requests[0]!.issueId));
expect(intake).toHaveLength(1);
expect(intake[0]).toMatchObject({ assigneeAgentId: f.agent.id, createdByAgentId: f.agent.id, responsibleUserId: f.userId });
@@ -289,6 +289,28 @@ describe("durable Grok routine qualification", () => {
expect((await dotRunnerBroker(db).read(f.principal, work.id)).toolUsageInstructions).toContain("paperclip_grok_operation_status");
});
it.each(["new offer", "prepared before restart"] as const)("does not echo delayed Bot-initiated work: %s", async scenario => {
const f = await fixture(); const { work, run } = await admittedWork(f, "offered");
await db.update(heartbeatRuns).set({ contextSnapshot: { grokProactiveRequest: {
requestId: randomUUID(), bindingId: f.pairing.bindingId, bindingGeneration: 1,
} } }).where(eq(heartbeatRuns.id, run.id));
await db.update(dotRunnerAssignments).set({ createdAt: new Date(Date.now() - 60_000) }).where(eq(dotRunnerAssignments.id, work.id));
const legacy = scenario === "prepared before restart" ? await f.service.reserve({
companyId: f.company.id, agentId: f.agent.id, bindingId: f.pairing.bindingId,
assignmentId: work.id, kind: "mailbox_updated", sourceEventId: `proactive-admission:${work.id}`,
}) : null;
f.delivery.mockClear(); const dispatcher = createGrokRoutineDispatcher(db, () => true, f.delivery);
await dispatcher.tick(); await dispatcher.tick();
expect(f.delivery).not.toHaveBeenCalled();
const wakes = await db.select().from(grokBotDispatches).where(eq(grokBotDispatches.assignmentId, work.id));
if (legacy) expect(wakes).toEqual([expect.objectContaining({ id: legacy.id, status: "cancelled" })]);
else expect(wakes).toEqual([]);
// The original conversation still owns and can accept the polled offer.
await expect(dotRunnerBroker(db).read(f.principal, work.id)).resolves.toBeDefined();
await (await acceptance(f, work))();
expect((await db.select().from(dotRunnerAssignments).where(eq(dotRunnerAssignments.id, work.id)))[0]?.status).toBe("accepted");
});
it("cancels a queued wake when its assignment was already accepted", async () => {
const f = await fixture(); const { work } = await admittedWork(f, "offered");
const queued = await f.service.reserve({ companyId: f.company.id, agentId: f.agent.id,
@@ -5,6 +5,7 @@ import type { LifecycleAgent, LifecycleStore, LifecycleDriver } from "../applica
import { startAgentLifecycle } from "../index.js";
import { createAgentLifecycleEffects } from "../../../services/agent-lifecycle.js";
import { logger } from "../../../middleware/logger.js";
import { lifecycleFailureMessage } from "../application/failure.js";
const agent: LifecycleAgent = { id: "agent", companyId: "company", lifecycleState: "verifying", lifecycleVersion: 3,
lifecycleRequiredPluginIds: null, lifecycleError: null,
@@ -66,6 +67,30 @@ describe("lifecycle readiness handoff", () => {
});
describe("lifecycle failure diagnostics", () => {
it.each([
["host", "harness_model_unavailable", "Execution configuration check failed. The selected model is unavailable."],
["plugin", "required_plugin_unavailable", "Execution configuration check: required plugin step failed. A required plugin is unavailable."],
["select_plugins", "unknown private code", "Execution configuration check: finding required plugins failed. Paperclip could not complete this step."],
["transition", "40P01", "Execution configuration check: saving progress failed. Paperclip could not complete this step."],
] as const)("persists safe actionable errors for %s", async (stage, code, expected) => {
const { store, driver, worker } = fixture();
const error = Object.assign(new Error("secret provider output"), { code });
if (stage === "host") driver.runHost.mockRejectedValue(error);
if (stage === "plugin") driver.runPlugin.mockRejectedValue(error);
if (stage === "select_plugins") driver.requiredPluginIds.mockRejectedValue(error);
if (stage === "transition") store.change.mockRejectedValue(error);
await worker.process(agent.id);
expect(store.defer).toHaveBeenCalledWith(agent, expect.any(String), expect.stringContaining(expected), expect.any(Date));
expect(JSON.stringify(store.defer.mock.calls)).not.toContain("secret provider output");
expect(JSON.stringify(store.defer.mock.calls)).not.toContain("unknown private code");
await worker.stop();
});
it("does not treat prototype properties as allowed failure codes", () => {
expect(lifecycleFailureMessage({ stage: "host", phase: "verifying" }, { code: "toString" }))
.toContain("Paperclip could not complete this step");
});
it.each(["host", "plugin", "claim", "renew", "scan", "defer"] as const)("reports %s failures with the available context", async stage => {
const { store, driver, report, worker } = fixture();
const error = new Error("private provider detail");
@@ -100,6 +125,7 @@ describe("lifecycle failure diagnostics", () => {
it.each([
[{ code: "required_plugin_unavailable" }, "required_plugin_unavailable"],
[{ code: "harness_test_failed" }, "harness_test_failed"],
[{ code: "harness_auth_failed" }, "harness_auth_failed"],
[{ cause: { code: "40P01", message: "private query" } }, "40P01"],
[{ code: "private credential value" }, "unclassified"],
])("logs known codes without provider error contents", (details, failureCode) => {
@@ -140,7 +140,7 @@ const support = await getEmbeddedPostgresTestSupport();
const work = worker(createLifecycleDriver(db, {} as never).runHost);
await work.process(agent.id);
expect(probe).toHaveBeenCalled();
expect(await current(agent.id)).toMatchObject({ lifecycleState: "verifying", lifecycleError: "The lifecycle step failed. Retry the operation." });
expect(await current(agent.id)).toMatchObject({ lifecycleState: "verifying", lifecycleError: expect.stringContaining("Execution configuration check failed. Authentication failed.") });
} finally { probe.mockRestore(); }
});
@@ -0,0 +1,40 @@
import type { LifecycleFailure } from "./ports.js";
// Only fixed text may leave this boundary. Provider errors can contain secrets.
const reasons = {
harness_test_failed: "The execution test did not pass. Open Configuration and run the agent test to inspect the failed checks.",
harness_auth_failed: "Authentication failed. Check the agent's AI connection or sign in to its execution tool, then retry.",
harness_model_unavailable: "The selected model is unavailable. Choose a model supported by the agent's AI connection in Configuration, then retry.",
harness_command_unavailable: "The execution command was not found. Check the command and its installation in the agent's environment, then retry.",
harness_cwd_invalid: "The working directory is unavailable. Check the working directory in Configuration, then retry.",
harness_probe_timed_out: "The execution test timed out. Paperclip will retry automatically. If this continues, run the agent test in Configuration.",
harness_probe_unexpected_output: "The execution test did not return the expected response. Run the agent test in Configuration to inspect the failed checks.",
test_environment_unavailable: "The test environment is unavailable. Check the agent's environment in Configuration. Paperclip will retry automatically.",
required_plugin_unavailable: "A required plugin is unavailable. Check the plugin's status in Settings. Paperclip will retry automatically.",
invalid_lifecycle_result: "A required plugin returned an invalid result. Check the plugin's status in Settings. Paperclip will retry automatically.",
} as const;
const transientCodes = new Set(["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "40001", "40P01", "53300", "57P01"]);
export function lifecycleFailureCode(error: unknown): string | undefined {
const candidate = error as { code?: unknown; cause?: { code?: unknown } } | null;
return [candidate?.code, candidate?.cause?.code].find((code): code is string =>
typeof code === "string" && (Object.hasOwn(reasons, code) || transientCodes.has(code)));
}
export function lifecycleFailureMessage(context: LifecycleFailure, error: unknown): string {
const phases = {
preparing: "Agent preparation", verifying: "Execution configuration check", pausing: "Stopping agent execution",
resuming: "Starting agent resources", terminating: "Stopping agent execution", cleaning_up: "Agent resource cleanup",
};
const phase = context.phase && Object.hasOwn(phases, context.phase)
? phases[context.phase as keyof typeof phases] : "Agent setup";
const step = context.stage === "plugin" ? `${phase}: required plugin step`
: context.stage === "select_plugins" ? `${phase}: finding required plugins`
: ["save_plugins", "complete_host", "complete_plugin", "transition"].includes(context.stage)
? `${phase}: saving progress` : phase;
const code = lifecycleFailureCode(error);
const reason = code && Object.hasOwn(reasons, code) ? reasons[code as keyof typeof reasons]
: "Paperclip could not complete this step. It will retry automatically. If this continues, contact your instance administrator.";
return `${step} failed. ${reason}`;
}
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import type { LifecycleDriver, LifecycleStore, LifecycleFailure } from "./ports.js";
import { isLifecycleWorkPending } from "../domain/policy.js";
import { lifecycleFailureMessage } from "./failure.js";
/** Database leases protect each attempt across server processes. */
export function createLifecycleWorker(store: LifecycleStore, driver: LifecycleDriver, reportFailure: (context: LifecycleFailure, error: unknown) => void, canRun = () => true) {
@@ -20,10 +21,13 @@ export function createLifecycleWorker(store: LifecycleStore, driver: LifecycleDr
if (!agent || !isLifecycleWorkPending(agent.lifecycleState)) return;
const context = { agentId: agent.id, companyId: agent.companyId, phase: agent.lifecycleState,
operationId: agent.lifecycleOperation?.id, version: agent.lifecycleVersion };
const runStep = <T>(stage: LifecycleFailure["stage"], work: () => Promise<T>, pluginId?: string) =>
attempt({ ...context, stage, ...(pluginId ? { pluginId } : {}) }, work);
let failureContext: LifecycleFailure = { ...context, stage: "host" };
const runStep = <T>(stage: LifecycleFailure["stage"], work: () => Promise<T>, pluginId?: string) => {
const stepContext = { ...context, stage, ...(pluginId ? { pluginId } : {}) };
return attempt(stepContext, work).catch(error => { failureContext = stepContext; throw error; });
};
const renewal = setInterval(() => {
void runStep("renew", () => store.renew(agent, owner, new Date())).catch(() => {});
void attempt({ ...context, stage: "renew" }, () => store.renew(agent, owner, new Date())).catch(() => {});
}, 30_000);
renewal.unref?.();
try {
@@ -65,8 +69,9 @@ export function createLifecycleWorker(store: LifecycleStore, driver: LifecycleDr
async () => { await driver.onReady?.(changed); }).catch(() => {});
}
if (changed && !isLifecycleWorkPending(changed.lifecycleState)) return;
} catch {
if (!await runStep("defer", () => store.defer(agent, owner, "The lifecycle step failed. Retry the operation.", new Date()))) continue;
} catch (error) {
const message = lifecycleFailureMessage(failureContext, error);
if (!await runStep("defer", () => store.defer(agent, owner, message, new Date()))) continue;
return;
} finally {
clearInterval(renewal);
@@ -142,3 +142,5 @@ export function startAgentLifecycle(db: Db, effects: LifecycleEffects, driver: L
},
};
}
export { lifecycleFailureCode } from "./application/failure.js";
@@ -19,7 +19,17 @@ export function assertHarnessTestPassed(result: AdapterEnvironmentTestResult) {
const authenticationFailed = result.checks.some(check =>
check.code === ADAPTER_AUTH_MISSING_CHECK_CODE || /_hello_probe_auth_required$/.test(check.code));
const incomplete = !verified && (result.status === "warn" || result.checks.some(check => check.code.includes("hello_probe")));
if (result.status === "fail" || authenticationFailed || incomplete) throw Object.assign(new Error("The harness test failed"), { code: "harness_test_failed" });
if (result.status === "fail" || authenticationFailed || incomplete) {
const has = (suffix: string) => result.checks.some(check => check.code.endsWith(suffix));
const code = authenticationFailed ? "harness_auth_failed"
: has("_hello_probe_model_unavailable") || has("_model_invalid") ? "harness_model_unavailable"
: has("_command_unresolvable") ? "harness_command_unavailable"
: has("_cwd_invalid") ? "harness_cwd_invalid"
: has("_hello_probe_timed_out") ? "harness_probe_timed_out"
: has("_hello_probe_unexpected_output") ? "harness_probe_unexpected_output"
: "harness_test_failed";
throw Object.assign(new Error("The harness test failed"), { code });
}
}
export function agentHarnessVerificationService(db: Db, manager: PluginWorkerManager) {
+1 -5
View File
@@ -16,11 +16,7 @@ export type { LifecycleDriver, LifecycleAgent } from "../modules/agent-lifecycle
export function createAgentLifecycleEffects(hooks: BudgetServiceHooks = {}): lifecycle.LifecycleEffects {
return {
reportFailure(context, error) {
// Only known codes are safe; provider errors can contain credentials or private data.
const allowedCodes = new Set(["required_plugin_unavailable", "invalid_lifecycle_result", "harness_test_failed",
"test_environment_unavailable", "ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "40001", "40P01", "53300", "57P01"]);
const candidate = error as { code?: unknown; cause?: { code?: unknown } } | null;
const code = [candidate?.code, candidate?.cause?.code].find(value => typeof value === "string" && allowedCodes.has(value));
const code = lifecycle.lifecycleFailureCode(error);
logger.warn({ ...context, failureCode: code ?? "unclassified" }, "Agent lifecycle operation failed");
},
transaction: withAccountingTransaction,
+16 -3
View File
@@ -249,6 +249,16 @@ function createBroker(db: Db) {
if (options.requireOffered && authority.assignment.status !== "offered") {
throw fail("This work was already accepted. Read its feedback and operation receipts without another routine wake.");
}
if (options.requireOffered && authority.binding.providerKind === "grok_bot") {
// A prepared intent from an older dispatcher must not start a second
// interaction while the original caller is waiting for admission.
const [run] = await db.select({ contextSnapshot: heartbeatRuns.contextSnapshot }).from(heartbeatRuns)
.where(and(eq(heartbeatRuns.id, authority.assignment.runId), eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, agentId)));
const proactive = run?.contextSnapshot?.grokProactiveRequest as Record<string, unknown> | undefined;
if (proactive?.bindingId === authority.binding.id && proactive.bindingGeneration === authority.binding.generation) {
throw fail("This Bot requested the work. Continue polling its original request instead of starting another routine.");
}
}
return authority;
},
async authorizeBinding(principal: McpPrincipal, companyId: string, bindingId: string) {
@@ -390,13 +400,16 @@ function createBroker(db: Db) {
if (!assignment) await new Promise(resolve => setTimeout(resolve, 50));
} while (!assignment && Date.now() < deadline);
return { ...wake, issueId: task.id, identifier: task.identifier, assignment: assignment ?? null,
instruction: wake.status === "admission_failed" ? wake.message : "Drain the inbox now. If assignment is present, read and accept it, then use its catalog. If admission is still pending, retry this provider’s request_turn tool with exactly the same requestId and prompt to check this intake; never create a second request. Events also notify queued work. This intake supplies normal run authority; no human needs to create a preliminary task." };
instruction: wake.status === "admission_failed" ? wake.message : `Drain the inbox now. If assignment is present, read and accept it, then use its catalog. If admission is still pending, retry this provider’s request_turn tool with exactly the same requestId and prompt to check this intake; never create a second request. ${b.providerKind === "grok_bot" ? "Queued admission does not start another Grok routine. Keep checking this request from the current conversation until it is admitted or fails." : "Events also notify queued work."} This intake supplies normal run authority; no human needs to create a preliminary task.` };
},
async requestWork(principal: McpPrincipal, issueId: string, requestId: string) {
const b = await principalBinding(principal);
if (!z.uuid().safeParse(requestId).success) throw fail("Use a stable UUID requestId.");
if (!await enabled(b.providerKind) || !(await this.bindingForAgent(b.companyId, b.agentId))?.wakeVerified) throw fail("External agent admission is unavailable.");
const key = `dot-work:${b.id}:${b.generation}:${requestId}`;
const admissionMessage = b.providerKind === "grok_bot"
? "Normal admission determines when this task can run. Keep checking the original request with the same request ID and arguments, then read and accept its inbox assignment. Queued admission does not start another Grok routine."
: "Normal admission determines when this task can run. Read the mailbox after its event.";
const receipt = async () => (await db.select().from(agentWakeupRequests).where(and(eq(agentWakeupRequests.companyId, b.companyId), eq(agentWakeupRequests.agentId, b.agentId), eq(agentWakeupRequests.idempotencyKey, key))).limit(1))[0];
const replay = async (row: typeof agentWakeupRequests.$inferSelect) => {
if (row.payload?.issueId !== issueId) throw fail("requestId was reused for another task.");
@@ -407,7 +420,7 @@ function createBroker(db: Db) {
return { status: "admission_failed", runId: row.runId, admissionStatus: run.status,
message: "The previous admission ended without an active assignment. Stop waiting for its mailbox event. Inspect the task's run and use normal task recovery after resolving the failure. This request ID retains its original receipt." };
}
return { status: "requested", runId: row.runId, message: "Normal admission determines when this task can run. Read the mailbox after its event." };
return { status: "requested", runId: row.runId, message: admissionMessage };
};
// A retry is a receipt read, including after the task yielded or finished.
const old = await receipt();
@@ -424,7 +437,7 @@ function createBroker(db: Db) {
payload: { issueId, dotRequestId: requestId }, contextSnapshot: { issueId, ...(b.providerKind === "grok_bot" ? { grokProactiveRequest: { requestId, bindingId: b.id, bindingGeneration: b.generation } } : {}) }, idempotencyKey: key, requestedByActorType: "agent", requestedByActorId: b.agentId,
allowRunCoalescing: false, durableDotRequest: { id: receiptId, companyId: b.companyId, agentId: b.agentId, issueId, requestId, idempotencyKey: key, requestedAt: new Date() } });
const reserved = await receipt();
return reserved ? replay(reserved) : { status: "requested", runId: run?.id ?? null, message: "Normal admission determines when this task can run. Read the mailbox after its event." };
return reserved ? replay(reserved) : { status: "requested", runId: run?.id ?? null, message: admissionMessage };
} catch (error) {
// A concurrent reservation can lose the PK race on a different task.
// Replay only that durable receipt; do not mask unrelated failures.
+5 -13
View File
@@ -1,5 +1,5 @@
import { and, asc, eq, isNull, lt, sql } from "drizzle-orm";
import { grokBotDispatches, grokBotRoutines, dotAgentBindings, dotRunnerAssignments, heartbeatRuns, type Db } from "@paperclipai/db";
import { and, asc, eq } from "drizzle-orm";
import { grokBotDispatches, grokBotRoutines, type Db } from "@paperclipai/db";
import { grokRoutineService } from "./routine.js";
import type { deliverGrokRoutineWake } from "./webhook.js";
import { externalBotEnabled } from "../external-bot-authority.js";
@@ -14,17 +14,9 @@ export function createGrokRoutineDispatcher(db: Db, isBackgroundWorkEnabled: ()
running = true;
try {
const service = grokRoutineService(db, deliver);
// A proactive caller receives an immediate assignment without an echo.
// Queued admission gets one delayed wake only while still unaccepted.
const proactive = await db.select({ assignment: dotRunnerAssignments }).from(dotRunnerAssignments)
.innerJoin(dotAgentBindings, eq(dotAgentBindings.id, dotRunnerAssignments.bindingId))
.innerJoin(heartbeatRuns, eq(heartbeatRuns.id, dotRunnerAssignments.runId))
.where(and(eq(dotAgentBindings.providerKind, "grok_bot"), isNull(dotAgentBindings.revokedAt), eq(dotRunnerAssignments.status, "offered"),
lt(dotRunnerAssignments.createdAt, new Date(Date.now() - 10_000)), sql`${heartbeatRuns.contextSnapshot}->'grokProactiveRequest'->>'bindingId' = ${dotAgentBindings.id}::text`)).limit(10);
for (const { assignment } of proactive) {
try { await service.reserve({ companyId: assignment.companyId, agentId: assignment.agentId, bindingId: assignment.bindingId,
assignmentId: assignment.id, kind: "mailbox_updated", sourceEventId: `proactive-admission:${assignment.id}` }); } catch { /* Current authority is rechecked on reservation and send. */ }
}
// Bot-initiated callers poll their durable request while admission is
// pending. Delay does not prove that the originating conversation ended.
// Starting a webhook here would create a competing Grok interaction.
// An accepted session already has a routine handling it. Quiet MCP
// traffic does not prove that routine ended. Keep feedback and receipts
// durable for the Bot to read instead of starting overlapping work.
+6 -1
View File
@@ -95,5 +95,10 @@ export default defineConfig({
},
},
outputDir: "./test-results",
reporter: [["list"], ["html", { open: "never", outputFolder: "./playwright-report" }]],
reporter: [
["list"], ["html", { open: "never", outputFolder: "./playwright-report" }],
...(process.env.PAPERCLIP_CI_PROFILE === "true"
? [[path.resolve(import.meta.dirname, "../../scripts/ci-profile/playwright-reporter.mjs")] as [string]]
: []),
],
});
@@ -34,7 +34,9 @@ it("retries a failed step once while pending and removes the failure after recov
try {
await act(async () => root.render(<AgentLifecycleStatus agent={agent} onRetry={retry} retryPending={false} />));
expect(container.textContent).toContain(agent.lifecycleError);
expect(container.textContent).toContain("Termination is not complete");
expect(container.textContent).toContain("Agent cleanup failed");
expect(container.textContent).toContain("Paperclip retries automatically");
expect(container.textContent).not.toContain("Required resources are being removed");
await act(async () => container.querySelector("button")!.click());
expect(retry).toHaveBeenCalledOnce();
await act(async () => root.render(<AgentLifecycleStatus agent={agent} onRetry={retry} retryPending />));
+10 -3
View File
@@ -15,6 +15,12 @@ const phases = {
cleaning_up: ["Cleaning up agent", "Required resources are being removed. Termination is not complete."],
} satisfies Partial<Record<AgentLifecycleState, [string, string]>>;
const failedPhases = {
preparing: "Agent preparation failed", verifying: "Agent verification failed",
pausing: "Agent pause failed", resuming: "Agent resume failed",
terminating: "Agent termination failed", cleaning_up: "Agent cleanup failed",
};
function pendingPhase(state?: AgentLifecycleState) {
return state && state in phases ? phases[state as keyof typeof phases] : undefined;
}
@@ -48,15 +54,16 @@ export function AgentLifecycleStatus({ agent, onRetry, retryPending, refreshErro
{phase && <div className="flex flex-wrap items-center gap-2">
<Badge variant={agent.lifecycleError ? "destructive" : "secondary"}>
{!agent.lifecycleError && <Loader2 aria-hidden="true" className="mr-1 size-3 animate-spin motion-reduce:animate-none" />}
{phase[0]}
{agent.lifecycleError ? failedPhases[agent.lifecycleState as keyof typeof failedPhases] : phase[0]}
</Badge>
<span className="text-muted-foreground">{phase[1]}</span>
{!agent.lifecycleError && <span className="text-muted-foreground">{phase[1]}</span>}
</div>}
{phase && agent.lifecycleError && <div className="flex flex-wrap items-center gap-2 text-destructive">
<span>{agent.lifecycleError}</span>
<Button variant="outline" size="sm" disabled={retryPending} onClick={onRetry}>
{retryPending ? "Retrying…" : "Retry"}
{retryPending ? "Retrying…" : "Retry now"}
</Button>
</div>}
{phase && agent.lifecycleError && <p className="text-muted-foreground">Paperclip retries automatically; Retry now checks again immediately.</p>}
</div>;
}
+4 -1
View File
@@ -1,7 +1,10 @@
import { defineConfig } from "vitest/config";
import { configDefaults, defineConfig } from "vitest/config";
export default defineConfig({
test: {
...(process.env.PAPERCLIP_CI_PROFILE === "true"
? { reporters: [...configDefaults.reporters, "./scripts/ci-profile/vitest-reporter.mjs"] }
: {}),
projects: [
"packages/shared",
"packages/skills-catalog",