Skip to content

FF-442: Enforce issue branch matches workspace on wake/checkout#6425

Open
daveliddlefreddies wants to merge 1 commit into
paperclipai:masterfrom
daveliddlefreddies:ff-442-enforce-issue-branch
Open

FF-442: Enforce issue branch matches workspace on wake/checkout#6425
daveliddlefreddies wants to merge 1 commit into
paperclipai:masterfrom
daveliddlefreddies:ff-442-enforce-issue-branch

Conversation

@daveliddlefreddies

Copy link
Copy Markdown

Summary

Prevents coding agents from running on the wrong git branch when using shared project_primary workspaces (incident: FF-425 woke on FF-438's branch).

  • Adds workspace-branch-preflight service: derive expected branch from persisted workspace, template, description links, or ff-<issueNumber>-* prefix; detect shared-cwd conflicts; optional auto-checkout; fail heartbeat before adapter with workspace_branch_preflight.
  • Integrates preflight in heartbeat + sets PAPERCLIP_EXPECTED_BRANCH for adapters.
  • Unit tests (4) + skill/docs updates.

Test plan

  • vitest run server/src/__tests__/workspace-branch-preflight.test.ts
  • pnpm --filter @paperclipai/server typecheck
  • Deploy/restart instance from this build
  • Smoke: wake issue on mismatched branch → run fails with workspace_branch_preflight

FF tracking

[FF-442](https://paperclip instance issue) — Paperclip platform work for Freddie's Flowers.

Made with Cursor

Fail heartbeats before adapter execution when the git branch in
PAPERCLIP_WORKSPACE_CWD does not match the checked-out issue, inject
PAPERCLIP_EXPECTED_BRANCH, refuse shared project_primary cwd conflicts,
and document Step 5.5 branch preflight for operators.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-authored-by: Cursor <cursoragent@cursor.com>
@greptile-apps

greptile-apps Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces a workspace-branch-preflight service that prevents coding agents from running on the wrong git branch when using shared project_primary workspaces (addressing incident FF-425). It derives the expected branch from persisted workspace state, project/issue templates, description PR links, or an ff-<issueNumber>-* prefix heuristic, and integrates the check into the heartbeat path before any adapter runs.

  • Adds workspace-branch-preflight.ts with conflict detection, expected-branch derivation, optional auto-checkout, and a dedicated WorkspaceBranchPreflightError that surfaces as workspace_branch_preflight in the run log.
  • Wires PAPERCLIP_EXPECTED_BRANCH through the heartbeat context, cursor adapter, and server-utils env helpers; updates SKILL.md and issue-workspaces reference docs.
  • Includes 4 unit tests covering extraction, prefix derivation, persisted-branch precedence, and match semantics.

Confidence Score: 3/5

The preflight guard is a safety-critical path; the auto-checkout branch has an exception-handling gap that surfaces as a confusing adapter_failed error, and the env injection in workspace-runtime.ts is wired up but never passed to the call site.

The auto-checkout path in checkoutGitBranch propagates a raw execFile error on failure, bypassing the WorkspaceBranchPreflightError wrapper the heartbeat outer-catch relies on. Additionally, buildWorkspaceCommandEnv exposes expectedBranchName but the sole call site never passes it, leaving PAPERCLIP_EXPECTED_BRANCH unset for workspace provision scripts.

workspace-branch-preflight.ts (auto-checkout error handling) and workspace-runtime.ts (missing call-site wiring for expectedBranchName) need attention before deployment.

Important Files Changed

Filename Overview
server/src/services/workspace-branch-preflight.ts New service implementing branch preflight enforcement. Core logic is sound but auto-checkout path can throw uncaught non-WorkspaceBranchPreflightError (dirty tree, locked index), and case-sensitive prefix matching may reject valid uppercase-prefix branches.
server/src/services/heartbeat.ts Integrates preflight into heartbeat; WorkspaceBranchPreflightError is correctly surfaced as its own error code, but a non-WorkspaceBranchPreflightError from checkoutGitBranch would fall through to adapter_failed.
server/src/services/workspace-runtime.ts Adds expectedBranchName field to buildWorkspaceCommandEnv, but the sole call site does not pass the value, so PAPERCLIP_EXPECTED_BRANCH is never set via the workspace provision path.
packages/adapter-utils/src/server-utils.ts Adds workspaceExpectedBranch to both applyPaperclipWorkspaceEnv and refreshPaperclipWorkspaceEnvForExecution; straightforward and consistent with existing env propagation pattern.
packages/adapters/cursor-local/src/server/execute.ts Reads paperclipExpectedBranch from context with correct fallback chain and passes it through to env refresh.
server/src/tests/workspace-branch-preflight.test.ts Four unit tests covering extraction, prefix derivation, persisted-branch precedence, and match/no-match cases.
skills/paperclip/SKILL.md Adds Step 5.5 documenting branch preflight behavior, override options, and the comment-reopen inheritance note.
skills/paperclip/references/issue-workspaces.md Adds Branch preflight reference section explaining expected-branch derivation, run-failure behavior, and the second-line-of-defense script hook.

Comments Outside Diff (1)

  1. server/src/services/workspace-branch-preflight.ts, line 678-687 (link)

    P1 Uncaught error from failed git checkout in auto-checkout path

    checkoutGitBranch calls execFile without a try/catch. If git checkout fails — e.g., because the working tree has uncommitted changes, a lock file is present, or permissions deny it — the raw execFile error propagates out of enforceWorkspaceBranchPreflight as a non-WorkspaceBranchPreflightError. The outer catch in heartbeat.ts only checks instanceof WorkspaceBranchPreflightError, so the run would be logged with errorCode: "adapter_failed" (misleading) and the branch-mismatch error path would never be reached. Wrapping in try/catch and either pushing a warning + continuing, or re-throwing as WorkspaceBranchPreflightError, would give the correct error code and allow the branch check to still run.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: server/src/services/workspace-branch-preflight.ts
    Line: 678-687
    
    Comment:
    **Uncaught error from failed `git checkout` in auto-checkout path**
    
    `checkoutGitBranch` calls `execFile` without a try/catch. If `git checkout` fails — e.g., because the working tree has uncommitted changes, a lock file is present, or permissions deny it — the raw `execFile` error propagates out of `enforceWorkspaceBranchPreflight` as a non-`WorkspaceBranchPreflightError`. The outer catch in `heartbeat.ts` only checks `instanceof WorkspaceBranchPreflightError`, so the run would be logged with `errorCode: "adapter_failed"` (misleading) and the branch-mismatch error path would never be reached. Wrapping in try/catch and either pushing a warning + continuing, or re-throwing as `WorkspaceBranchPreflightError`, would give the correct error code and allow the branch check to still run.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
server/src/services/workspace-branch-preflight.ts:678-687
**Uncaught error from failed `git checkout` in auto-checkout path**

`checkoutGitBranch` calls `execFile` without a try/catch. If `git checkout` fails — e.g., because the working tree has uncommitted changes, a lock file is present, or permissions deny it — the raw `execFile` error propagates out of `enforceWorkspaceBranchPreflight` as a non-`WorkspaceBranchPreflightError`. The outer catch in `heartbeat.ts` only checks `instanceof WorkspaceBranchPreflightError`, so the run would be logged with `errorCode: "adapter_failed"` (misleading) and the branch-mismatch error path would never be reached. Wrapping in try/catch and either pushing a warning + continuing, or re-throwing as `WorkspaceBranchPreflightError`, would give the correct error code and allow the branch check to still run.

### Issue 2 of 4
server/src/services/workspace-runtime.ts:718-720
**`expectedBranchName` is never passed at the only call site**

The new `expectedBranchName` field is added to the `buildWorkspaceCommandEnv` input type and the env-injection logic is wired up, but the sole call site (workspace provision command, ~line 922) does not pass `expectedBranchName`. As a result, `PAPERCLIP_EXPECTED_BRANCH` will never be set via this code path. If workspace provision scripts are intended to read this variable (e.g., `scripts/paperclip_verify_branch.sh` mentioned in the docs), they will receive an empty/unset value.

### Issue 3 of 4
server/src/services/workspace-branch-preflight.ts:460-462
**Case-sensitive prefix matching may silently reject valid branches**

The `ff-<num>-` prefix is derived by lowercasing the identifier (e.g., "FF-442" → `ff-442-`). But `branchMatchesSpec` uses `String.prototype.startsWith`, which is case-sensitive. A branch created as `FF-442-my-feature` (uppercase project prefix) would fail the check and block the run, even though it is the correct branch for the issue. Consider normalizing both sides to lowercase before the prefix comparison, or documenting that branch names must use lowercase project prefixes.

### Issue 4 of 4
server/src/services/workspace-branch-preflight.ts:1
**PR description is missing required CONTRIBUTING.md template sections**

Per `CONTRIBUTING.md`, every PR must follow the PR template and include: a **Thinking Path** (top-down reasoning from the product down to the specific change), explicit **Risks**, and a **Model Used** section with provider, exact model ID/version, context window size, and capability details. The current description has a summary and test plan but omits the Thinking Path entirely, has no Risks section, and "Made with Cursor" does not satisfy the required model detail (no model ID, no context window size). Please fill out the full PR template.

Reviews (1): Last reviewed commit: "FF-442: enforce workspace branch preflig..." | Re-trigger Greptile

Comment on lines +718 to +720
if (input.expectedBranchName) {
env.PAPERCLIP_EXPECTED_BRANCH = input.expectedBranchName;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 expectedBranchName is never passed at the only call site

The new expectedBranchName field is added to the buildWorkspaceCommandEnv input type and the env-injection logic is wired up, but the sole call site (workspace provision command, ~line 922) does not pass expectedBranchName. As a result, PAPERCLIP_EXPECTED_BRANCH will never be set via this code path. If workspace provision scripts are intended to read this variable (e.g., scripts/paperclip_verify_branch.sh mentioned in the docs), they will receive an empty/unset value.

Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/services/workspace-runtime.ts
Line: 718-720

Comment:
**`expectedBranchName` is never passed at the only call site**

The new `expectedBranchName` field is added to the `buildWorkspaceCommandEnv` input type and the env-injection logic is wired up, but the sole call site (workspace provision command, ~line 922) does not pass `expectedBranchName`. As a result, `PAPERCLIP_EXPECTED_BRANCH` will never be set via this code path. If workspace provision scripts are intended to read this variable (e.g., `scripts/paperclip_verify_branch.sh` mentioned in the docs), they will receive an empty/unset value.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +460 to +462
expectedBranch,
expectedSpec,
currentBranch,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Case-sensitive prefix matching may silently reject valid branches

The ff-<num>- prefix is derived by lowercasing the identifier (e.g., "FF-442" → ff-442-). But branchMatchesSpec uses String.prototype.startsWith, which is case-sensitive. A branch created as FF-442-my-feature (uppercase project prefix) would fail the check and block the run, even though it is the correct branch for the issue. Consider normalizing both sides to lowercase before the prefix comparison, or documenting that branch names must use lowercase project prefixes.

Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/services/workspace-branch-preflight.ts
Line: 460-462

Comment:
**Case-sensitive prefix matching may silently reject valid branches**

The `ff-<num>-` prefix is derived by lowercasing the identifier (e.g., "FF-442" → `ff-442-`). But `branchMatchesSpec` uses `String.prototype.startsWith`, which is case-sensitive. A branch created as `FF-442-my-feature` (uppercase project prefix) would fail the check and block the run, even though it is the correct branch for the issue. Consider normalizing both sides to lowercase before the prefix comparison, or documenting that branch names must use lowercase project prefixes.

How can I resolve this? If you propose a fix, please make it concise.

@@ -0,0 +1,471 @@
import { execFile as execFileCallback } from "node:child_process";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 PR description is missing required CONTRIBUTING.md template sections

Per CONTRIBUTING.md, every PR must follow the PR template and include: a Thinking Path (top-down reasoning from the product down to the specific change), explicit Risks, and a Model Used section with provider, exact model ID/version, context window size, and capability details. The current description has a summary and test plan but omits the Thinking Path entirely, has no Risks section, and "Made with Cursor" does not satisfy the required model detail (no model ID, no context window size). Please fill out the full PR template.

Context Used: CONTRIBUTING.md has a guide for a good PR message ... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/services/workspace-branch-preflight.ts
Line: 1

Comment:
**PR description is missing required CONTRIBUTING.md template sections**

Per `CONTRIBUTING.md`, every PR must follow the PR template and include: a **Thinking Path** (top-down reasoning from the product down to the specific change), explicit **Risks**, and a **Model Used** section with provider, exact model ID/version, context window size, and capability details. The current description has a summary and test plan but omits the Thinking Path entirely, has no Risks section, and "Made with Cursor" does not satisfy the required model detail (no model ID, no context window size). Please fill out the full PR template.

**Context Used:** CONTRIBUTING.md has a guide for a good PR message ... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

How can I resolve this? If you propose a fix, please make it concise.

@nickyleach nickyleach mentioned this pull request Jul 7, 2026
14 tasks
nickyleach added a commit that referenced this pull request Jul 7, 2026
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents run inside git worktrees; the heartbeat system establishes a
workspace branch and tracks it through checkout, realization, restore,
and finalization
> - When the live git branch diverges from the recorded workspace branch
mid-change (branch incoherence), the heartbeat must fail closed with
`workspace_validation_failed` and block the source issue with a recovery
action
> - There were no embedded-Postgres tests covering this fail-closed
behavior across the three interlock call sites: fresh git-worktree
realization, persisted workspace restore, and heartbeat finalization
> - This PR adds a single test file covering all three call sites with
an embedded-Postgres heartbeat test harness and asserts the exact
fail-closed outcome and evidence fields
> - The benefit is confidence that branch-incoherence containment is
correct and regressions in the interlock chain are caught before they
silently corrupt workspace state

## Linked Issues or Issue Description

Refs: #6425 (related: enforce issue branch matches workspace on
wake/checkout)

No pre-existing public GitHub issue for this specific reproduction test
gap. The underlying problem:

**Bug / gap:** The heartbeat's branch-incoherence containment was
untested by any embedded-Postgres integration test. All three call sites
— fresh git-worktree realization, persisted workspace restore, and
finalization — could regress without detection. The fail-closed path
(`workspace_validation_failed` + source-issue block + deduped recovery
action) and the evidence fields surfaced to operators were unverified.

## What Changed

- Added
`server/src/__tests__/heartbeat-workspace-branch-containment.test.ts`
with embedded-Postgres integration tests covering:
- **Fresh git-worktree realization** — heartbeat detects branch
divergence at workspace setup and fails closed
- **Persisted workspace restore** — re-entering a previously-established
workspace with a diverged branch fails closed instead of being silently
coerced into a generic reuse-failure path
- **Heartbeat finalization** — any late-stage branch incoherence
detected at finalization fails closed
- Asserts fail-closed behavior in all three cases: run status =
`workspace_validation_failed`, source issue status = `blocked`, exactly
one deduped workspace-validation recovery action on the blocked issue,
sibling issues on same/other workspaces retain their status
- Asserts evidence completeness: `expectedBranch`, `liveBranch`,
`expectedHead`, `liveHead`, `cleanliness`, `ancestryVerdict`,
`plainLanguageReason`, and `recoveryGuidance` fields are present and
correct on the run
- Ensures release/promotion errors after setup failures are logged (not
silently swallowed), making cleanup failures observable

## Verification

```bash
pnpm exec vitest run server/src/__tests__/heartbeat-workspace-branch-containment.test.ts
pnpm --filter @paperclipai/server typecheck
```

All 3 tests pass, typecheck clean.

## Risks

Low. Test-only change — no production code paths are modified. The tests
use an embedded-Postgres harness and do not touch any shared or live
database.

## Model Used

Claude Sonnet 4.6 (`claude-sonnet-4-6`) via Claude Code — tool use mode,
standard context window.

## 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
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant