From 592cade5a6f03e69669a221226b21991dba5a013 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sat, 1 Aug 2026 17:43:42 -0700 Subject: [PATCH] feat(server): trigger the push-capability preflight from the issue's stated PR deliverable (#10659) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Runs that must push to GitHub have a pre-dispatch credential preflight (`push_write_credential_missing`) so the missing token surfaces as a configuration-incomplete blocker instead of a late runtime failure > - The preflight only triggers when the issue mentions the GitHub PR workflow *skill* — routine-created issues and agent-to-agent handoffs rarely do, even when their text literally says "push the branch and open a PR" > - In practice the credential gap then surfaced only after implementation and review were complete, stranding finished work > - This pull request adds a conservative, verb-anchored text heuristic over the issue title and description as a second preflight trigger > - The benefit is that the credential ask reaches the human before any work is burned ## Linked Issues or Issue Description Fixes #10644 (completes the prevention set with #10648, #10650, #10658) ## What Changed - `issueTextImpliesPrDeliverable(text)`: matches verb-anchored deliverable statements — "open/create/raise/submit a (draft) pull request/PR", "push … branch/remote/origin/upstream". Verb anchoring deliberately ignores passing mentions ("the PR merged yesterday", "PR feedback addressed"). - `requiresPushCapabilityPreflight` takes the issue's title+description and ORs the text heuristic with the existing skill-mention trigger; adapter-type and issue gating are unchanged. The run-dispatch call site threads the already-loaded issue text — no extra query. ## Verification - `pnpm vitest run server/src/__tests__/heartbeat-workspace-session.test.ts` — new heuristic matrix (4 positive, 6 negative including null/empty) and preflight-by-text cases (text triggers, passing mention does not, no issue → no preflight); 122 tests total. - `cd server && pnpm run typecheck`. ## Risks - A false positive turns into a configuration-incomplete blocker asking for a GitHub token on an issue that didn't need one — the heuristic is intentionally conservative (verb-anchored) to keep that rare, and the blocker names the exact remediation. - No behavior change for issues that neither mention the skill nor state a PR deliverable. ## Model Used Claude Fable 5 (`claude-fable-5`, Anthropic) via Claude Code — extended thinking, agentic tool use. No other models involved. ## 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 --- .../heartbeat-workspace-session.test.ts | 52 +++++++++++++++++++ server/src/services/heartbeat.ts | 33 +++++++++++- 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index de0562f3be..a7ef9da9d5 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -30,6 +30,7 @@ import { resolveExecutionWorkspaceReuseProvisioningPolicy, resolveNextSessionState, resolveTaskSessionConfigFreshness, + issueTextImpliesPrDeliverable, requiresPushCapabilityPreflight, resolveWorkspaceAfterLowTrustPreflight, resolveRuntimeSessionParamsForWorkspace, @@ -702,7 +703,58 @@ describe("assertPushCapabilityCheckoutValid", () => { }); }); +describe("issueTextImpliesPrDeliverable", () => { + it("matches verb-anchored PR deliverables", () => { + expect(issueTextImpliesPrDeliverable("Review and open PR for the CI shard split")).toBe(true); + expect(issueTextImpliesPrDeliverable("Push the branch and open a pull request")).toBe(true); + expect(issueTextImpliesPrDeliverable("Each run: make the change and open a draft PR")).toBe(true); + expect(issueTextImpliesPrDeliverable("push feature work to origin when done")).toBe(true); + }); + + it("ignores passing mentions and unrelated text", () => { + expect(issueTextImpliesPrDeliverable("The PR merged yesterday; investigate the regression")).toBe(false); + expect(issueTextImpliesPrDeliverable("PR feedback addressed")).toBe(false); + expect(issueTextImpliesPrDeliverable("Update the pricing page copy")).toBe(false); + expect(issueTextImpliesPrDeliverable("a proper approach to pushing back on scope")).toBe(false); + expect(issueTextImpliesPrDeliverable(null)).toBe(false); + expect(issueTextImpliesPrDeliverable("")).toBe(false); + }); + + it("ignores non-git uses of push", () => { + expect(issueTextImpliesPrDeliverable("push back on the upstream dependency change")).toBe(false); + expect(issueTextImpliesPrDeliverable("push back the branch cut date")).toBe(false); + expect(issueTextImpliesPrDeliverable("push notifications for mobile")).toBe(false); + // Git shapes still match. + expect(issueTextImpliesPrDeliverable("pushing the release branch")).toBe(true); + expect(issueTextImpliesPrDeliverable("push feature work to origin when done")).toBe(true); + }); +}); + describe("requiresPushCapabilityPreflight", () => { + it("enables the guard when the issue text states the PR deliverable", () => { + expect(requiresPushCapabilityPreflight({ + adapterType: "codex_local", + issueId: "issue-1", + explicitRunScopedSkillKeys: [], + issueText: "Push ci/shard-split and open PR", + })).toBe(true); + + expect(requiresPushCapabilityPreflight({ + adapterType: "codex_local", + issueId: "issue-1", + explicitRunScopedSkillKeys: [], + issueText: "Investigate why the PR checks were slow", + })).toBe(false); + + // Without an issue there is nothing to preflight. + expect(requiresPushCapabilityPreflight({ + adapterType: "codex_local", + issueId: null, + explicitRunScopedSkillKeys: [], + issueText: "open a PR", + })).toBe(false); + }); + it("only enables the guard when the issue explicitly mentions the GitHub PR workflow skill", () => { expect(requiresPushCapabilityPreflight({ adapterType: "codex_local", diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 4ae1857aaa..b535b0d1ce 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -640,14 +640,44 @@ function hasGithubPrWorkflowSkill(desiredSkills: string[]) { }); } +/** + * Conservative, verb-anchored patterns for an issue whose deliverable is a + * pushed branch or opened pull request. Verb anchoring keeps passing mentions + * ("the PR merged yesterday") from triggering the credential preflight. + */ +const PR_DELIVERABLE_TEXT_PATTERNS = [ + /\bopen(?:s|ed|ing)?\s+(?:a\s+|the\s+|an?\s+draft\s+)?(?:pull\s+request|pr)\b/i, + /\b(?:create|creates|created|creating|raise|raises|raised|raising|submit|submits|submitted|submitting)\s+(?:a\s+|the\s+|an?\s+draft\s+)?(?:pull\s+request|pr)\b/i, + // "push back" (an objection or a date) is never a git push, and bare + // proximity to words like "upstream" over-matches ("push back on the + // upstream dependency change"); require the git object shape instead. + /\bpush(?:es|ed|ing)?\b(?!\s+back\b)[^.\n]{0,40}\bbranch(?:es)?\b/i, + /\bpush(?:es|ed|ing)?\s+(?:[^.\n]{0,30}\s)?to\s+(?:origin|remote|upstream|github)\b/i, +]; + +export function issueTextImpliesPrDeliverable(text: string | null | undefined): boolean { + if (!text) return false; + return PR_DELIVERABLE_TEXT_PATTERNS.some((pattern) => pattern.test(text)); +} + export function requiresPushCapabilityPreflight(input: { adapterType: string; issueId: string | null | undefined; explicitRunScopedSkillKeys: string[]; + /** + * Issue title + description. Routine-created issues and agent-to-agent + * handoffs rarely mention the GitHub PR workflow skill explicitly, yet + * state the PR deliverable in plain text — without this, the credential + * gap only surfaces after the implementation and review work is done. + */ + issueText?: string | null; }) { return Boolean(input.issueId) && GIT_SENSITIVE_LOCAL_ADAPTER_TYPES.has(input.adapterType) - && hasGithubPrWorkflowSkill(input.explicitRunScopedSkillKeys); + && ( + hasGithubPrWorkflowSkill(input.explicitRunScopedSkillKeys) + || issueTextImpliesPrDeliverable(input.issueText) + ); } const LOW_TRUST_SENSITIVE_ENV_KEY_RE = @@ -13294,6 +13324,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) adapterType: agent.adapterType, issueId, explicitRunScopedSkillKeys: runScopedMentionedSkillKeys, + issueText: issueRef ? `${issueRef.title ?? ""}\n${issueRef.description ?? ""}` : null, }); const { resolvedConfig, secretKeys, secretManifest } = await resolveExecutionRunAdapterConfig({ companyId: agent.companyId,