mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-09 06:15:21 +02:00
fix(skills): ship the completion helper with the installed skill (#15554)
## Thinking Path > - Paperclip manages work for AI agents. > - Legacy agents use the Paperclip skill to save task status and comments. > - The skill names a script relative to the task workspace. > - That script exists only in the Paperclip source repository. > - Agents in other workspaces can hit a missing command or search for it. > - This PR ships the helper inside the skill and uses the installed skill path. > - The repository command remains available through a forwarding wrapper. ## Linked Issues or Issue Description Fixes #9527. Refs #15548 for the preceding runtime checkout guidance. Related: #6052 addresses LF line endings for the repository helper; this change addresses helper delivery and path resolution. ## What Changed - Bundle the existing issue update helper with the Paperclip skill. Preserve its HTTP checks, echoed-status check and two-attempt limit. - Resolve the command from the installed skill directory. Use a verified PATCH when that path is unavailable, without searching the filesystem. - Keep the repository command as a wrapper that works from any directory. - Test shell execution and exact status/comment payloads through both provider skill-home layouts, including paths with spaces. - Add helper sources and existing verification tests to stock-harness admission. Record an absent historical helper explicitly. Add the missing declaration for the admission fingerprint export. ## Verification - Complete directly affected source suites: 30 tests pass. They cover skill delivery, preserved multiline comments and links, authentication headers, empty responses, mismatched status, transient retries and definitive rejections. - Product E2E typecheck passes. Support suites: 1,835 Vitest tests pass, one is skipped; 128 Node tests pass. - Full local build and workspace typecheck pass. - Full local repository tests are not claimed as passed. Embedded PostgreSQL was unavailable in this worktree during the preceding task; Linux CI will run the repository gates. - The authorized matched Codex/Claude comparison is pending. It uses the existing assigned-skill case and original oracle, one initial attempt per profile and variant. - CI and a fresh Greptile review are pending. Keep this PR in draft until readiness gates complete. ## Risks - Correct path resolution depends on the harness supplying the installed skill path. The instructions use verified PATCH when that path is unavailable. - The helper still requires Bash, curl and jq. Its existing retry and response-verification behavior is unchanged. - Tests use the shared skill-directory symlink mechanism and an HTTP fixture. Real provider completion behavior still requires the bounded live comparison. - This fix does not redesign native completion, legacy recovery or ambiguous transport handling. ## Model Used OpenAI Codex, GPT-6 family. The exact model build and context window are not exposed in this session. Used code editing, shell tools and test execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
71cd0a2621
commit
2f0c485dec
9 files changed
+271
-176
No files matched your search
@@ -1,166 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Keep the repository command compatible; the deployable implementation belongs
|
||||
# to the skill so agents do not need a checkout of this repository.
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
scripts/paperclip-issue-update.sh [--issue-id ID] [--status STATUS] [--comment TEXT] [--dry-run]
|
||||
|
||||
Reads a multiline markdown comment from stdin when stdin is piped. This preserves
|
||||
newlines when building the JSON payload for PATCH /api/issues/{issueId}.
|
||||
|
||||
Examples:
|
||||
scripts/paperclip-issue-update.sh --issue-id "$PAPERCLIP_TASK_ID" --status in_progress <<'MD'
|
||||
Investigating formatting
|
||||
|
||||
- Pulled the raw comment body
|
||||
- Comparing it with the run transcript
|
||||
MD
|
||||
|
||||
scripts/paperclip-issue-update.sh --issue-id "$PAPERCLIP_TASK_ID" --status done --dry-run <<'MD'
|
||||
Done
|
||||
|
||||
- Fixed the issue update helper
|
||||
MD
|
||||
EOF
|
||||
}
|
||||
|
||||
require_command() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
printf 'Missing required command: %s\n' "$1" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
issue_id="${PAPERCLIP_TASK_ID:-}"
|
||||
status=""
|
||||
comment_arg=""
|
||||
dry_run=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--issue-id)
|
||||
issue_id="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--status)
|
||||
status="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--comment)
|
||||
comment_arg="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run=1
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$issue_id" ]]; then
|
||||
printf 'Missing issue id. Pass --issue-id or set PAPERCLIP_TASK_ID.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
comment=""
|
||||
if [[ -n "$comment_arg" ]]; then
|
||||
comment="$comment_arg"
|
||||
elif [[ ! -t 0 ]]; then
|
||||
comment="$(cat)"
|
||||
fi
|
||||
|
||||
require_command jq
|
||||
|
||||
payload="$(
|
||||
jq -nc \
|
||||
--arg status "$status" \
|
||||
--arg comment "$comment" \
|
||||
'
|
||||
(if $status == "" then {} else {status: $status} end) +
|
||||
(if $comment == "" then {} else {comment: $comment} end)
|
||||
'
|
||||
)"
|
||||
|
||||
if [[ "$dry_run" == "1" ]]; then
|
||||
printf '%s\n' "$payload"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -z "${PAPERCLIP_API_URL:-}" || -z "${PAPERCLIP_API_KEY:-}" || -z "${PAPERCLIP_RUN_ID:-}" ]]; then
|
||||
printf 'Missing PAPERCLIP_API_URL, PAPERCLIP_API_KEY, or PAPERCLIP_RUN_ID.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A successful PATCH always returns the updated issue JSON. An empty body or a
|
||||
# connection-level failure means the write did NOT land, even when a pipeline
|
||||
# exit code says otherwise, so verify the response instead of inferring success.
|
||||
# Two attempts total: the shared heartbeat policy stops a control-plane write
|
||||
# after two consecutive failures, so the helper must not send a third.
|
||||
max_attempts=2
|
||||
attempt=1
|
||||
while :; do
|
||||
http_code=""
|
||||
body=""
|
||||
set +e
|
||||
response="$(
|
||||
curl -sS -m 30 -X PATCH \
|
||||
"$PAPERCLIP_API_URL/api/issues/$issue_id" \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary "$payload" \
|
||||
-w '\n%{http_code}'
|
||||
)"
|
||||
curl_exit=$?
|
||||
set -e
|
||||
|
||||
if [[ "$curl_exit" -eq 0 ]]; then
|
||||
http_code="${response##*$'\n'}"
|
||||
body="${response%$'\n'*}"
|
||||
fi
|
||||
|
||||
if [[ "$curl_exit" -eq 0 && "$http_code" == 2* ]]; then
|
||||
if [[ -z "$body" ]]; then
|
||||
printf 'Issue update FAILED: HTTP %s with an empty response body. A real update echoes the issue JSON; treat this write as not saved.\n' "$http_code" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$status" ]]; then
|
||||
returned_status="$(jq -r '.status // empty' <<<"$body" 2>/dev/null || true)"
|
||||
if [[ "$returned_status" != "$status" ]]; then
|
||||
printf 'Issue update FAILED: server echoed status %s instead of requested %s.\n' "${returned_status:-<none>}" "$status" >&2
|
||||
printf '%s\n' "$body" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
printf '%s\n' "$body"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 4xx (other than 429) is a definitive rejection; retrying cannot change it.
|
||||
if [[ "$curl_exit" -eq 0 && "$http_code" == 4* && "$http_code" != "429" ]]; then
|
||||
printf 'Issue update rejected (HTTP %s).\n' "$http_code" >&2
|
||||
[[ -n "$body" ]] && printf '%s\n' "$body" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if (( attempt >= max_attempts )); then
|
||||
printf 'Issue update FAILED after %d attempts (curl exit %s, HTTP %s). The status/comment was NOT saved — report this write as failed, do not assume it landed.\n' "$max_attempts" "$curl_exit" "${http_code:-000}" >&2
|
||||
[[ -n "$body" ]] && printf '%s\n' "$body" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Issue update attempt %d/%d failed (curl exit %s, HTTP %s); retrying...\n' "$attempt" "$max_attempts" "$curl_exit" "${http_code:-000}" >&2
|
||||
sleep $((attempt * 2))
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
paperclip_repo_scripts="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec bash "$paperclip_repo_scripts/../skills/paperclip/scripts/paperclip-issue-update.sh" "$@"
|
||||
@@ -1,15 +1,19 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import http from "node:http";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import { ensurePaperclipSkillSymlink } from "@paperclipai/adapter-utils/server-utils";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
// End-to-end coverage for scripts/paperclip-issue-update.sh: the helper must
|
||||
// End-to-end coverage for the skill-bundled issue update helper: the helper must
|
||||
// only exit 0 when the server confirms the write by echoing the update, must
|
||||
// classify failures (retry connection-level faults and 5xx, never retry a
|
||||
// definitive 4xx), and must stop at two attempts total to honor the shared
|
||||
// bounded-write-retry rule.
|
||||
const HELPER_PATH = path.resolve("scripts/paperclip-issue-update.sh");
|
||||
const HELPER_PATH = path.resolve("skills/paperclip/scripts/paperclip-issue-update.sh");
|
||||
const REPO_HELPER_PATH = path.resolve("scripts/paperclip-issue-update.sh");
|
||||
|
||||
interface HelperResult {
|
||||
code: number | null;
|
||||
@@ -21,6 +25,8 @@ interface RecordedRequest {
|
||||
method: string;
|
||||
url: string;
|
||||
body: string;
|
||||
authorization: string | undefined;
|
||||
runId: string | string[] | undefined;
|
||||
}
|
||||
|
||||
describe("paperclip issue update helper", () => {
|
||||
@@ -48,6 +54,8 @@ describe("paperclip issue update helper", () => {
|
||||
method: req.method ?? "",
|
||||
url: req.url ?? "",
|
||||
body,
|
||||
authorization: req.headers.authorization,
|
||||
runId: req.headers["x-paperclip-run-id"],
|
||||
};
|
||||
requests.push(recorded);
|
||||
respond(recorded, requests.length, res);
|
||||
@@ -66,17 +74,19 @@ describe("paperclip issue update helper", () => {
|
||||
return { baseUrl: `http://127.0.0.1:${port}`, requests };
|
||||
}
|
||||
|
||||
function runHelper(apiUrl: string, args: string[]): Promise<HelperResult> {
|
||||
function runHelper(apiUrl: string, args: string[], options: { helper?: string; cwd?: string; input?: string } = {}): Promise<HelperResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("bash", [HELPER_PATH, ...args], {
|
||||
const child = spawn("bash", [options.helper ?? HELPER_PATH, ...args], {
|
||||
cwd: options.cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
PAPERCLIP_API_URL: apiUrl,
|
||||
PAPERCLIP_API_KEY: "test-key",
|
||||
PAPERCLIP_RUN_ID: "test-run",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
child.stdin.end(options.input);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
@@ -109,6 +119,44 @@ describe("paperclip issue update helper", () => {
|
||||
expect(JSON.parse(requests[0]?.body ?? "{}")).toEqual({ status: "done", comment: "closing note" });
|
||||
});
|
||||
|
||||
it.each([".claude/skills", "codex-home/skills"])(
|
||||
"delivers the bundled helper through %s from an unrelated workspace", async skillsHome => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip completion helper "));
|
||||
cleanupFns.push(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const source = path.join(root, "bundle", "paperclip");
|
||||
const installed = path.join(root, skillsHome, "paperclip");
|
||||
const cwd = path.join(root, "unrelated project");
|
||||
await fs.cp(path.dirname(path.dirname(HELPER_PATH)), source, { recursive: true });
|
||||
await fs.mkdir(path.dirname(installed), { recursive: true });
|
||||
await fs.mkdir(cwd);
|
||||
await ensurePaperclipSkillSymlink(source, installed);
|
||||
await expect(fs.access(path.join(cwd, "scripts/paperclip-issue-update.sh"))).rejects.toThrow();
|
||||
const savedComments: string[] = [];
|
||||
const { baseUrl, requests } = await startServer((request, _attempt, res) => {
|
||||
const payload = JSON.parse(request.body);
|
||||
savedComments.push(payload.comment);
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ id: "issue-1", status: payload.status, comment: { body: payload.comment } }));
|
||||
});
|
||||
const comment = "Done\n\n- Saved [report](/TST/issues/TST-1#document-report)\n- Literal `code`, $HOME and $(not-a-command)";
|
||||
const result = await runHelper(baseUrl, ["--issue-id", "issue-1", "--status", "done"], {
|
||||
helper: path.join(installed, "scripts/paperclip-issue-update.sh"), cwd, input: comment,
|
||||
});
|
||||
expect(result.code).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({ status: "done", comment: { body: comment } });
|
||||
expect(savedComments).toEqual([comment]);
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0]).toMatchObject({ method: "PATCH", url: "/api/issues/issue-1", authorization: "Bearer test-key", runId: "test-run" });
|
||||
});
|
||||
|
||||
it("keeps the repository entrypoint working outside the repository", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-helper-wrapper-"));
|
||||
cleanupFns.push(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const result = await runHelper("", [...doneArgs, "--dry-run"], { helper: REPO_HELPER_PATH, cwd: root });
|
||||
expect(result.code).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toEqual({ status: "done", comment: "closing note" });
|
||||
});
|
||||
|
||||
it("fails an empty 2xx body instead of treating it as success", async () => {
|
||||
const { baseUrl } = await startServer((_request, _attempt, res) => {
|
||||
res.writeHead(200, { "content-length": "0" });
|
||||
|
||||
@@ -547,6 +547,10 @@ describe("paperclip skill utils", () => {
|
||||
expect(skillBody).toContain("Verify writes — never infer them");
|
||||
expect(skillBody).toContain("An empty response body means the write FAILED");
|
||||
expect(skillBody).toContain("Never pipe a disposition write through `head`/`tail`");
|
||||
expect(skillBody).toContain("resolved relative to this installed `SKILL.md`, not the task workspace");
|
||||
expect(skillBody).toContain("do not search the filesystem for it");
|
||||
expect(skillBody).toContain('bash "$paperclip_skill_dir/scripts/paperclip-issue-update.sh"');
|
||||
expect(skillBody).not.toMatch(/^scripts\/paperclip-issue-update\.sh/m);
|
||||
// The helper's verification behavior (HTTP status parsing, retry
|
||||
// classification, attempt bound, exit codes) is exercised end-to-end in
|
||||
// paperclip-issue-update-helper.test.ts against a live local server.
|
||||
|
||||
@@ -210,7 +210,7 @@ the routine server-verified external-chat handoff described above.
|
||||
|
||||
**Bounded write retry.** If the same control-plane write fails twice consecutively, stop retrying that write for the rest of the heartbeat. Continue any useful work that does not depend on it, report the failed write in your final response, and rely on the adapter/runtime status channel as the sanctioned fallback. Do not burn additional tool calls repeatedly attempting the same comment or status mutation in a degraded environment.
|
||||
|
||||
**Verify writes — never infer them.** A successful `PATCH /api/issues/{id}` always returns the updated issue JSON. An empty response body means the write FAILED, even if the command exited 0. Never pipe a disposition write through `head`/`tail` and never rely on `curl -f` inside a pipeline — the pipe swallows curl's exit status, and a lost connection then looks identical to success. Use `scripts/paperclip-issue-update.sh` (it checks the HTTP status, retries connection-level failures, and confirms the echoed `status`); if you must hand-roll curl, capture `-w '%{http_code}'` and check the response echoes your update. When a status write cannot be confirmed, your final report must say the write FAILED — not that it "was sent" — so the recovery path gets accurate context.
|
||||
**Verify writes — never infer them.** A successful `PATCH /api/issues/{id}` always returns the updated issue JSON. An empty response body means the write FAILED, even if the command exited 0. Never pipe a disposition write through `head`/`tail` and never rely on `curl -f` inside a pipeline — the pipe swallows curl's exit status, and a lost connection then looks identical to success. Use the bundled `scripts/paperclip-issue-update.sh`, resolved relative to this installed `SKILL.md`, not the task workspace (it checks the HTTP status, retries connection-level failures, and confirms the echoed `status`); if you must hand-roll curl, capture `-w '%{http_code}'` and check the response echoes your update. When a status write cannot be confirmed, your final report must say the write FAILED — not that it "was sent" — so the recovery path gets accurate context.
|
||||
|
||||
Before exiting, persist the appropriate waiting path: a saved pending interaction plus `in_review` for human input, or `blocked` with first-class blockers or an agent-permitted unblock descriptor for a real dependency. A comment naming someone does not create that path.
|
||||
|
||||
@@ -230,10 +230,10 @@ Headers: X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID
|
||||
{ "status": "done", "comment": "What was done and why." }
|
||||
```
|
||||
|
||||
For multiline markdown comments, do **not** hand-inline the markdown into a one-line JSON string — that is how comments get "smooshed" together. Use the helper below (or an equivalent `jq --arg` pattern reading from a heredoc/file) so literal newlines survive JSON encoding:
|
||||
For multiline comments, use a heredoc/file with the helper (or `jq --arg`) to preserve newlines. Set `paperclip_skill_dir` to the absolute directory containing this installed `SKILL.md`, using the skill path/base directory supplied by your harness. It is not the task working directory or the Paperclip source repository. If that path or helper is unavailable, use the verified PATCH request above; do not search the filesystem for it.
|
||||
|
||||
```bash
|
||||
scripts/paperclip-issue-update.sh --issue-id "$PAPERCLIP_TASK_ID" --status done <<'MD'
|
||||
bash "$paperclip_skill_dir/scripts/paperclip-issue-update.sh" --issue-id "$PAPERCLIP_TASK_ID" --status done <<'MD'
|
||||
Done
|
||||
|
||||
- Fixed the newline-preserving issue update path
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
scripts/paperclip-issue-update.sh [--issue-id ID] [--status STATUS] [--comment TEXT] [--dry-run]
|
||||
|
||||
Reads a multiline markdown comment from stdin when stdin is piped. This preserves
|
||||
newlines when building the JSON payload for PATCH /api/issues/{issueId}.
|
||||
|
||||
Examples:
|
||||
scripts/paperclip-issue-update.sh --issue-id "$PAPERCLIP_TASK_ID" --status in_progress <<'MD'
|
||||
Investigating formatting
|
||||
|
||||
- Pulled the raw comment body
|
||||
- Comparing it with the run transcript
|
||||
MD
|
||||
|
||||
scripts/paperclip-issue-update.sh --issue-id "$PAPERCLIP_TASK_ID" --status done --dry-run <<'MD'
|
||||
Done
|
||||
|
||||
- Fixed the issue update helper
|
||||
MD
|
||||
EOF
|
||||
}
|
||||
|
||||
require_command() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
printf 'Missing required command: %s\n' "$1" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
issue_id="${PAPERCLIP_TASK_ID:-}"
|
||||
status=""
|
||||
comment_arg=""
|
||||
dry_run=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--issue-id)
|
||||
issue_id="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--status)
|
||||
status="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--comment)
|
||||
comment_arg="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run=1
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$issue_id" ]]; then
|
||||
printf 'Missing issue id. Pass --issue-id or set PAPERCLIP_TASK_ID.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
comment=""
|
||||
if [[ -n "$comment_arg" ]]; then
|
||||
comment="$comment_arg"
|
||||
elif [[ ! -t 0 ]]; then
|
||||
comment="$(cat)"
|
||||
fi
|
||||
|
||||
require_command jq
|
||||
|
||||
payload="$(
|
||||
jq -nc \
|
||||
--arg status "$status" \
|
||||
--arg comment "$comment" \
|
||||
'
|
||||
(if $status == "" then {} else {status: $status} end) +
|
||||
(if $comment == "" then {} else {comment: $comment} end)
|
||||
'
|
||||
)"
|
||||
|
||||
if [[ "$dry_run" == "1" ]]; then
|
||||
printf '%s\n' "$payload"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -z "${PAPERCLIP_API_URL:-}" || -z "${PAPERCLIP_API_KEY:-}" || -z "${PAPERCLIP_RUN_ID:-}" ]]; then
|
||||
printf 'Missing PAPERCLIP_API_URL, PAPERCLIP_API_KEY, or PAPERCLIP_RUN_ID.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A successful PATCH always returns the updated issue JSON. An empty body or a
|
||||
# connection-level failure means the write did NOT land, even when a pipeline
|
||||
# exit code says otherwise, so verify the response instead of inferring success.
|
||||
# Two attempts total: the shared heartbeat policy stops a control-plane write
|
||||
# after two consecutive failures, so the helper must not send a third.
|
||||
max_attempts=2
|
||||
attempt=1
|
||||
while :; do
|
||||
http_code=""
|
||||
body=""
|
||||
set +e
|
||||
response="$(
|
||||
curl -sS -m 30 -X PATCH \
|
||||
"$PAPERCLIP_API_URL/api/issues/$issue_id" \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary "$payload" \
|
||||
-w '\n%{http_code}'
|
||||
)"
|
||||
curl_exit=$?
|
||||
set -e
|
||||
|
||||
if [[ "$curl_exit" -eq 0 ]]; then
|
||||
http_code="${response##*$'\n'}"
|
||||
body="${response%$'\n'*}"
|
||||
fi
|
||||
|
||||
if [[ "$curl_exit" -eq 0 && "$http_code" == 2* ]]; then
|
||||
if [[ -z "$body" ]]; then
|
||||
printf 'Issue update FAILED: HTTP %s with an empty response body. A real update echoes the issue JSON; treat this write as not saved.\n' "$http_code" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$status" ]]; then
|
||||
returned_status="$(jq -r '.status // empty' <<<"$body" 2>/dev/null || true)"
|
||||
if [[ "$returned_status" != "$status" ]]; then
|
||||
printf 'Issue update FAILED: server echoed status %s instead of requested %s.\n' "${returned_status:-<none>}" "$status" >&2
|
||||
printf '%s\n' "$body" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
printf '%s\n' "$body"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 4xx (other than 429) is a definitive rejection; retrying cannot change it.
|
||||
if [[ "$curl_exit" -eq 0 && "$http_code" == 4* && "$http_code" != "429" ]]; then
|
||||
printf 'Issue update rejected (HTTP %s).\n' "$http_code" >&2
|
||||
[[ -n "$body" ]] && printf '%s\n' "$body" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if (( attempt >= max_attempts )); then
|
||||
printf 'Issue update FAILED after %d attempts (curl exit %s, HTTP %s). The status/comment was NOT saved — report this write as failed, do not assume it landed.\n' "$max_attempts" "$curl_exit" "${http_code:-000}" >&2
|
||||
[[ -n "$body" ]] && printf '%s\n' "$body" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Issue update attempt %d/%d failed (curl exit %s, HTTP %s); retrying...\n' "$attempt" "$max_attempts" "$curl_exit" "${http_code:-000}" >&2
|
||||
sleep $((attempt * 2))
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
@@ -0,0 +1,4 @@
|
||||
export function sourceFingerprint(): {
|
||||
fingerprint: string;
|
||||
sourceErrors: string[];
|
||||
};
|
||||
@@ -33,10 +33,12 @@ export const stockHarnessGates = [
|
||||
"packages/adapters/opencode-local/src/server/execute.test.ts",
|
||||
"packages/adapters/cursor-cloud/src/server/execute.test.ts",
|
||||
"server/src/__tests__/codex-local-execute.test.ts",
|
||||
"server/src/__tests__/paperclip-issue-update-helper.test.ts",
|
||||
], required: ["keeps task and chat defaults to identity and connection guidance",
|
||||
"does not restore generic procedures on resume or with the legacy opt-in",
|
||||
"integrates the env-free store", "advertises folded routing metadata", "bounds routing descriptions",
|
||||
"loads an old env-bearing file with rotated credentials", "drops a skill that fails to materialize"] },
|
||||
"loads an old env-bearing file with rotated credentials", "drops a skill that fails to materialize", "exits 0 and prints the issue JSON when the server echoes the requested status",
|
||||
"fails an empty 2xx body instead of treating it as success"] },
|
||||
// Hermes is not in the root Vitest project list. Run its package config so
|
||||
// the requested file cannot silently disappear from discovery.
|
||||
{ id: "SH-3-hermes", name: "Hermes shared-prompt delivery", cwd: "packages/adapters/hermes",
|
||||
@@ -113,11 +115,13 @@ export function sourceFingerprint() {
|
||||
const sourceErrors = [];
|
||||
sources.add("skills/paperclip/SKILL.md");
|
||||
sources.add("skills/paperclip/references/issue-documents.md");
|
||||
sources.add("scripts/paperclip-issue-update.sh");
|
||||
sources.add("skills/paperclip/scripts/paperclip-issue-update.sh");
|
||||
for (const source of [...sources].sort()) {
|
||||
hash.update(source);
|
||||
try { hash.update("present\0").update(readFileSync(join(root, source))); }
|
||||
catch (error) {
|
||||
if (source === "skills/paperclip/references/issue-documents.md" && error.code === "ENOENT") hash.update("absent\0");
|
||||
if ((source === "skills/paperclip/references/issue-documents.md" || source === "skills/paperclip/scripts/paperclip-issue-update.sh") && error.code === "ENOENT") hash.update("absent\0");
|
||||
else sourceErrors.push(source);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { sourceFingerprint } from "./stock-harness-checks.mjs";
|
||||
import { stockHarnessSourceDigest, stockHarnessSkillSources } from "./stock-harness.js";
|
||||
|
||||
@@ -17,13 +18,38 @@ describe("stock harness instruction revision", () => {
|
||||
expect(changed.fingerprint).not.toBe(original.fingerprint);
|
||||
});
|
||||
|
||||
it.each(["tests/runner-e2e/checkout-activity.ts", "server/src/onboarding-assets/default/AGENTS.md", "packages/adapter-utils/src/server-utils.ts", "packages/shared/src/connection-intent-guidance.ts", "skills/paperclip/SKILL.md", "skills/paperclip/references/issue-documents.md", "packages/paperclip-runner/generated/capability/capabilities.yaml", "packages/paperclip-runner/spec/capability/capabilities.yaml", "tests/runner-e2e/stock-harness-manifest.ts", "packages/adapter-utils/src/acpx-engine/execute.ts", "packages/adapter-utils/src/acpx-engine/ephemeral-session-environment.ts", "tests/runner-e2e/stock-harness-instruction-variant.mjs", "tests/runner-e2e/automatic-retry.ts", "tests/runner-e2e/catalog.ts"])(
|
||||
it.each(["scripts/paperclip-issue-update.sh", "skills/paperclip/scripts/paperclip-issue-update.sh", "tests/runner-e2e/checkout-activity.ts", "server/src/onboarding-assets/default/AGENTS.md", "packages/adapter-utils/src/server-utils.ts", "packages/shared/src/connection-intent-guidance.ts", "skills/paperclip/SKILL.md", "skills/paperclip/references/issue-documents.md", "packages/paperclip-runner/generated/capability/capabilities.yaml", "packages/paperclip-runner/spec/capability/capabilities.yaml", "tests/runner-e2e/stock-harness-manifest.ts", "packages/adapter-utils/src/acpx-engine/execute.ts", "packages/adapter-utils/src/acpx-engine/ephemeral-session-environment.ts", "tests/runner-e2e/stock-harness-instruction-variant.mjs", "tests/runner-e2e/automatic-retry.ts", "tests/runner-e2e/catalog.ts"])(
|
||||
"changes when the evaluated %s changes", source => {
|
||||
vi.mocked(readFileSync).mockImplementation(() => Buffer.from("unchanged"));
|
||||
const original = stockHarnessSourceDigest();
|
||||
vi.mocked(readFileSync).mockImplementation(file => Buffer.from(String(file).endsWith(source) ? "changed instructions" : "unchanged"));
|
||||
expect(stockHarnessSourceDigest()).not.toBe(original);
|
||||
});
|
||||
it.each(["scripts/paperclip-issue-update.sh", "skills/paperclip/scripts/paperclip-issue-update.sh"])(
|
||||
"invalidates the prerequisite fingerprint when %s changes", source => {
|
||||
vi.mocked(readFileSync).mockImplementation(() => Buffer.from("unchanged"));
|
||||
const original = sourceFingerprint();
|
||||
vi.mocked(readFileSync).mockImplementation(file => Buffer.from(
|
||||
String(file) === resolve(import.meta.dirname, "../..", source) ? "changed helper" : "unchanged"));
|
||||
expect(sourceFingerprint().fingerprint).not.toBe(original.fingerprint);
|
||||
});
|
||||
it("records a missing historical bundled helper without accepting unreadable helper files", () => {
|
||||
vi.mocked(readFileSync).mockImplementation(() => Buffer.from("unchanged"));
|
||||
const original = sourceFingerprint();
|
||||
const digest = stockHarnessSourceDigest();
|
||||
vi.mocked(readFileSync).mockImplementation(file => {
|
||||
if (String(file).endsWith("skills/paperclip/scripts/paperclip-issue-update.sh"))
|
||||
throw Object.assign(new Error("absent"), { code: "ENOENT" });
|
||||
return Buffer.from("unchanged");
|
||||
});
|
||||
expect(sourceFingerprint().sourceErrors).toEqual([]);
|
||||
expect(sourceFingerprint().fingerprint).not.toBe(original.fingerprint);
|
||||
expect(stockHarnessSourceDigest()).not.toBe(digest);
|
||||
expect(stockHarnessSkillSources()[3]).toMatchObject({ present: false, sha256: null });
|
||||
vi.mocked(readFileSync).mockImplementation(() => { throw Object.assign(new Error("unreadable"), { code: "EACCES" }); });
|
||||
expect(stockHarnessSourceDigest).toThrow("unreadable");
|
||||
expect(sourceFingerprint().sourceErrors).toContain("skills/paperclip/scripts/paperclip-issue-update.sh");
|
||||
});
|
||||
it("records an absent historical recipe without introducing its content or hiding other read errors", () => {
|
||||
vi.mocked(readFileSync).mockImplementation(() => Buffer.from("unchanged"));
|
||||
const present = stockHarnessSourceDigest();
|
||||
|
||||
@@ -7,14 +7,16 @@ import { readStockInstructionVariant } from "./stock-harness-instruction-variant
|
||||
// constants. Otherwise a larger shipped manual could silently update the oracle.
|
||||
export const STOCK_HIRE_IDENTITY = "You are an agent in a Paperclip company.\n";
|
||||
export const STOCK_TEMPLATE_IDENTITY = "You are agent";
|
||||
const SKILL_SOURCES = ["../../skills/paperclip/SKILL.md", "../../skills/paperclip/references/issue-documents.md"];
|
||||
const SKILL_SOURCES = ["../../skills/paperclip/SKILL.md", "../../skills/paperclip/references/issue-documents.md",
|
||||
"../../scripts/paperclip-issue-update.sh", "../../skills/paperclip/scripts/paperclip-issue-update.sh"];
|
||||
export function stockHarnessSkillSources() {
|
||||
return SKILL_SOURCES.map(source => {
|
||||
try {
|
||||
return { path: source.replace(/^\.\.\/\.\.\//, ""), present: true,
|
||||
sha256: createHash("sha256").update(readFileSync(new URL(source, import.meta.url))).digest("hex") };
|
||||
} catch (error) {
|
||||
if (source.endsWith("/references/issue-documents.md") && (error as NodeJS.ErrnoException).code === "ENOENT")
|
||||
if ((source.endsWith("/references/issue-documents.md") || source === "../../skills/paperclip/scripts/paperclip-issue-update.sh")
|
||||
&& (error as NodeJS.ErrnoException).code === "ENOENT")
|
||||
return { path: source.replace(/^\.\.\/\.\.\//, ""), present: false, sha256: null };
|
||||
throw error;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user